Skip to content

Add ghost tutorial command and generated tutorial doc#18

Open
murrayju wants to merge 2 commits into
mainfrom
murrayju/tutorial
Open

Add ghost tutorial command and generated tutorial doc#18
murrayju wants to merge 2 commits into
mainfrom
murrayju/tutorial

Conversation

@murrayju
Copy link
Copy Markdown
Member

Summary

Adds ghost tutorial, an interactive guided tour of the core Ghost workflow (create → add data → fork → mutate → compare → delete), and generates a matching written tutorial at docs/tutorials/learn-the-basics.md from the same source.

What the tutorial does

Walks the user through 7 steps:

  1. Create a database
  2. Add sample data with SQL
  3. Query the original database
  4. Fork the database
  5. Mutate the fork
  6. Compare the original and the fork
  7. Delete the tutorial databases (optional — runs after a [Y/n] cleanup prompt)

For each step the tutorial:

  • Echoes the equivalent ghost ... command.
  • Waits for any keypress, then erases the prompt line in TTYs so it doesn't clutter scrollback.
  • Re-enters the root command tree via cmd.Root().ExecuteContext() so the real sub-command runs against the user's stdout/stderr — output streams in real time and progress indicators like the --wait spinner work naturally.
  • Forwards persistent flags the user set on the outer invocation (e.g. --config-dir) and appends --version-check=false so the update banner doesn't appear once per step.

If a step fails before cleanup, the deferred handler prints ghost delete ... --confirm lines for every database that was successfully created so the user can reclaim them.

How the docs stay in sync with the live tutorial

Each tutorial is a tutorial struct bundling:

  • filename — where it ends up under docs/tutorials/
  • title, callout, intro — markdown-only narrative
  • steps []tutorialStep — the ordered live steps
  • deleteStep tutorialStep — the optional cleanup step

Each step is a list of tutorialBlocks, each of which can carry:

  • prose — shown before the command
  • args — the sub-command invocation
  • expectedOutput — shown only in the markdown doc
  • targettutorialTargetAll (default), tutorialTargetCLIOnly, or tutorialTargetDocsOnly
  • createsDatabase / removesDatabase — live-runtime cleanup tracking

Both the CLI loop in runTutorial and the markdown renderer in tutorial_docs.go walk the same []tutorialStep, each filtering blocks by its own audience. Docs-only callouts (like the cleanup preamble at the top of Step 7) live alongside the step they describe without affecting the CLI flow.

A new registry allTutorials() []tutorial lists every tutorial. To add another tutorial, append a buildXxxTutorial(...) call to that function.

Doc generation

go run ./cmd/generate-tutorial-docs        # writes every tutorial to ./docs/tutorials
go run ./cmd/generate-tutorial-docs -out X # writes to X instead

The golden test TestAllTutorialDocsMatchGoldenFiles iterates the registry and fails if any on-disk file drifts from the renderer output, so CI catches forgotten regenerations.

Tests

  • TestTutorialCmd — drives the CLI with stubbed tutorialGenerateNameSuffix, common.WaitForDatabaseWithProgress, and common.ExecuteQuery; mocks API calls per sub-command; scripts stdin with the right number of keypresses plus the cleanup answer. Covers non-interactive stdin, not-logged-in, read-only config, keep-databases, and delete-databases paths.
  • TestAllTutorialDocsMatchGoldenFiles — re-renders every tutorial doc and compares against docs/tutorials/<filename>.

Other changes

  • common.ExecuteQuery and common.WaitForDatabaseWithProgress are now vars so the tutorial tests can stub them (matches the existing common.OpenBrowser pattern).
  • README gains a "Run ghost tutorial" callout in Getting Started plus a row in the commands table.
  • CLAUDE.md documents the tutorial registry and the doc-sync pattern.

@murrayju murrayju requested a review from nathanjcochran May 26, 2026 18:38
Comment thread internal/cmd/tutorial_docs.go Outdated
Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Did a fairly quick review. Left a few comments/questions, but nothing major. Did not test myself.

Comment thread internal/cmd/tutorial.go
Comment on lines +265 to +327
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
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code has a lot of overlap with /internal/util/read.go. Any reason not to consolidate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it could be. Neither I nor Claude knew this existed, apparently. We should probably add something to the CLAUDE.md along the lines of "always search for existing implementations that can be reused before writing a new utility function"...

Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude knew it existed, because it's documented in the CLAUDE.md (see here and here). Claude also copied the existing readString function nearly verbatim (the main change seems to be that the new one is generic so it can return a byte in some cases).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, well then the problem is that I didn't know it existed, and Claude loves to duplicate things.

Comment thread internal/cmd/tutorial.go
Comment thread internal/cmd/tutorial.go
Comment on lines +224 to +231
func removeTutorialName(names []string, name string) []string {
for i, n := range names {
if n == name {
return append(names[:i], names[i+1:]...)
}
}
return names
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could use slices.DeleteFunc() instead of this custom function.

The slices package is newish, and therefore under-represented in LLM training data, so LLMs often fail to use it when it would make sense.

@@ -0,0 +1,125 @@
# Learn the basics of Ghost
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a plan to do something with this markdown version of the tutorial?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing tutorials in this folder are published to https://ghost.build/tutorials/. I think that's automated, so this will also appear there? Either way, my intention is to publish it there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @thenoahhein probably set that up. Not sure if it's fully automated or not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants