diff --git a/CLAUDE.md b/CLAUDE.md index 31c8570..7be15f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,10 @@ ## Repository Structure -- **`cmd/`** - Binary entry points. Contains `ghost/main.go` (the main CLI binary, which sets up context/signal handling and delegates to the internal command infrastructure), `npm-publisher/` (a CI tool that generates and publishes npm packages for each platform), and `generate-docs/` (generates Markdown CLI reference docs to `docs/cli/`). +- **`cmd/`** - Binary entry points. Contains `ghost/main.go` (the main CLI binary, which sets up context/signal handling and delegates to the internal command infrastructure), `npm-publisher/` (a CI tool that generates and publishes npm packages for each platform), `generate-docs/` (generates Markdown CLI reference docs to `docs/cli/`), and `generate-tutorial-docs/` (renders every tutorial in the `allTutorials()` registry to `docs/tutorials/`, sharing source-of-truth step data with the live `ghost tutorial` command). - **`internal/`** - All core application logic (non-public Go packages). - - **`internal/cmd/`** - Cobra command implementations for all CLI commands (init, create, fork, list, delete, pause, resume, connect, psql, sql, schema, logs, password, pricing, rename, status, feedback, api-key, login, logout, config, mcp, version, upgrade, completion, payment). Each command lives in its own file, named to match the command in snake_case (e.g. `ghost payment list` → `payment_list.go`). Helper files like `completion.go`, `errors.go`, and `logger.go` contain shared utilities. Commands that are not yet ready for public release can be gated behind the `GHOST_EXPERIMENTAL` env var (see `internal/common/app.go`'s `App.Experimental` field). + - **`internal/tutorial/`** - Data definitions for Ghost's guided tutorials. Each `Tutorial` is a struct bundling `Filename`, narrative (`Title`/`Callout`/`Intro`), an ordered `[]Step`, and an optional `DeleteStep`. Blocks carry CLI args, prose, expected output (markdown-only), and a `Target` enum that scopes them to CLI runs, doc renders, or both. `All()` is the registry imported by both the live CLI command and the `generate-tutorial-docs` binary. + - **`internal/cmd/`** - Cobra command implementations for all CLI commands (init, tutorial, create, fork, list, delete, pause, resume, connect, psql, sql, schema, logs, password, pricing, rename, status, feedback, api-key, login, logout, config, mcp, version, upgrade, completion, payment). Each command lives in its own file, named to match the command in snake_case (e.g. `ghost payment list` → `payment_list.go`). Helper files like `completion.go`, `errors.go`, and `logger.go` contain shared utilities. Commands that are not yet ready for public release can be gated behind the `GHOST_EXPERIMENTAL` env var (see `internal/common/app.go`'s `App.Experimental` field). - **`internal/api/`** - API client layer. Includes an OpenAPI-generated REST client (`client.go`, `types.go`), shared HTTP client singleton, and request/response types. **Do not edit `client.go` or `types.go` by hand** — they are generated from `openapi.yaml` (see [Code Generation](#code-generation)). The `mock/` subdirectory contains a generated mock of `ClientWithResponsesInterface` for use in tests. - **`internal/config/`** - Configuration management. Handles config file loading (via Viper), credential storage (keyring with file fallback), and version checking. - **`internal/common/`** - Shared business logic used across commands and MCP tools. Includes API client initialization, database connection/schema/query utilities, error handling with exit codes, and version update checks. @@ -308,6 +309,10 @@ After adding new commands, directories, or major functionality, update: - **`README.md`** — Update the Commands table and Usage examples as needed. - **`docs/`** — Regenerate the CLI reference docs (see below). +### Tutorial Docs + +The live `ghost tutorial` command and the markdown tutorials under `docs/tutorials/` share a single source of truth in `internal/tutorial/`. Each `tutorial.Tutorial` bundles its filename, title/callout/intro narrative, ordered `[]tutorial.Step`, and optional `DeleteStep`. Steps contain `tutorial.Block`s whose `Target` field controls visibility: `TargetAll` (default), `TargetCLIOnly`, or `TargetDocsOnly`. A block can carry CLI args, an `ExpectedOutput` string shown only in the markdown, and side-effect tracking (`CreatesDatabase`/`RemovesDatabase`) used by the live runtime. The renderer in `cmd/generate-tutorial-docs/docs.go` is content-agnostic — it walks the struct without any hard-coded text or step numbers, and `tutorial.FormatCommand` is shared with the CLI echo so multi-statement SQL formatting stays in sync. To add a new tutorial, create a `BuildXxxTutorial` function in `internal/tutorial/` and append it to `tutorial.All()`. After editing tutorial content, regenerate the docs with `go run ./cmd/generate-tutorial-docs` (`-out` defaults to `./docs/tutorials`). The golden test `TestAllTutorialDocsMatchGoldenFiles` (in `cmd/generate-tutorial-docs/`) iterates the registry and fails if any on-disk markdown drifts. + ### CLI Reference Docs The `docs/cli/` directory contains generated Markdown reference documentation for every CLI command. These are produced by `cmd/generate-docs`, which uses Cobra's `doc` package to walk the command tree and emit one file per command. diff --git a/README.md b/README.md index 01dd1f2..ec1a7a5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ ghost create # Create a new Postgres database ghost list # List all databases ``` +Learn more about ghost's forking workflow and other features with the interactive tutorial: + +```bash +ghost tutorial +``` + ## Commands | Command | Description | @@ -84,6 +90,7 @@ ghost list # List all databases | `schema` | Display database schema information | | `share` | Share a database | | `sql` | Execute SQL query on a database | +| `tutorial` | Run an interactive Ghost tutorial | | `usage` | Show space usage | | `upgrade` | Upgrade the ghost CLI to the latest version | | `version` | Show version information | diff --git a/cmd/generate-tutorial-docs/docs.go b/cmd/generate-tutorial-docs/docs.go new file mode 100644 index 0000000..847fe34 --- /dev/null +++ b/cmd/generate-tutorial-docs/docs.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/timescale/ghost/internal/tutorial" +) + +// renderTutorialMarkdown walks a tutorial.Tutorial and emits markdown. The +// renderer is content-agnostic: every piece of tutorial-specific text comes +// from the struct, so updating a tutorial only requires editing its +// definition in the tutorial package. +func renderTutorialMarkdown(t tutorial.Tutorial) string { + var sb strings.Builder + + fmt.Fprintf(&sb, "# %s\n\n", t.Title) + if t.Callout != "" { + fmt.Fprintf(&sb, "> %s\n\n", t.Callout) + } + writeParagraphs(&sb, t.Intro) + + for i, step := range t.Steps { + writeStepMarkdown(&sb, i+1, step) + } + writeStepMarkdown(&sb, len(t.Steps)+1, t.DeleteStep) + + return strings.TrimRight(sb.String(), "\n") + "\n" +} + +func writeParagraphs(sb *strings.Builder, paragraphs []string) { + for _, p := range paragraphs { + sb.WriteString(p) + sb.WriteString("\n\n") + } +} + +func writeStepMarkdown(sb *strings.Builder, number int, step tutorial.Step) { + fmt.Fprintf(sb, "## Step %d — %s\n\n", number, step.Title) + + visibleBlocks := tutorial.FilterBlocks(step.Blocks, tutorial.TargetDocsOnly) + + if step.JoinedBlocks { + for _, block := range visibleBlocks { + if block.Prose != "" { + sb.WriteString(block.Prose + "\n\n") + } + } + commands := make([]string, 0, len(visibleBlocks)) + outputs := make([]string, 0, len(visibleBlocks)) + for _, block := range visibleBlocks { + if len(block.Args) == 0 { + continue + } + commands = append(commands, tutorial.FormatCommand(block.Args)) + if block.ExpectedOutput != "" { + outputs = append(outputs, block.ExpectedOutput) + } + } + if len(commands) > 0 { + writeCodeBlock(sb, "bash", strings.Join(commands, "\n")) + } + if len(outputs) > 0 { + writeCodeBlock(sb, "", strings.Join(outputs, "\n")) + } + return + } + + for _, block := range visibleBlocks { + if block.Prose != "" { + sb.WriteString(block.Prose + "\n\n") + } + if len(block.Args) > 0 { + writeCodeBlock(sb, "bash", tutorial.FormatCommand(block.Args)) + if block.ExpectedOutput != "" { + writeCodeBlock(sb, "", block.ExpectedOutput) + } + } + } +} + +func writeCodeBlock(sb *strings.Builder, lang, content string) { + sb.WriteString("```") + sb.WriteString(lang) + sb.WriteString("\n") + sb.WriteString(content) + sb.WriteString("\n```\n\n") +} diff --git a/cmd/generate-tutorial-docs/docs_test.go b/cmd/generate-tutorial-docs/docs_test.go new file mode 100644 index 0000000..495eba9 --- /dev/null +++ b/cmd/generate-tutorial-docs/docs_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/timescale/ghost/internal/tutorial" +) + +func TestAllTutorialDocsMatchGoldenFiles(t *testing.T) { + for _, tut := range tutorial.All() { + t.Run(tut.Filename, func(t *testing.T) { + goldenPath := filepath.Join("..", "..", "docs", "tutorials", tut.Filename) + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("failed to read %s: %v", goldenPath, err) + } + got := renderTutorialMarkdown(tut) + if diff := cmp.Diff(string(want), got); diff != "" { + t.Errorf("%s out of date (run `go run ./cmd/generate-tutorial-docs`):\n%s", goldenPath, diff) + } + }) + } +} diff --git a/cmd/generate-tutorial-docs/main.go b/cmd/generate-tutorial-docs/main.go new file mode 100644 index 0000000..7525968 --- /dev/null +++ b/cmd/generate-tutorial-docs/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/timescale/ghost/internal/tutorial" +) + +func main() { + outDir := flag.String("out", "./docs/tutorials", "Output directory") + flag.Parse() + + if err := os.MkdirAll(*outDir, 0o755); err != nil { + log.Fatal(err) + } + + for _, t := range tutorial.All() { + path := filepath.Join(*outDir, t.Filename) + if err := os.WriteFile(path, []byte(renderTutorialMarkdown(t)), 0o644); err != nil { + log.Fatal(err) + } + fmt.Printf("Generated %s\n", path) + } +} diff --git a/docs/cli/ghost.md b/docs/cli/ghost.md index 42f760b..be103a1 100644 --- a/docs/cli/ghost.md +++ b/docs/cli/ghost.md @@ -48,6 +48,7 @@ Ghost is a command-line interface for managing PostgreSQL databases. * [ghost schema](ghost_schema.md) - Display database schema information * [ghost share](ghost_share.md) - Share a database * [ghost sql](ghost_sql.md) - Execute SQL query on a database +* [ghost tutorial](ghost_tutorial.md) - Run an interactive Ghost tutorial * [ghost upgrade](ghost_upgrade.md) - Upgrade the ghost CLI to the latest version * [ghost usage](ghost_usage.md) - Show space usage * [ghost version](ghost_version.md) - Show version information diff --git a/docs/cli/ghost_tutorial.md b/docs/cli/ghost_tutorial.md new file mode 100644 index 0000000..4442457 --- /dev/null +++ b/docs/cli/ghost_tutorial.md @@ -0,0 +1,47 @@ +--- +title: "ghost tutorial" +slug: "ghost_tutorial" +description: "CLI reference for ghost tutorial" +--- + +## ghost tutorial + +Run an interactive Ghost tutorial + +### Synopsis + +Run an interactive tutorial that demonstrates the core Ghost workflow. + +The tutorial creates a temporary database, inserts +sample data, forks the database, mutates the fork, compares the original and +fork, and then asks whether to delete or keep the tutorial databases. Each step +explains and echoes the equivalent Ghost CLI command before running it. + +``` +ghost tutorial [flags] +``` + +### Examples + +``` + ghost tutorial +``` + +### Options + +``` + -h, --help help for tutorial +``` + +### Options inherited from parent commands + +``` + --analytics enable/disable usage analytics (default true) + --color enable colored output (default true) + --config-dir string config directory (default "~/.config/ghost") + --version-check check for updates (default true) +``` + +### SEE ALSO + +* [ghost](ghost.md) - CLI for managing Postgres databases diff --git a/docs/tutorials/learn-the-basics.md b/docs/tutorials/learn-the-basics.md new file mode 100644 index 0000000..7ff1b81 --- /dev/null +++ b/docs/tutorials/learn-the-basics.md @@ -0,0 +1,125 @@ +# Learn the basics of Ghost + +> Run `ghost tutorial` to step through this tutorial live in the CLI. + +This guided tour walks through the core Ghost workflow: create a database, load data, fork it, change the fork, compare the results, and clean up. Each step shows the exact `ghost` command the live tutorial runs and the output you can expect to see. + +Throughout this guide, the temporary databases are named `tutorial-example` and `tutorial-example-fork`. The live `ghost tutorial` command generates a random suffix instead. + +## Step 1 — Create a database + +```bash +ghost create --name tutorial-example --wait +``` + +``` +Created database 'tutorial-example' +ID: abc1234567 +Connection: postgresql://tsdbadmin:@:5432/tsdb?sslmode=require +``` + +## Step 2 — Add sample data with SQL + +The sql command connects to the database and executes the query you provide. + +```bash +ghost sql tutorial-example \ + "CREATE TABLE ghost_tutorial_items (id serial PRIMARY KEY, name text NOT NULL, location text NOT NULL); + INSERT INTO ghost_tutorial_items (name, location) VALUES ('apples', 'original'), ('bananas', 'original'), ('carrots', 'original');" +``` + +``` +CREATE TABLE +INSERT 0 3 +``` + +## Step 3 — Query the original database + +```bash +ghost sql tutorial-example "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" +``` + +``` + id │ name │ location +────┼─────────┼────────── + 1 │ apples │ original + 2 │ bananas │ original + 3 │ carrots │ original +(3 rows) +``` + +## Step 4 — Fork the database + +Forking creates an independent copy you can safely experiment with. + +```bash +ghost fork tutorial-example --name tutorial-example-fork --wait +``` + +``` +Forked 'tutorial-example' → 'tutorial-example-fork' +ID: def1234567 +Connection: postgresql://tsdbadmin:@:5432/tsdb?sslmode=require +``` + +## Step 5 — Mutate the fork + +These changes are made only on the fork. + +```bash +ghost sql tutorial-example-fork \ + "INSERT INTO ghost_tutorial_items (name, location) VALUES ('dragonfruit', 'fork'); + UPDATE ghost_tutorial_items SET location = 'fork' WHERE name = 'bananas';" +``` + +``` +INSERT 0 1 +UPDATE 1 +``` + +## Step 6 — Compare the original and the fork + +First, query the original database: + +```bash +ghost sql tutorial-example "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" +``` + +``` + id │ name │ location +────┼─────────┼────────── + 1 │ apples │ original + 2 │ bananas │ original + 3 │ carrots │ original +(3 rows) +``` + +Now query the fork. Notice the extra row and updated value: + +```bash +ghost sql tutorial-example-fork "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" +``` + +``` + id │ name │ location +────┼─────────────┼────────── + 1 │ apples │ original + 2 │ bananas │ fork + 3 │ carrots │ original + 4 │ dragonfruit │ fork +(4 rows) +``` + +## Step 7 — Delete the tutorial databases + +When the main steps finish, the live tutorial asks whether to delete the databases. To run the cleanup step yourself, use the following. + +```bash +ghost delete tutorial-example-fork --confirm +ghost delete tutorial-example --confirm +``` + +``` +Deleted 'tutorial-example-fork' (def1234567) +Deleted 'tutorial-example' (abc1234567) +``` diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 53a2bd2..580350e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -103,6 +103,7 @@ func buildRootCmd() (*cobra.Command, *common.App, error) { cmd.AddCommand(buildConfigCmd(app)) cmd.AddCommand(buildMCPCmd(app)) cmd.AddCommand(buildInitCmd(app)) + cmd.AddCommand(buildTutorialCmd(app)) cmd.AddCommand(buildLoginCmd(app)) cmd.AddCommand(buildLogoutCmd(app)) cmd.AddCommand(buildCreateCmd(app)) diff --git a/internal/cmd/tutorial.go b/internal/cmd/tutorial.go new file mode 100644 index 0000000..6538c3a --- /dev/null +++ b/internal/cmd/tutorial.go @@ -0,0 +1,327 @@ +package cmd + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "strings" + + lipgloss "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/term" + + "github.com/timescale/ghost/internal/common" + "github.com/timescale/ghost/internal/tutorial" + "github.com/timescale/ghost/internal/util" +) + +var ( + tutorialGenerateNameSuffix = generateTutorialNameSuffix + + tutorialTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Cyan) + tutorialStepStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Cyan) + tutorialRuleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + tutorialProseStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + tutorialLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")) + tutorialCommandStyle = lipgloss.NewStyle().Foreground(lipgloss.Green) + tutorialPromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + tutorialSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Green) +) + +func buildTutorialCmd(app *common.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "tutorial", + Short: "Run an interactive Ghost tutorial", + Long: `Run an interactive tutorial that demonstrates the core Ghost workflow. + +The tutorial creates a temporary database, inserts sample data, forks the database, +mutates the fork, compares the original and fork, and then asks whether to delete +or keep the tutorial databases. Each step explains and echoes the equivalent Ghost +CLI command before running it.`, + Example: ` ghost tutorial`, + Args: cobra.NoArgs, + ValidArgsFunction: cobra.NoFileCompletions, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runTutorial(cmd, app) + }, + } + + return cmd +} + +func runTutorial(cmd *cobra.Command, app *common.App) (runErr error) { + createdDatabaseNames := make([]string, 0, 2) + defer func() { + if runErr == nil || len(createdDatabaseNames) == 0 { + return + } + + cmd.PrintErrln() + cmd.PrintErrln("Tutorial stopped before cleanup. To delete created databases later, run:") + for i := len(createdDatabaseNames) - 1; i >= 0; i-- { + cmd.PrintErrf(" ghost delete %s --confirm\n", createdDatabaseNames[i]) + } + }() + + if !util.IsTerminal(cmd.InOrStdin()) { + return errors.New("cannot run tutorial: stdin is not a terminal") + } + + cfg, _, _, err := app.GetAll() + if err != nil { + return err + } + if cfg.ReadOnly { + return errors.New("cannot run tutorial while read_only is enabled; run `ghost config set read_only false` to allow tutorial writes") + } + + nameSuffix, err := tutorialGenerateNameSuffix() + if err != nil { + return err + } + + originalDatabaseName := "tutorial-" + nameSuffix + forkDatabaseName := originalDatabaseName + "-fork" + promptReader := newTutorialPromptReader(cmd.InOrStdin()) + + cmd.Println(tutorialTitleStyle.Render("Welcome to the Ghost tutorial!")) + cmd.Println() + cmd.Println(tutorialProseStyle.Render("This guided tour will run real Ghost commands to demonstrate the core workflow:")) + cmd.Println(tutorialProseStyle.Render("create a database, load data, fork it, change the fork, compare the results, and clean up.")) + cmd.Println() + cmd.Println(tutorialLabelStyle.Render("Temporary database names")) + cmd.Printf(" original: %s\n", originalDatabaseName) + cmd.Printf(" fork: %s\n", forkDatabaseName) + cmd.Println() + + t := tutorial.BuildLearnTheBasicsTutorial(originalDatabaseName, forkDatabaseName) + for i, step := range t.Steps { + if err := runTutorialStep(cmd, promptReader, i+1, step, &createdDatabaseNames); err != nil { + return err + } + } + + deleteDatabases, err := promptTutorialCleanup(cmd, promptReader) + if err != nil { + return err + } + + if !deleteDatabases { + cmd.Println() + cmd.Println(tutorialSuccessStyle.Render("Keeping the tutorial databases.")) + cmd.Println(tutorialProseStyle.Render("To clean them up later, run:")) + cmd.Println(tutorialCommandStyle.Render(" ghost delete " + forkDatabaseName + " --confirm")) + cmd.Println(tutorialCommandStyle.Render(" ghost delete " + originalDatabaseName + " --confirm")) + return nil + } + + cmd.Println() + if err := runTutorialStep(cmd, promptReader, len(t.Steps)+1, t.DeleteStep, &createdDatabaseNames); err != nil { + return err + } + + cmd.Println(tutorialSuccessStyle.Render("Tutorial complete. You created, queried, forked, changed, compared, and deleted Ghost databases.")) + return nil +} + +func runTutorialStep(cmd *cobra.Command, promptReader *tutorialPromptReader, number int, step tutorial.Step, createdDatabaseNames *[]string) error { + printTutorialStep(cmd, number, step.Title) + visibleBlocks := tutorial.FilterBlocks(step.Blocks, tutorial.TargetCLIOnly) + for i, block := range visibleBlocks { + if block.Prose != "" { + cmd.Println(tutorialProseStyle.Render(block.Prose)) + } + if len(block.Args) > 0 { + if err := runTutorialCommand(cmd, promptReader, block.Args); err != nil { + return err + } + } + if block.CreatesDatabase != "" { + *createdDatabaseNames = append(*createdDatabaseNames, block.CreatesDatabase) + } + if block.RemovesDatabase != "" { + *createdDatabaseNames = removeTutorialName(*createdDatabaseNames, block.RemovesDatabase) + } + isLast := i == len(visibleBlocks)-1 + if !step.JoinedBlocks || isLast { + cmd.Println() + } + } + return nil +} + +// runTutorialCommand displays the equivalent CLI invocation, waits for the +// user to press a key, then re-enters the root command tree to actually run +// it. The sub-execution writes directly to the user's real stdout/stderr so +// that output streams in real time and progress indicators (like the +// --wait spinner) work naturally. +func runTutorialCommand(cmd *cobra.Command, promptReader *tutorialPromptReader, args []string) error { + printTutorialCommand(cmd, tutorial.FormatCommand(args)) + cmd.PrintErr(tutorialPromptStyle.Render("Press any key to run this command...")) + if err := promptReader.readKey(cmd.Context()); err != nil { + return fmt.Errorf("failed to read key: %w", err) + } + if util.IsTerminal(cmd.ErrOrStderr()) { + // Erase the prompt line in place so it doesn't clutter scrollback, + // leaving a blank line in its place for visual separation. + cmd.PrintErr("\r\033[2K\n") + } else { + cmd.PrintErrln() + } + + root := cmd.Root() + // Forward any persistent flags the user set on the outer invocation + // (e.g. --config-dir) so the sub-execution uses the same config and + // state. --version-check=false is appended last so it overrides any + // forwarded version-check value and prevents the update-available + // banner from appearing once per step. + root.SetArgs(append(tutorialForwardedFlags(root), append(args, "--version-check=false")...)) + if err := root.ExecuteContext(cmd.Context()); err != nil { + // The sub-command's cobra dispatch already printed "Error: ..." + // to stderr; silence the outer print so it doesn't appear twice. + cmd.SilenceErrors = true + return err + } + return nil +} + +// tutorialForwardedFlags returns persistent flag args the user set on the +// outer invocation, so sub-executions see the same values. pflag.Visit only +// visits flags whose Changed field is true, so default values are not +// forwarded (they'll re-evaluate naturally during the sub-execution's flag +// parsing). +func tutorialForwardedFlags(root *cobra.Command) []string { + var forwarded []string + root.PersistentFlags().Visit(func(f *pflag.Flag) { + forwarded = append(forwarded, fmt.Sprintf("--%s=%s", f.Name, f.Value.String())) + }) + return forwarded +} + +func printTutorialStep(cmd *cobra.Command, step int, title string) { + heading := fmt.Sprintf("Step %d / %s", step, title) + cmd.Println(tutorialStepStyle.Render(heading)) + cmd.Println(tutorialRuleStyle.Render(strings.Repeat("-", len(heading)))) +} + +func printTutorialCommand(cmd *cobra.Command, command string) { + for i, line := range strings.Split(command, "\n") { + prefix := "$ " + if i > 0 { + prefix = " " + } + cmd.Println(tutorialCommandStyle.Render(prefix + line)) + } +} + +func removeTutorialName(names []string, name string) []string { + for i, n := range names { + if n == name { + return append(names[:i], names[i+1:]...) + } + } + return names +} + +func promptTutorialCleanup(cmd *cobra.Command, promptReader *tutorialPromptReader) (bool, error) { + for { + cmd.PrintErr("Delete the tutorial databases now? [Y/n] ") + answer, err := promptReader.readLine(cmd.Context()) + if err != nil { + return false, fmt.Errorf("failed to read cleanup choice: %w", err) + } + + switch strings.ToLower(strings.TrimSpace(answer)) { + case "", "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + cmd.PrintErrln("Please answer y or n.") + } + } +} + +func generateTutorialNameSuffix() (string, error) { + bytes := make([]byte, 3) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate tutorial database name: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +type tutorialPromptReader struct { + input io.Reader + bufferedInput *bufio.Reader +} + +func newTutorialPromptReader(input io.Reader) *tutorialPromptReader { + return &tutorialPromptReader{ + input: input, + bufferedInput: bufio.NewReader(input), + } +} + +func (r *tutorialPromptReader) readKey(ctx context.Context) error { + if terminalInput, ok := r.input.(*os.File); ok && util.IsTerminal(r.input) { + fd := int(terminalInput.Fd()) + state, err := term.MakeRaw(fd) + if err != nil { + return fmt.Errorf("failed to configure terminal: %w", err) + } + + key, readErr := readTutorialValue(ctx, r.bufferedInput.ReadByte) + restoreErr := term.Restore(fd, state) + if readErr != nil { + return readErr + } + if restoreErr != nil { + return fmt.Errorf("failed to restore terminal: %w", restoreErr) + } + if key == byte(3) { + return context.Canceled + } + return nil + } + + _, err := r.readLine(ctx) + return err +} + +func (r *tutorialPromptReader) readLine(ctx context.Context) (string, error) { + line, err := readTutorialValue(ctx, func() (string, error) { + return r.bufferedInput.ReadString('\n') + }) + return strings.TrimSpace(line), err +} + +func readTutorialValue[T any](ctx context.Context, readFn func() (T, error)) (T, error) { + type result struct { + value T + err error + } + + resultCh := make(chan result, 1) + go func() { + value, err := readFn() + if ctx.Err() != nil { + return + } + resultCh <- result{value: value, err: err} + }() + + select { + case <-ctx.Done(): + var zero T + return zero, ctx.Err() + case result := <-resultCh: + return result.value, result.err + } +} diff --git a/internal/cmd/tutorial_test.go b/internal/cmd/tutorial_test.go new file mode 100644 index 0000000..2adee5f --- /dev/null +++ b/internal/cmd/tutorial_test.go @@ -0,0 +1,291 @@ +package cmd + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/timescale/ghost/internal/api" + "github.com/timescale/ghost/internal/api/mock" + "github.com/timescale/ghost/internal/common" + "github.com/timescale/ghost/internal/tutorial" +) + +func TestTutorialCmd(t *testing.T) { + password := "testpass123" + + setupSuccessfulTutorial := func(m *mock.MockClientWithResponsesInterface, includeDeletes bool) { + originalDatabase := sampleDatabase(func(db *api.Database) { + db.Id = "orig1234567" + db.Name = "tutorial-test" + db.Password = &password + }) + forkDatabase := sampleDatabase(func(db *api.Database) { + db.Id = "fork1234567" + db.Name = "tutorial-test-fork" + db.Password = &password + }) + + // Step 1: ghost create --name tutorial-test --wait + m.EXPECT().CreateDatabaseWithResponse(validCtx, "test-project", api.CreateDatabaseRequest{Name: new("tutorial-test")}). + Return(&api.CreateDatabaseResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &originalDatabase, + }, nil) + + // Step 4: ghost fork tutorial-test --name tutorial-test-fork --wait + m.EXPECT().GetDatabaseWithResponse(validCtx, "test-project", "tutorial-test"). + Return(&api.GetDatabaseResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &originalDatabase, + }, nil) + m.EXPECT().ForkDatabaseWithResponse(validCtx, "test-project", "orig1234567", api.ForkDatabaseRequest{Name: new("tutorial-test-fork")}). + Return(&api.ForkDatabaseResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &forkDatabase, + }, nil) + + if includeDeletes { + // Step 7a: ghost delete tutorial-test-fork --confirm + m.EXPECT().GetDatabaseWithResponse(validCtx, "test-project", "tutorial-test-fork"). + Return(&api.GetDatabaseResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &forkDatabase, + }, nil) + m.EXPECT().DeleteDatabaseWithResponse(validCtx, "test-project", "fork1234567"). + Return(&api.DeleteDatabaseResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + + // Step 7b: ghost delete tutorial-test --confirm + m.EXPECT().GetDatabaseWithResponse(validCtx, "test-project", "tutorial-test"). + Return(&api.GetDatabaseResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &originalDatabase, + }, nil) + m.EXPECT().DeleteDatabaseWithResponse(validCtx, "test-project", "orig1234567"). + Return(&api.DeleteDatabaseResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + } + } + + withTutorialStubs := func(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + + originalGenerateNameSuffix := tutorialGenerateNameSuffix + originalWaitForDatabaseWithProgress := common.WaitForDatabaseWithProgress + originalExecuteQuery := common.ExecuteQuery + + tutorialGenerateNameSuffix = func() (string, error) { + return "test", nil + } + common.WaitForDatabaseWithProgress = func(context.Context, io.Writer, common.WaitForDatabaseArgs) error { + return nil + } + common.ExecuteQuery = func(_ context.Context, args common.ExecuteQueryArgs) (*common.QueryResult, error) { + switch args.Query { + case tutorial.SetupSQL: + return &common.QueryResult{ResultSets: []common.ResultSet{ + {CommandTag: "CREATE TABLE"}, + {CommandTag: "INSERT 0 3"}, + }}, nil + case tutorial.MutateForkSQL: + return &common.QueryResult{ResultSets: []common.ResultSet{ + {CommandTag: "INSERT 0 1"}, + {CommandTag: "UPDATE 1"}, + }}, nil + case tutorial.QuerySQL: + rows := [][]string{ + {"1", "apples", "original"}, + {"2", "bananas", "original"}, + {"3", "carrots", "original"}, + } + if args.DatabaseRef == "tutorial-test-fork" { + rows = [][]string{ + {"1", "apples", "original"}, + {"2", "bananas", "fork"}, + {"3", "carrots", "original"}, + {"4", "dragonfruit", "fork"}, + } + } + return &common.QueryResult{ResultSets: []common.ResultSet{ + { + Columns: []common.Column{ + {Name: "id"}, + {Name: "name"}, + {Name: "location"}, + }, + Rows: rows, + }, + }}, nil + default: + return &common.QueryResult{}, nil + } + } + + t.Cleanup(func() { + tutorialGenerateNameSuffix = originalGenerateNameSuffix + common.WaitForDatabaseWithProgress = originalWaitForDatabaseWithProgress + common.ExecuteQuery = originalExecuteQuery + }) + } + + t.Run("non-interactive stdin", func(t *testing.T) { + result := runCommand(t, []string{"tutorial"}, nil) + + if result.err == nil { + t.Fatal("expected error, got nil") + } + assertOutput(t, result.err.Error(), "cannot run tutorial: stdin is not a terminal") + assertOutput(t, result.stdout, "") + assertOutput(t, result.stderr, "Error: cannot run tutorial: stdin is not a terminal\n") + }) + + t.Run("not logged in", func(t *testing.T) { + withTutorialStubs(t) + result := runCommand(t, []string{"tutorial"}, nil, withStdin("\n"), withIsTerminal(true), withClientError(errors.New("authentication required: no credentials found"))) + + if result.err == nil { + t.Fatal("expected error, got nil") + } + assertOutput(t, result.err.Error(), "authentication required: no credentials found") + assertOutput(t, result.stdout, "") + assertOutput(t, result.stderr, "Error: authentication required: no credentials found\n") + }) + + t.Run("read only config", func(t *testing.T) { + withTutorialStubs(t) + result := runCommand(t, []string{"tutorial"}, nil, withStdin("\n"), withIsTerminal(true), withEnv("GHOST_READ_ONLY", "true")) + + if result.err == nil { + t.Fatal("expected error, got nil") + } + assertOutput(t, result.err.Error(), "cannot run tutorial while read_only is enabled; run `ghost config set read_only false` to allow tutorial writes") + assertOutput(t, result.stdout, "") + assertOutput(t, result.stderr, "Error: cannot run tutorial while read_only is enabled; run `ghost config set read_only false` to allow tutorial writes\n") + }) + + t.Run("keep tutorial databases", func(t *testing.T) { + withTutorialStubs(t) + result := runCommand(t, []string{"tutorial"}, func(m *mock.MockClientWithResponsesInterface) { + setupSuccessfulTutorial(m, false) + }, withStdin(strings.Repeat("\n", 7)+"n\n"), withIsTerminal(true)) + + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + assertOutput(t, result.stdout, tutorialKeepExpectedStdout) + assertOutput(t, result.stderr, strings.Repeat("Press any key to run this command...\r\n", 7)+"Delete the tutorial databases now? [Y/n] ") + }) + + t.Run("delete tutorial databases", func(t *testing.T) { + withTutorialStubs(t) + result := runCommand(t, []string{"tutorial"}, func(m *mock.MockClientWithResponsesInterface) { + setupSuccessfulTutorial(m, true) + }, withStdin(strings.Repeat("\n", 7)+"y\n\n\n"), withIsTerminal(true)) + + if result.err != nil { + t.Fatalf("unexpected error: %v", result.err) + } + assertOutput(t, result.stdout, tutorialDeleteExpectedStdout) + assertOutput(t, result.stderr, strings.Repeat("Press any key to run this command...\r\n", 7)+"Delete the tutorial databases now? [Y/n] "+strings.Repeat("Press any key to run this command...\r\n", 2)) + }) +} + +// Note: the table rows below have significant trailing whitespace. +const tutorialCommonExpectedStdout = `Welcome to the Ghost tutorial! + +This guided tour will run real Ghost commands to demonstrate the core workflow: +create a database, load data, fork it, change the fork, compare the results, and clean up. + +Temporary database names + original: tutorial-test + fork: tutorial-test-fork + +Step 1 / Create a database +-------------------------- +$ ghost create --name tutorial-test --wait +Created database 'tutorial-test' +ID: orig1234567 +Connection: postgresql://tsdbadmin:testpass123@host.example.com:5432/tsdb?sslmode=require + +Step 2 / Add sample data with SQL +--------------------------------- +The sql command connects to the database and executes the query you provide. +$ ghost sql tutorial-test \ + "CREATE TABLE ghost_tutorial_items (id serial PRIMARY KEY, name text NOT NULL, location text NOT NULL); + INSERT INTO ghost_tutorial_items (name, location) VALUES ('apples', 'original'), ('bananas', 'original'), ('carrots', 'original');" +CREATE TABLE +INSERT 0 3 + +Step 3 / Query the original database +------------------------------------ +$ ghost sql tutorial-test "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" + id │ name │ location +────┼─────────┼────────── + 1 │ apples │ original + 2 │ bananas │ original + 3 │ carrots │ original +(3 rows) + + +Step 4 / Fork the database +-------------------------- +Forking creates an independent copy you can safely experiment with. +$ ghost fork tutorial-test --name tutorial-test-fork --wait +Forked 'tutorial-test' → 'tutorial-test-fork' +ID: fork1234567 +Connection: postgresql://tsdbadmin:testpass123@host.example.com:5432/tsdb?sslmode=require + +Step 5 / Mutate the fork +------------------------ +These changes are made only on the fork. +$ ghost sql tutorial-test-fork \ + "INSERT INTO ghost_tutorial_items (name, location) VALUES ('dragonfruit', 'fork'); + UPDATE ghost_tutorial_items SET location = 'fork' WHERE name = 'bananas';" +INSERT 0 1 +UPDATE 1 + +Step 6 / Compare the original and the fork +------------------------------------------ +First, query the original database: +$ ghost sql tutorial-test "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" + id │ name │ location +────┼─────────┼────────── + 1 │ apples │ original + 2 │ bananas │ original + 3 │ carrots │ original +(3 rows) + + +Now query the fork. Notice the extra row and updated value: +$ ghost sql tutorial-test-fork "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" + id │ name │ location +────┼─────────────┼────────── + 1 │ apples │ original + 2 │ bananas │ fork + 3 │ carrots │ original + 4 │ dragonfruit │ fork +(4 rows) + + +` + +const tutorialKeepExpectedStdout = tutorialCommonExpectedStdout + ` +Keeping the tutorial databases. +To clean them up later, run: + ghost delete tutorial-test-fork --confirm + ghost delete tutorial-test --confirm +` + +const tutorialDeleteExpectedStdout = tutorialCommonExpectedStdout + ` +Step 7 / Delete the tutorial databases +-------------------------------------- +$ ghost delete tutorial-test-fork --confirm +Deleted 'tutorial-test-fork' (fork1234567) +$ ghost delete tutorial-test --confirm +Deleted 'tutorial-test' (orig1234567) + +Tutorial complete. You created, queried, forked, changed, compared, and deleted Ghost databases. +` diff --git a/internal/common/query.go b/internal/common/query.go index 5cc0333..cfd6e36 100644 --- a/internal/common/query.go +++ b/internal/common/query.go @@ -51,7 +51,10 @@ type ExecuteQueryArgs struct { // Multi-statement queries (semicolon-separated) are supported when no // parameters are provided. When parameters are provided, only single // statements are supported. -func ExecuteQuery(ctx context.Context, args ExecuteQueryArgs) (*QueryResult, error) { +// +// Declared as a var so tests can replace it with a stub that doesn't +// require a real database connection. +var ExecuteQuery = func(ctx context.Context, args ExecuteQueryArgs) (*QueryResult, error) { // Fetch database details database, err := fetchDatabase(ctx, args.Client, args.ProjectID, args.DatabaseRef) if err != nil { diff --git a/internal/common/wait.go b/internal/common/wait.go index dc6c351..a4a9f4f 100644 --- a/internal/common/wait.go +++ b/internal/common/wait.go @@ -69,7 +69,10 @@ func WaitForDatabase(ctx context.Context, args WaitForDatabaseArgs) error { // WaitForDatabaseWithProgress waits for a database to be ready, showing an // animated spinner if the writer is a terminal, or plain text otherwise. -func WaitForDatabaseWithProgress(ctx context.Context, out io.Writer, args WaitForDatabaseArgs) error { +// +// Declared as a var so tests can replace it with a stub that doesn't +// require polling a real database. +var WaitForDatabaseWithProgress = func(ctx context.Context, out io.Writer, args WaitForDatabaseArgs) error { if !util.IsTerminal(out) { return WaitForDatabase(ctx, args) } diff --git a/internal/tutorial/tutorial.go b/internal/tutorial/tutorial.go new file mode 100644 index 0000000..2cab257 --- /dev/null +++ b/internal/tutorial/tutorial.go @@ -0,0 +1,282 @@ +// Package tutorial holds the data definitions for Ghost's guided tutorials. +// The CLI command in internal/cmd consumes these definitions to run a +// tutorial interactively; the generate-tutorial-docs binary consumes the +// same definitions to render the matching markdown docs. Keeping the data +// here (rather than next to either consumer) makes the source-of-truth +// explicit and avoids leaking doc-rendering concerns into the CLI package. +package tutorial + +import ( + "fmt" + "strconv" + "strings" +) + +// SQL statements run during the tutorial. Exported so test code in other +// packages can match recorded query strings against them. +const ( + SetupSQL = "CREATE TABLE ghost_tutorial_items (id serial PRIMARY KEY, name text NOT NULL, location text NOT NULL); INSERT INTO ghost_tutorial_items (name, location) VALUES ('apples', 'original'), ('bananas', 'original'), ('carrots', 'original');" + MutateForkSQL = "INSERT INTO ghost_tutorial_items (name, location) VALUES ('dragonfruit', 'fork'); UPDATE ghost_tutorial_items SET location = 'fork' WHERE name = 'bananas';" + QuerySQL = "SELECT id, name, location FROM ghost_tutorial_items ORDER BY id;" +) + +// Placeholder values used when rendering the markdown tutorial doc. The live +// `ghost tutorial` command never uses these — it generates a real suffix and +// reads real IDs from the API — but the markdown renderer needs +// concrete-looking values so the example output reads naturally. +const ( + docsOriginalDatabaseName = "tutorial-example" + docsForkDatabaseName = "tutorial-example-fork" + docsOriginalDatabaseID = "abc1234567" + docsForkDatabaseID = "def1234567" + docsConnectionString = "postgresql://tsdbadmin:@:5432/tsdb?sslmode=require" +) + +// Target controls whether a Block appears in the live CLI run, the rendered +// markdown doc, or both. The zero value (TargetAll) means the block is +// visible everywhere, which is the common case. +type Target int + +const ( + TargetAll Target = iota + TargetCLIOnly + TargetDocsOnly +) + +// Block is one unit of tutorial content: optional prose followed by an +// optional ghost CLI command. ExpectedOutput is shown only in the markdown +// doc — the live CLI prints whatever the sub-command actually emits. Target +// lets a block be doc-only (e.g. the cleanup preamble that explains how the +// live tutorial transitions into Step 7) or CLI-only. CreatesDatabase and +// RemovesDatabase track side effects on the cleanup list for the live +// runtime; they are ignored by the markdown renderer. +type Block struct { + Prose string + Args []string + ExpectedOutput string + Target Target + CreatesDatabase string + RemovesDatabase string +} + +// Step is a numbered group of Blocks under a single heading. When +// JoinedBlocks is true, adjacent blocks render flush against each other (no +// blank line between them) — used for tight sequences such as the paired +// delete commands at the end of the tutorial. +type Step struct { + Title string + Blocks []Block + JoinedBlocks bool +} + +// Tutorial bundles everything about one tutorial: the narrative shown in +// docs/tutorials/, the steps run by the live `ghost tutorial` +// command, and an optional cleanup step the live CLI conditionally runs +// after a user prompt. New tutorials should be added to All(). +type Tutorial struct { + Filename string + Title string + Callout string + Intro []string + Steps []Step + DeleteStep Step +} + +// All is the registry of every tutorial defined in this package. The +// generate-tutorial-docs binary iterates this list to render markdown docs; +// the live `ghost tutorial` CLI command picks one (currently always +// learn-the-basics). +func All() []Tutorial { + return []Tutorial{ + BuildLearnTheBasicsTutorial(docsOriginalDatabaseName, docsForkDatabaseName), + } +} + +// BuildLearnTheBasicsTutorial constructs the learn-the-basics tutorial +// using the provided database names. The docs registry passes placeholder +// names so the rendered markdown reads consistently; the live CLI passes +// dynamically generated names so its sub-commands operate on real +// databases. +func BuildLearnTheBasicsTutorial(originalDatabaseName, forkDatabaseName string) Tutorial { + return Tutorial{ + Filename: "learn-the-basics.md", + Title: "Learn the basics of Ghost", + Callout: "Run `ghost tutorial` to step through this tutorial live in the CLI.", + Intro: []string{ + "This guided tour walks through the core Ghost workflow: create a database, load data, fork it, change the fork, compare the results, and clean up. Each step shows the exact `ghost` command the live tutorial runs and the output you can expect to see.", + fmt.Sprintf("Throughout this guide, the temporary databases are named `%s` and `%s`. The live `ghost tutorial` command generates a random suffix instead.", originalDatabaseName, forkDatabaseName), + }, + Steps: buildLearnTheBasicsSteps(originalDatabaseName, forkDatabaseName), + DeleteStep: buildLearnTheBasicsDeleteStep(originalDatabaseName, forkDatabaseName), + } +} + +func buildLearnTheBasicsSteps(originalDatabaseName, forkDatabaseName string) []Step { + threeRowQueryOutput := "" + + " id │ name │ location \n" + + "────┼─────────┼──────────\n" + + " 1 │ apples │ original \n" + + " 2 │ bananas │ original \n" + + " 3 │ carrots │ original \n" + + "(3 rows)" + + return []Step{ + { + Title: "Create a database", + Blocks: []Block{ + { + Args: []string{"create", "--name", originalDatabaseName, "--wait"}, + CreatesDatabase: originalDatabaseName, + ExpectedOutput: "Created database '" + originalDatabaseName + "'\n" + + "ID: " + docsOriginalDatabaseID + "\n" + + "Connection: " + docsConnectionString, + }, + }, + }, + { + Title: "Add sample data with SQL", + Blocks: []Block{ + { + Prose: "The sql command connects to the database and executes the query you provide.", + Args: []string{"sql", originalDatabaseName, SetupSQL}, + ExpectedOutput: "CREATE TABLE\nINSERT 0 3", + }, + }, + }, + { + Title: "Query the original database", + Blocks: []Block{ + { + Args: []string{"sql", originalDatabaseName, QuerySQL}, + ExpectedOutput: threeRowQueryOutput, + }, + }, + }, + { + Title: "Fork the database", + Blocks: []Block{ + { + Prose: "Forking creates an independent copy you can safely experiment with.", + Args: []string{"fork", originalDatabaseName, "--name", forkDatabaseName, "--wait"}, + CreatesDatabase: forkDatabaseName, + ExpectedOutput: "Forked '" + originalDatabaseName + "' → '" + forkDatabaseName + "'\n" + + "ID: " + docsForkDatabaseID + "\n" + + "Connection: " + docsConnectionString, + }, + }, + }, + { + Title: "Mutate the fork", + Blocks: []Block{ + { + Prose: "These changes are made only on the fork.", + Args: []string{"sql", forkDatabaseName, MutateForkSQL}, + ExpectedOutput: "INSERT 0 1\nUPDATE 1", + }, + }, + }, + { + Title: "Compare the original and the fork", + Blocks: []Block{ + { + Prose: "First, query the original database:", + Args: []string{"sql", originalDatabaseName, QuerySQL}, + ExpectedOutput: threeRowQueryOutput, + }, + { + Prose: "Now query the fork. Notice the extra row and updated value:", + Args: []string{"sql", forkDatabaseName, QuerySQL}, + ExpectedOutput: "" + + " id │ name │ location \n" + + "────┼─────────────┼──────────\n" + + " 1 │ apples │ original \n" + + " 2 │ bananas │ fork \n" + + " 3 │ carrots │ original \n" + + " 4 │ dragonfruit │ fork \n" + + "(4 rows)", + }, + }, + }, + } +} + +func buildLearnTheBasicsDeleteStep(originalDatabaseName, forkDatabaseName string) Step { + return Step{ + Title: "Delete the tutorial databases", + JoinedBlocks: true, + Blocks: []Block{ + { + Prose: "When the main steps finish, the live tutorial asks whether to delete the databases. To run the cleanup step yourself, use the following.", + Target: TargetDocsOnly, + }, + { + Args: []string{"delete", forkDatabaseName, "--confirm"}, + RemovesDatabase: forkDatabaseName, + ExpectedOutput: "Deleted '" + forkDatabaseName + "' (" + docsForkDatabaseID + ")", + }, + { + Args: []string{"delete", originalDatabaseName, "--confirm"}, + RemovesDatabase: originalDatabaseName, + ExpectedOutput: "Deleted '" + originalDatabaseName + "' (" + docsOriginalDatabaseID + ")", + }, + }, + } +} + +// FilterBlocks returns the blocks visible to the given audience. Blocks +// whose Target is TargetAll always pass; otherwise Target must match +// audience. +func FilterBlocks(blocks []Block, audience Target) []Block { + out := make([]Block, 0, len(blocks)) + for _, block := range blocks { + if block.Target == TargetAll || block.Target == audience { + out = append(out, block) + } + } + return out +} + +// FormatCommand builds the user-facing echo string from sub-command args. +// The sql command's query argument is rendered specially so multi-statement +// queries appear on multiple indented, quoted lines instead of as a single +// long line. Shared between the CLI step echo and the markdown code blocks +// so the two stay in sync. +func FormatCommand(args []string) string { + if len(args) == 3 && args[0] == "sql" { + return formatSQLCommand(args[1], args[2]) + } + return "ghost " + strings.Join(args, " ") +} + +func formatSQLCommand(databaseRef, query string) string { + statements := splitSQLStatements(query) + if len(statements) <= 1 { + return "ghost sql " + databaseRef + " " + strconv.Quote(query) + } + + lines := []string{"ghost sql " + databaseRef + " \\"} + for i, statement := range statements { + quote := `"` + if i > 0 { + quote = " " + } + suffix := ";" + if i == len(statements)-1 { + suffix = `;"` + } + lines = append(lines, " "+quote+statement+suffix) + } + return strings.Join(lines, "\n") +} + +func splitSQLStatements(query string) []string { + parts := strings.Split(query, ";") + statements := make([]string, 0, len(parts)) + for _, part := range parts { + statement := strings.TrimSpace(part) + if statement != "" { + statements = append(statements, statement) + } + } + return statements +}