From 2763e61cd1787f72270d3670c29e351cfc158f43 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Wed, 20 May 2026 17:38:17 -0400 Subject: [PATCH 1/2] add a tutorial command --- CLAUDE.md | 8 +- README.md | 7 + cmd/generate-tutorial-docs/main.go | 28 ++ docs/cli/ghost.md | 1 + docs/cli/ghost_tutorial.md | 47 +++ docs/tutorials/learn-the-basics.md | 125 ++++++ internal/cmd/root.go | 1 + internal/cmd/tutorial.go | 589 +++++++++++++++++++++++++++++ internal/cmd/tutorial_docs.go | 107 ++++++ internal/cmd/tutorial_docs_test.go | 20 + internal/cmd/tutorial_test.go | 290 ++++++++++++++ internal/common/query.go | 5 +- internal/common/wait.go | 5 +- 13 files changed, 1229 insertions(+), 4 deletions(-) create mode 100644 cmd/generate-tutorial-docs/main.go create mode 100644 docs/cli/ghost_tutorial.md create mode 100644 docs/tutorials/learn-the-basics.md create mode 100644 internal/cmd/tutorial.go create mode 100644 internal/cmd/tutorial_docs.go create mode 100644 internal/cmd/tutorial_docs_test.go create mode 100644 internal/cmd/tutorial_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 31c8570..f53f12b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,9 @@ ## 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/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 +308,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. Each tutorial is a `tutorial` struct (`internal/cmd/tutorial.go`) bundling its filename, title/callout/intro narrative, ordered `[]tutorialStep`, and optional `deleteStep`. Steps contain `tutorialBlock`s whose `target` field controls visibility: `tutorialTargetAll` (default), `tutorialTargetCLIOnly`, or `tutorialTargetDocsOnly`. 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 `tutorial_docs.go` is content-agnostic — it walks the struct without any hard-coded text or step numbers. To add a new tutorial, create a new `buildXxxTutorial` function and append it to `allTutorials()`. After editing tutorial content, regenerate the docs with `go run ./cmd/generate-tutorial-docs` (`-out` defaults to `./docs/tutorials`). The golden test `TestAllTutorialDocsMatchGoldenFiles` 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/main.go b/cmd/generate-tutorial-docs/main.go new file mode 100644 index 0000000..81035b3 --- /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/cmd" +) + +func main() { + outDir := flag.String("out", "./docs/tutorials", "Output directory") + flag.Parse() + + if err := os.MkdirAll(*outDir, 0o755); err != nil { + log.Fatal(err) + } + + for _, doc := range cmd.AllTutorialDocs() { + path := filepath.Join(*outDir, doc.Filename) + if err := os.WriteFile(path, []byte(doc.Content), 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..d66f68a --- /dev/null +++ b/internal/cmd/tutorial.go @@ -0,0 +1,589 @@ +package cmd + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "strconv" + "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/util" +) + +const ( + tutorialSetupSQL = "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');" + tutorialMutateForkSQL = "INSERT INTO ghost_tutorial_items (name, location) VALUES ('dragonfruit', 'fork'); UPDATE ghost_tutorial_items SET location = 'fork' WHERE name = 'bananas';" + tutorialQuerySQL = "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 the real IDs from the API — but the markdown renderer + // needs concrete-looking values so the example output reads naturally. + tutorialDocsOriginalDatabaseName = "tutorial-example" + tutorialDocsForkDatabaseName = "tutorial-example-fork" + tutorialDocsOriginalDatabaseID = "abc1234567" + tutorialDocsForkDatabaseID = "def1234567" + tutorialDocsConnectionString = "postgresql://tsdbadmin:@:5432/tsdb?sslmode=require" +) + +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) +) + +// tutorialTarget controls whether a block appears in the live CLI run, the +// rendered markdown doc, or both. The zero value (tutorialTargetAll) means +// the block is visible everywhere, which is the common case. +type tutorialTarget int + +const ( + tutorialTargetAll tutorialTarget = iota + tutorialTargetCLIOnly + tutorialTargetDocsOnly +) + +// tutorialBlock 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 tutorialBlock struct { + prose string + args []string + expectedOutput string + target tutorialTarget + createsDatabase string + removesDatabase string +} + +// tutorialStep 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 tutorialStep struct { + title string + blocks []tutorialBlock + 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 allTutorials(). +type tutorial struct { + filename string + title string + callout string + intro []string + steps []tutorialStep + deleteStep tutorialStep +} + +// allTutorials is the registry of every tutorial defined in this package. +// AllTutorialDocs iterates this list to render markdown docs; the live +// `ghost tutorial` CLI command picks one (currently always learn-the-basics). +func allTutorials() []tutorial { + return []tutorial{ + buildLearnTheBasicsTutorial(tutorialDocsOriginalDatabaseName, tutorialDocsForkDatabaseName), + } +} + +// buildLearnTheBasicsTutorial constructs the 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: buildTutorialSteps(originalDatabaseName, forkDatabaseName), + deleteStep: buildTutorialDeleteStep(originalDatabaseName, forkDatabaseName), + } +} + +// filterTutorialBlocks returns the blocks visible to the given audience. +// Blocks whose target is tutorialTargetAll always pass; otherwise the +// target must match audience. +func filterTutorialBlocks(blocks []tutorialBlock, audience tutorialTarget) []tutorialBlock { + out := make([]tutorialBlock, 0, len(blocks)) + for _, block := range blocks { + if block.target == tutorialTargetAll || block.target == audience { + out = append(out, block) + } + } + return out +} + +func buildTutorialSteps(originalDatabaseName, forkDatabaseName string) []tutorialStep { + threeRowQueryOutput := "" + + " id │ name │ location \n" + + "────┼─────────┼──────────\n" + + " 1 │ apples │ original \n" + + " 2 │ bananas │ original \n" + + " 3 │ carrots │ original \n" + + "(3 rows)" + + return []tutorialStep{ + { + title: "Create a database", + blocks: []tutorialBlock{ + { + args: []string{"create", "--name", originalDatabaseName, "--wait"}, + createsDatabase: originalDatabaseName, + expectedOutput: "Created database '" + originalDatabaseName + "'\n" + + "ID: " + tutorialDocsOriginalDatabaseID + "\n" + + "Connection: " + tutorialDocsConnectionString, + }, + }, + }, + { + title: "Add sample data with SQL", + blocks: []tutorialBlock{ + { + prose: "The sql command connects to the database and executes the query you provide.", + args: []string{"sql", originalDatabaseName, tutorialSetupSQL}, + expectedOutput: "CREATE TABLE\nINSERT 0 3", + }, + }, + }, + { + title: "Query the original database", + blocks: []tutorialBlock{ + { + args: []string{"sql", originalDatabaseName, tutorialQuerySQL}, + expectedOutput: threeRowQueryOutput, + }, + }, + }, + { + title: "Fork the database", + blocks: []tutorialBlock{ + { + 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: " + tutorialDocsForkDatabaseID + "\n" + + "Connection: " + tutorialDocsConnectionString, + }, + }, + }, + { + title: "Mutate the fork", + blocks: []tutorialBlock{ + { + prose: "These changes are made only on the fork.", + args: []string{"sql", forkDatabaseName, tutorialMutateForkSQL}, + expectedOutput: "INSERT 0 1\nUPDATE 1", + }, + }, + }, + { + title: "Compare the original and the fork", + blocks: []tutorialBlock{ + { + prose: "First, query the original database:", + args: []string{"sql", originalDatabaseName, tutorialQuerySQL}, + expectedOutput: threeRowQueryOutput, + }, + { + prose: "Now query the fork. Notice the extra row and updated value:", + args: []string{"sql", forkDatabaseName, tutorialQuerySQL}, + 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 buildTutorialDeleteStep(originalDatabaseName, forkDatabaseName string) tutorialStep { + return tutorialStep{ + title: "Delete the tutorial databases", + joinedBlocks: true, + blocks: []tutorialBlock{ + { + 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: tutorialTargetDocsOnly, + }, + { + args: []string{"delete", forkDatabaseName, "--confirm"}, + removesDatabase: forkDatabaseName, + expectedOutput: "Deleted '" + forkDatabaseName + "' (" + tutorialDocsForkDatabaseID + ")", + }, + { + args: []string{"delete", originalDatabaseName, "--confirm"}, + removesDatabase: originalDatabaseName, + expectedOutput: "Deleted '" + originalDatabaseName + "' (" + tutorialDocsOriginalDatabaseID + ")", + }, + }, + } +} + +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 := 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 tutorialStep, createdDatabaseNames *[]string) error { + printTutorialStep(cmd, number, step.title) + visibleBlocks := filterTutorialBlocks(step.blocks, tutorialTargetCLIOnly) + 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, formatTutorialCommand(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 +} + +// formatTutorialCommand builds the user-facing echo string from the args +// that will be passed to the sub-execution. 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. +func formatTutorialCommand(args []string) string { + if len(args) == 3 && args[0] == "sql" { + return formatTutorialSQLCommand(args[1], args[2]) + } + return "ghost " + strings.Join(args, " ") +} + +// 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 formatTutorialSQLCommand(databaseRef, query string) string { + statements := splitTutorialSQLStatements(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 splitTutorialSQLStatements(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 +} + +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_docs.go b/internal/cmd/tutorial_docs.go new file mode 100644 index 0000000..d31995a --- /dev/null +++ b/internal/cmd/tutorial_docs.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "strings" +) + +// TutorialDoc is one rendered tutorial markdown file together with the +// filename it should be written to. +type TutorialDoc struct { + Filename string + Content string +} + +// AllTutorialDocs renders every tutorial in the registry. The +// generate-tutorial-docs binary writes each Content to /Filename. +func AllTutorialDocs() []TutorialDoc { + tutorials := allTutorials() + docs := make([]TutorialDoc, len(tutorials)) + for i, t := range tutorials { + docs[i] = TutorialDoc{ + Filename: t.filename, + Content: renderTutorialMarkdown(t), + } + } + return docs +} + +// renderTutorialMarkdown walks the tutorial struct 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. +func renderTutorialMarkdown(t 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) + } + writeTutorialParagraphs(&sb, t.intro) + + for i, step := range t.steps { + writeTutorialStepMarkdown(&sb, i+1, step) + } + writeTutorialStepMarkdown(&sb, len(t.steps)+1, t.deleteStep) + + return strings.TrimRight(sb.String(), "\n") + "\n" +} + +func writeTutorialParagraphs(sb *strings.Builder, paragraphs []string) { + for _, p := range paragraphs { + sb.WriteString(p) + sb.WriteString("\n\n") + } +} + +func writeTutorialStepMarkdown(sb *strings.Builder, number int, step tutorialStep) { + fmt.Fprintf(sb, "## Step %d — %s\n\n", number, step.title) + + visibleBlocks := filterTutorialBlocks(step.blocks, tutorialTargetDocsOnly) + + 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, formatTutorialCommand(block.args)) + if block.expectedOutput != "" { + outputs = append(outputs, block.expectedOutput) + } + } + if len(commands) > 0 { + writeTutorialCodeBlock(sb, "bash", strings.Join(commands, "\n")) + } + if len(outputs) > 0 { + writeTutorialCodeBlock(sb, "", strings.Join(outputs, "\n")) + } + return + } + + for _, block := range visibleBlocks { + if block.prose != "" { + sb.WriteString(block.prose + "\n\n") + } + if len(block.args) > 0 { + writeTutorialCodeBlock(sb, "bash", formatTutorialCommand(block.args)) + if block.expectedOutput != "" { + writeTutorialCodeBlock(sb, "", block.expectedOutput) + } + } + } +} + +func writeTutorialCodeBlock(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/internal/cmd/tutorial_docs_test.go b/internal/cmd/tutorial_docs_test.go new file mode 100644 index 0000000..9fc3f3e --- /dev/null +++ b/internal/cmd/tutorial_docs_test.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAllTutorialDocsMatchGoldenFiles(t *testing.T) { + for _, doc := range AllTutorialDocs() { + t.Run(doc.Filename, func(t *testing.T) { + goldenPath := filepath.Join("..", "..", "docs", "tutorials", doc.Filename) + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("failed to read %s: %v", goldenPath, err) + } + assertOutput(t, doc.Content, string(want)) + }) + } +} diff --git a/internal/cmd/tutorial_test.go b/internal/cmd/tutorial_test.go new file mode 100644 index 0000000..7071d8c --- /dev/null +++ b/internal/cmd/tutorial_test.go @@ -0,0 +1,290 @@ +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" +) + +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 tutorialSetupSQL: + return &common.QueryResult{ResultSets: []common.ResultSet{ + {CommandTag: "CREATE TABLE"}, + {CommandTag: "INSERT 0 3"}, + }}, nil + case tutorialMutateForkSQL: + return &common.QueryResult{ResultSets: []common.ResultSet{ + {CommandTag: "INSERT 0 1"}, + {CommandTag: "UPDATE 1"}, + }}, nil + case tutorialQuerySQL: + 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) } From 964cdf7181c367b472aff88527cafc89a87485c8 Mon Sep 17 00:00:00 2001 From: Justin Murray Date: Tue, 26 May 2026 15:09:44 -0400 Subject: [PATCH 2/2] organize shared tutorial code in a separate package --- CLAUDE.md | 3 +- cmd/generate-tutorial-docs/docs.go | 88 +++++++ cmd/generate-tutorial-docs/docs_test.go | 26 +++ cmd/generate-tutorial-docs/main.go | 8 +- internal/cmd/tutorial.go | 296 ++---------------------- internal/cmd/tutorial_docs.go | 107 --------- internal/cmd/tutorial_docs_test.go | 20 -- internal/cmd/tutorial_test.go | 7 +- internal/tutorial/tutorial.go | 282 ++++++++++++++++++++++ 9 files changed, 423 insertions(+), 414 deletions(-) create mode 100644 cmd/generate-tutorial-docs/docs.go create mode 100644 cmd/generate-tutorial-docs/docs_test.go delete mode 100644 internal/cmd/tutorial_docs.go delete mode 100644 internal/cmd/tutorial_docs_test.go create mode 100644 internal/tutorial/tutorial.go diff --git a/CLAUDE.md b/CLAUDE.md index f53f12b..7be15f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ - **`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/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. @@ -310,7 +311,7 @@ After adding new commands, directories, or major functionality, update: ### Tutorial Docs -The live `ghost tutorial` command and the markdown tutorials under `docs/tutorials/` share a single source of truth. Each tutorial is a `tutorial` struct (`internal/cmd/tutorial.go`) bundling its filename, title/callout/intro narrative, ordered `[]tutorialStep`, and optional `deleteStep`. Steps contain `tutorialBlock`s whose `target` field controls visibility: `tutorialTargetAll` (default), `tutorialTargetCLIOnly`, or `tutorialTargetDocsOnly`. 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 `tutorial_docs.go` is content-agnostic — it walks the struct without any hard-coded text or step numbers. To add a new tutorial, create a new `buildXxxTutorial` function and append it to `allTutorials()`. After editing tutorial content, regenerate the docs with `go run ./cmd/generate-tutorial-docs` (`-out` defaults to `./docs/tutorials`). The golden test `TestAllTutorialDocsMatchGoldenFiles` iterates the registry and fails if any on-disk markdown drifts. +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 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 index 81035b3..7525968 100644 --- a/cmd/generate-tutorial-docs/main.go +++ b/cmd/generate-tutorial-docs/main.go @@ -7,7 +7,7 @@ import ( "os" "path/filepath" - "github.com/timescale/ghost/internal/cmd" + "github.com/timescale/ghost/internal/tutorial" ) func main() { @@ -18,9 +18,9 @@ func main() { log.Fatal(err) } - for _, doc := range cmd.AllTutorialDocs() { - path := filepath.Join(*outDir, doc.Filename) - if err := os.WriteFile(path, []byte(doc.Content), 0o644); err != nil { + 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/internal/cmd/tutorial.go b/internal/cmd/tutorial.go index d66f68a..6538c3a 100644 --- a/internal/cmd/tutorial.go +++ b/internal/cmd/tutorial.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "os" - "strconv" "strings" lipgloss "charm.land/lipgloss/v2" @@ -18,25 +17,10 @@ import ( "golang.org/x/term" "github.com/timescale/ghost/internal/common" + "github.com/timescale/ghost/internal/tutorial" "github.com/timescale/ghost/internal/util" ) -const ( - tutorialSetupSQL = "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');" - tutorialMutateForkSQL = "INSERT INTO ghost_tutorial_items (name, location) VALUES ('dragonfruit', 'fork'); UPDATE ghost_tutorial_items SET location = 'fork' WHERE name = 'bananas';" - tutorialQuerySQL = "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 the real IDs from the API — but the markdown renderer - // needs concrete-looking values so the example output reads naturally. - tutorialDocsOriginalDatabaseName = "tutorial-example" - tutorialDocsForkDatabaseName = "tutorial-example-fork" - tutorialDocsOriginalDatabaseID = "abc1234567" - tutorialDocsForkDatabaseID = "def1234567" - tutorialDocsConnectionString = "postgresql://tsdbadmin:@:5432/tsdb?sslmode=require" -) - var ( tutorialGenerateNameSuffix = generateTutorialNameSuffix @@ -50,208 +34,6 @@ var ( tutorialSuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Green) ) -// tutorialTarget controls whether a block appears in the live CLI run, the -// rendered markdown doc, or both. The zero value (tutorialTargetAll) means -// the block is visible everywhere, which is the common case. -type tutorialTarget int - -const ( - tutorialTargetAll tutorialTarget = iota - tutorialTargetCLIOnly - tutorialTargetDocsOnly -) - -// tutorialBlock 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 tutorialBlock struct { - prose string - args []string - expectedOutput string - target tutorialTarget - createsDatabase string - removesDatabase string -} - -// tutorialStep 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 tutorialStep struct { - title string - blocks []tutorialBlock - 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 allTutorials(). -type tutorial struct { - filename string - title string - callout string - intro []string - steps []tutorialStep - deleteStep tutorialStep -} - -// allTutorials is the registry of every tutorial defined in this package. -// AllTutorialDocs iterates this list to render markdown docs; the live -// `ghost tutorial` CLI command picks one (currently always learn-the-basics). -func allTutorials() []tutorial { - return []tutorial{ - buildLearnTheBasicsTutorial(tutorialDocsOriginalDatabaseName, tutorialDocsForkDatabaseName), - } -} - -// buildLearnTheBasicsTutorial constructs the 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: buildTutorialSteps(originalDatabaseName, forkDatabaseName), - deleteStep: buildTutorialDeleteStep(originalDatabaseName, forkDatabaseName), - } -} - -// filterTutorialBlocks returns the blocks visible to the given audience. -// Blocks whose target is tutorialTargetAll always pass; otherwise the -// target must match audience. -func filterTutorialBlocks(blocks []tutorialBlock, audience tutorialTarget) []tutorialBlock { - out := make([]tutorialBlock, 0, len(blocks)) - for _, block := range blocks { - if block.target == tutorialTargetAll || block.target == audience { - out = append(out, block) - } - } - return out -} - -func buildTutorialSteps(originalDatabaseName, forkDatabaseName string) []tutorialStep { - threeRowQueryOutput := "" + - " id │ name │ location \n" + - "────┼─────────┼──────────\n" + - " 1 │ apples │ original \n" + - " 2 │ bananas │ original \n" + - " 3 │ carrots │ original \n" + - "(3 rows)" - - return []tutorialStep{ - { - title: "Create a database", - blocks: []tutorialBlock{ - { - args: []string{"create", "--name", originalDatabaseName, "--wait"}, - createsDatabase: originalDatabaseName, - expectedOutput: "Created database '" + originalDatabaseName + "'\n" + - "ID: " + tutorialDocsOriginalDatabaseID + "\n" + - "Connection: " + tutorialDocsConnectionString, - }, - }, - }, - { - title: "Add sample data with SQL", - blocks: []tutorialBlock{ - { - prose: "The sql command connects to the database and executes the query you provide.", - args: []string{"sql", originalDatabaseName, tutorialSetupSQL}, - expectedOutput: "CREATE TABLE\nINSERT 0 3", - }, - }, - }, - { - title: "Query the original database", - blocks: []tutorialBlock{ - { - args: []string{"sql", originalDatabaseName, tutorialQuerySQL}, - expectedOutput: threeRowQueryOutput, - }, - }, - }, - { - title: "Fork the database", - blocks: []tutorialBlock{ - { - 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: " + tutorialDocsForkDatabaseID + "\n" + - "Connection: " + tutorialDocsConnectionString, - }, - }, - }, - { - title: "Mutate the fork", - blocks: []tutorialBlock{ - { - prose: "These changes are made only on the fork.", - args: []string{"sql", forkDatabaseName, tutorialMutateForkSQL}, - expectedOutput: "INSERT 0 1\nUPDATE 1", - }, - }, - }, - { - title: "Compare the original and the fork", - blocks: []tutorialBlock{ - { - prose: "First, query the original database:", - args: []string{"sql", originalDatabaseName, tutorialQuerySQL}, - expectedOutput: threeRowQueryOutput, - }, - { - prose: "Now query the fork. Notice the extra row and updated value:", - args: []string{"sql", forkDatabaseName, tutorialQuerySQL}, - 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 buildTutorialDeleteStep(originalDatabaseName, forkDatabaseName string) tutorialStep { - return tutorialStep{ - title: "Delete the tutorial databases", - joinedBlocks: true, - blocks: []tutorialBlock{ - { - 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: tutorialTargetDocsOnly, - }, - { - args: []string{"delete", forkDatabaseName, "--confirm"}, - removesDatabase: forkDatabaseName, - expectedOutput: "Deleted '" + forkDatabaseName + "' (" + tutorialDocsForkDatabaseID + ")", - }, - { - args: []string{"delete", originalDatabaseName, "--confirm"}, - removesDatabase: originalDatabaseName, - expectedOutput: "Deleted '" + originalDatabaseName + "' (" + tutorialDocsOriginalDatabaseID + ")", - }, - }, - } -} - func buildTutorialCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "tutorial", @@ -319,8 +101,8 @@ func runTutorial(cmd *cobra.Command, app *common.App) (runErr error) { cmd.Printf(" fork: %s\n", forkDatabaseName) cmd.Println() - t := buildLearnTheBasicsTutorial(originalDatabaseName, forkDatabaseName) - for i, step := range t.steps { + t := tutorial.BuildLearnTheBasicsTutorial(originalDatabaseName, forkDatabaseName) + for i, step := range t.Steps { if err := runTutorialStep(cmd, promptReader, i+1, step, &createdDatabaseNames); err != nil { return err } @@ -341,7 +123,7 @@ func runTutorial(cmd *cobra.Command, app *common.App) (runErr error) { } cmd.Println() - if err := runTutorialStep(cmd, promptReader, len(t.steps)+1, t.deleteStep, &createdDatabaseNames); err != nil { + if err := runTutorialStep(cmd, promptReader, len(t.Steps)+1, t.DeleteStep, &createdDatabaseNames); err != nil { return err } @@ -349,26 +131,26 @@ func runTutorial(cmd *cobra.Command, app *common.App) (runErr error) { return nil } -func runTutorialStep(cmd *cobra.Command, promptReader *tutorialPromptReader, number int, step tutorialStep, createdDatabaseNames *[]string) error { - printTutorialStep(cmd, number, step.title) - visibleBlocks := filterTutorialBlocks(step.blocks, tutorialTargetCLIOnly) +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 block.Prose != "" { + cmd.Println(tutorialProseStyle.Render(block.Prose)) } - if len(block.args) > 0 { - if err := runTutorialCommand(cmd, promptReader, block.args); err != nil { + 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.CreatesDatabase != "" { + *createdDatabaseNames = append(*createdDatabaseNames, block.CreatesDatabase) } - if block.removesDatabase != "" { - *createdDatabaseNames = removeTutorialName(*createdDatabaseNames, block.removesDatabase) + if block.RemovesDatabase != "" { + *createdDatabaseNames = removeTutorialName(*createdDatabaseNames, block.RemovesDatabase) } isLast := i == len(visibleBlocks)-1 - if !step.joinedBlocks || isLast { + if !step.JoinedBlocks || isLast { cmd.Println() } } @@ -381,7 +163,7 @@ func runTutorialStep(cmd *cobra.Command, promptReader *tutorialPromptReader, num // 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, formatTutorialCommand(args)) + 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) @@ -410,17 +192,6 @@ func runTutorialCommand(cmd *cobra.Command, promptReader *tutorialPromptReader, return nil } -// formatTutorialCommand builds the user-facing echo string from the args -// that will be passed to the sub-execution. 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. -func formatTutorialCommand(args []string) string { - if len(args) == 3 && args[0] == "sql" { - return formatTutorialSQLCommand(args[1], args[2]) - } - return "ghost " + strings.Join(args, " ") -} - // 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 @@ -450,39 +221,6 @@ func printTutorialCommand(cmd *cobra.Command, command string) { } } -func formatTutorialSQLCommand(databaseRef, query string) string { - statements := splitTutorialSQLStatements(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 splitTutorialSQLStatements(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 -} - func removeTutorialName(names []string, name string) []string { for i, n := range names { if n == name { diff --git a/internal/cmd/tutorial_docs.go b/internal/cmd/tutorial_docs.go deleted file mode 100644 index d31995a..0000000 --- a/internal/cmd/tutorial_docs.go +++ /dev/null @@ -1,107 +0,0 @@ -package cmd - -import ( - "fmt" - "strings" -) - -// TutorialDoc is one rendered tutorial markdown file together with the -// filename it should be written to. -type TutorialDoc struct { - Filename string - Content string -} - -// AllTutorialDocs renders every tutorial in the registry. The -// generate-tutorial-docs binary writes each Content to /Filename. -func AllTutorialDocs() []TutorialDoc { - tutorials := allTutorials() - docs := make([]TutorialDoc, len(tutorials)) - for i, t := range tutorials { - docs[i] = TutorialDoc{ - Filename: t.filename, - Content: renderTutorialMarkdown(t), - } - } - return docs -} - -// renderTutorialMarkdown walks the tutorial struct 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. -func renderTutorialMarkdown(t 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) - } - writeTutorialParagraphs(&sb, t.intro) - - for i, step := range t.steps { - writeTutorialStepMarkdown(&sb, i+1, step) - } - writeTutorialStepMarkdown(&sb, len(t.steps)+1, t.deleteStep) - - return strings.TrimRight(sb.String(), "\n") + "\n" -} - -func writeTutorialParagraphs(sb *strings.Builder, paragraphs []string) { - for _, p := range paragraphs { - sb.WriteString(p) - sb.WriteString("\n\n") - } -} - -func writeTutorialStepMarkdown(sb *strings.Builder, number int, step tutorialStep) { - fmt.Fprintf(sb, "## Step %d — %s\n\n", number, step.title) - - visibleBlocks := filterTutorialBlocks(step.blocks, tutorialTargetDocsOnly) - - 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, formatTutorialCommand(block.args)) - if block.expectedOutput != "" { - outputs = append(outputs, block.expectedOutput) - } - } - if len(commands) > 0 { - writeTutorialCodeBlock(sb, "bash", strings.Join(commands, "\n")) - } - if len(outputs) > 0 { - writeTutorialCodeBlock(sb, "", strings.Join(outputs, "\n")) - } - return - } - - for _, block := range visibleBlocks { - if block.prose != "" { - sb.WriteString(block.prose + "\n\n") - } - if len(block.args) > 0 { - writeTutorialCodeBlock(sb, "bash", formatTutorialCommand(block.args)) - if block.expectedOutput != "" { - writeTutorialCodeBlock(sb, "", block.expectedOutput) - } - } - } -} - -func writeTutorialCodeBlock(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/internal/cmd/tutorial_docs_test.go b/internal/cmd/tutorial_docs_test.go deleted file mode 100644 index 9fc3f3e..0000000 --- a/internal/cmd/tutorial_docs_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "testing" -) - -func TestAllTutorialDocsMatchGoldenFiles(t *testing.T) { - for _, doc := range AllTutorialDocs() { - t.Run(doc.Filename, func(t *testing.T) { - goldenPath := filepath.Join("..", "..", "docs", "tutorials", doc.Filename) - want, err := os.ReadFile(goldenPath) - if err != nil { - t.Fatalf("failed to read %s: %v", goldenPath, err) - } - assertOutput(t, doc.Content, string(want)) - }) - } -} diff --git a/internal/cmd/tutorial_test.go b/internal/cmd/tutorial_test.go index 7071d8c..2adee5f 100644 --- a/internal/cmd/tutorial_test.go +++ b/internal/cmd/tutorial_test.go @@ -11,6 +11,7 @@ import ( "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) { @@ -84,17 +85,17 @@ func TestTutorialCmd(t *testing.T) { } common.ExecuteQuery = func(_ context.Context, args common.ExecuteQueryArgs) (*common.QueryResult, error) { switch args.Query { - case tutorialSetupSQL: + case tutorial.SetupSQL: return &common.QueryResult{ResultSets: []common.ResultSet{ {CommandTag: "CREATE TABLE"}, {CommandTag: "INSERT 0 3"}, }}, nil - case tutorialMutateForkSQL: + case tutorial.MutateForkSQL: return &common.QueryResult{ResultSets: []common.ResultSet{ {CommandTag: "INSERT 0 1"}, {CommandTag: "UPDATE 1"}, }}, nil - case tutorialQuerySQL: + case tutorial.QuerySQL: rows := [][]string{ {"1", "apples", "original"}, {"2", "bananas", "original"}, 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 +}