Skip to content

Commit 419b848

Browse files
committed
Add {server} block support for dev server startup
Adds a new `{server}` code block annotation that marks a block as a long-running server process. When verify encounters a {server} block, it starts the server in the background with an auto-assigned port (via $PORT env var), waits for the port to become available, then runs subsequent blocks with $PORT in their environment. New features: - `{server}` annotation in markdown fenced code blocks - `showboat server <file>` command to start a server from a document - `--wait-port <port>` flag for verify and server commands to pin a port - $PORT env var auto-assigned and propagated to all blocks - `showboat extract` emits `showboat server` for server blocks New files: exec/server.go (FreePort, WaitForPort, RunServer), exec/server_test.go, demos/dev-server-startup.md https://claude.ai/code/session_01YZQJXZoLT9ZM6t1JL72bN6
1 parent fa2c501 commit 419b848

18 files changed

Lines changed: 854 additions & 25 deletions

README.md

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Usage:
6666
showboat pop <file> Remove the most recent entry
6767
showboat verify <file> [--output <new>] Re-run and diff all code blocks
6868
showboat extract <file> [--filename <name>] Emit commands to recreate file
69+
showboat server <file> [--wait-port <port>] Start {server} block from doc
6970
7071
Global Options:
7172
--workdir <dir> Set working directory for code execution (default: current)
@@ -97,17 +98,46 @@ Pop:
9798
produces an error that shouldn't remain in the document.
9899
99100
Verify:
100-
Re-runs every code block (skipping image blocks) and compares actual output
101-
against the recorded output. Prints diffs and exits with code 1 if any output
102-
has changed; exits 0 if everything matches. Use --output <file> to write an
103-
updated copy of the document with the new outputs without modifying the
104-
original.
101+
Re-runs every code block (skipping image and server blocks) and compares
102+
actual output against the recorded output. Prints diffs and exits with code 1
103+
if any output has changed; exits 0 if everything matches. Use --output <file>
104+
to write an updated copy of the document with the new outputs without
105+
modifying the original.
106+
107+
When a document contains a {server} block, verify automatically starts the
108+
server before running subsequent code blocks. A free port is assigned and
109+
made available as $PORT. Use --wait-port <port> to pin a specific port
110+
instead of auto-assigning one.
105111
106112
Extract:
107113
Parses a document and prints the sequence of showboat CLI commands (one per
108114
line) that would recreate it from scratch. Output blocks are omitted since
109-
they are regenerated by "exec". Use --filename <name> to substitute a
110-
different filename in the emitted commands.
115+
they are regenerated by "exec". Server blocks emit "showboat server" commands.
116+
Use --filename <name> to substitute a different filename in the emitted
117+
commands.
118+
119+
Server:
120+
Starts the first {server} code block from a document as a foreground process.
121+
A free port is auto-assigned and made available as $PORT in the server
122+
command's environment. The assigned port is printed to stdout. The server
123+
runs until interrupted (Ctrl-C).
124+
125+
Use --wait-port <port> to pin a specific port instead of auto-assigning one.
126+
This is useful when the server command does not use $PORT.
127+
128+
$ showboat server demo.md
129+
Server running on port 54321
130+
131+
In the markdown, a server block looks like:
132+
133+
```bash {server}
134+
python3 -m http.server $PORT
135+
```
136+
137+
The $PORT variable is optional. If the server command uses a hardcoded port,
138+
pass --wait-port to tell showboat which port to wait for:
139+
140+
$ showboat server demo.md --wait-port 8080
111141
112142
Stdin:
113143
Commands accept input from stdin when the text/code argument is omitted.
@@ -240,6 +270,33 @@ Hello from Python
240270
showboat verify demo.md
241271
```
242272

273+
## Server blocks
274+
275+
Documents can include `{server}` code blocks for long-running server processes. When `showboat verify` encounters a server block, it starts the server in the background, waits for its port, and makes `$PORT` available to all subsequent blocks:
276+
277+
````markdown
278+
```bash {server}
279+
python3 -m http.server $PORT
280+
```
281+
282+
```bash
283+
curl -s http://localhost:$PORT/
284+
```
285+
````
286+
287+
Use `showboat server` to start a server block standalone:
288+
289+
```bash
290+
showboat server demo.md
291+
# Server running on port 54321
292+
```
293+
294+
If the server command hardcodes a port instead of using `$PORT`, pass `--wait-port`:
295+
296+
```bash
297+
showboat server demo.md --wait-port 8080
298+
```
299+
243300
## Extracting
244301

245302
`showboat extract` emits the sequence of commands that would recreate a document from scratch:

cmd/extract.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ func Extract(file, outputFile string) ([]string, error) {
3535
case markdown.CodeBlock:
3636
if b.IsImage {
3737
commands = append(commands, fmt.Sprintf("showboat image %s %s", quotedTarget, shellQuote(b.Code)))
38+
} else if b.IsServer {
39+
commands = append(commands, fmt.Sprintf("showboat server %s %s %s", quotedTarget, b.Lang, shellQuote(b.Code)))
3840
} else {
3941
commands = append(commands, fmt.Sprintf("showboat exec %s %s %s", quotedTarget, b.Lang, shellQuote(b.Code)))
4042
}

cmd/extract_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package cmd
22

33
import (
4+
"os"
45
"path/filepath"
56
"strings"
67
"testing"
8+
9+
"github.com/simonw/showboat/markdown"
710
)
811

912
func TestExtract(t *testing.T) {
@@ -68,6 +71,48 @@ func TestExtractOutputOverride(t *testing.T) {
6871
}
6972
}
7073

74+
func TestExtractServerBlock(t *testing.T) {
75+
dir := t.TempDir()
76+
file := filepath.Join(dir, "demo.md")
77+
78+
// Write a document with a server block
79+
blocks := []markdown.Block{
80+
markdown.TitleBlock{Title: "Server Demo", Timestamp: "2026-02-14T00:00:00Z", Version: "dev"},
81+
markdown.CommentaryBlock{Text: "Start a server."},
82+
markdown.CodeBlock{Lang: "bash", Code: "python3 -m http.server $PORT", IsServer: true},
83+
markdown.CodeBlock{Lang: "bash", Code: "curl -s http://localhost:$PORT/"},
84+
}
85+
86+
f, err := os.Create(file)
87+
if err != nil {
88+
t.Fatal(err)
89+
}
90+
if err := markdown.Write(f, blocks); err != nil {
91+
f.Close()
92+
t.Fatal(err)
93+
}
94+
f.Close()
95+
96+
commands, err := Extract(file, "")
97+
if err != nil {
98+
t.Fatal(err)
99+
}
100+
101+
// Should have: init, note, server, exec
102+
found := false
103+
for _, c := range commands {
104+
if strings.Contains(c, "showboat server") {
105+
found = true
106+
if !strings.Contains(c, "python3 -m http.server $PORT") {
107+
t.Errorf("expected server command to contain code, got: %s", c)
108+
}
109+
}
110+
}
111+
if !found {
112+
t.Errorf("expected a 'showboat server' command, got: %v", commands)
113+
}
114+
}
115+
71116
func TestExtractShellQuote(t *testing.T) {
72117
tests := []struct {
73118
input string

cmd/verify.go

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
6+
"strconv"
57
"strings"
8+
"time"
69

710
execpkg "github.com/simonw/showboat/exec"
811
"github.com/simonw/showboat/markdown"
@@ -28,21 +31,55 @@ func (d Diff) String() string {
2831
// If outputFile is non-empty, an updated copy of the document is written there.
2932
// If workdir is non-empty, code blocks are executed in that directory.
3033
func Verify(file, outputFile, workdir string) ([]Diff, error) {
34+
return VerifyWithPort(file, outputFile, workdir, 0)
35+
}
36+
37+
// VerifyWithPort re-executes all code blocks and compares outputs.
38+
// Server blocks are started as background processes with the given waitPort
39+
// (or an auto-assigned port if waitPort is 0). The PORT environment variable
40+
// is set for all blocks.
41+
func VerifyWithPort(file, outputFile, workdir string, waitPort int) ([]Diff, error) {
3142
blocks, err := readBlocks(file)
3243
if err != nil {
3344
return nil, err
3445
}
3546

3647
var diffs []Diff
48+
var servers []*execpkg.ServerProcess
49+
defer func() {
50+
for _, s := range servers {
51+
s.Stop()
52+
}
53+
}()
54+
55+
// Port for $PORT env var — assigned when a server block is found
56+
port := waitPort
3757

3858
for i := 0; i < len(blocks); i++ {
3959
cb, ok := blocks[i].(markdown.CodeBlock)
4060
if !ok || cb.IsImage {
4161
continue
4262
}
4363

44-
// Execute the code block
45-
output, _, err := execpkg.Run(cb.Lang, cb.Code, workdir)
64+
if cb.IsServer {
65+
// Assign a port if not already set
66+
if port == 0 {
67+
p, err := execpkg.FreePort()
68+
if err != nil {
69+
return nil, fmt.Errorf("finding free port for server block %d: %w", i, err)
70+
}
71+
port = p
72+
}
73+
proc, err := execpkg.RunServer(cb.Lang, cb.Code, workdir, port, 10*time.Second)
74+
if err != nil {
75+
return nil, fmt.Errorf("starting server block %d: %w", i, err)
76+
}
77+
servers = append(servers, proc)
78+
continue
79+
}
80+
81+
// Execute normal code block, with PORT in the environment if set
82+
output, _, err := runWithPort(cb.Lang, cb.Code, workdir, port)
4683
if err != nil {
4784
return nil, fmt.Errorf("executing block %d: %w", i, err)
4885
}
@@ -71,3 +108,48 @@ func Verify(file, outputFile, workdir string) ([]Diff, error) {
71108

72109
return diffs, nil
73110
}
111+
112+
// runWithPort executes code with the PORT environment variable set.
113+
func runWithPort(lang, code, workdir string, port int) (string, int, error) {
114+
if port == 0 {
115+
return execpkg.Run(lang, code, workdir)
116+
}
117+
return execpkg.RunWithEnv(lang, code, workdir, []string{"PORT=" + strconv.Itoa(port)})
118+
}
119+
120+
// Server starts a server process from a showboat document. It finds the first
121+
// {server} code block, starts it, and prints the port. If waitPort is non-zero,
122+
// it uses that port; otherwise it auto-assigns one.
123+
func Server(file, workdir string, waitPort int) (*execpkg.ServerProcess, error) {
124+
blocks, err := readBlocks(file)
125+
if err != nil {
126+
return nil, err
127+
}
128+
129+
for _, block := range blocks {
130+
cb, ok := block.(markdown.CodeBlock)
131+
if !ok || !cb.IsServer {
132+
continue
133+
}
134+
135+
port := waitPort
136+
if port == 0 {
137+
p, err := execpkg.FreePort()
138+
if err != nil {
139+
return nil, fmt.Errorf("finding free port: %w", err)
140+
}
141+
port = p
142+
}
143+
144+
// Set PORT so subsequent commands can use it
145+
os.Setenv("PORT", strconv.Itoa(port))
146+
147+
proc, err := execpkg.RunServer(cb.Lang, cb.Code, workdir, port, 10*time.Second)
148+
if err != nil {
149+
return nil, fmt.Errorf("starting server: %w", err)
150+
}
151+
return proc, nil
152+
}
153+
154+
return nil, fmt.Errorf("no {server} block found in %s", file)
155+
}

cmd/verify_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package cmd
22

33
import (
4+
"fmt"
45
"os"
56
"path/filepath"
67
"strings"
78
"testing"
9+
10+
execpkg "github.com/simonw/showboat/exec"
11+
"github.com/simonw/showboat/markdown"
812
)
913

1014
func TestVerifyPasses(t *testing.T) {
@@ -120,3 +124,73 @@ func TestVerifyWritesOutput(t *testing.T) {
120124
t.Errorf("output file should not contain tampered output, got: %s", updatedContent)
121125
}
122126
}
127+
128+
func TestVerifyWithServerBlock(t *testing.T) {
129+
dir := t.TempDir()
130+
file := filepath.Join(dir, "demo.md")
131+
132+
// Get a free port
133+
port, err := execpkg.FreePort()
134+
if err != nil {
135+
t.Fatal(err)
136+
}
137+
138+
// Build a document with a server block and a curl block
139+
blocks := []markdown.Block{
140+
markdown.TitleBlock{Title: "Server Test", Timestamp: "2026-02-14T00:00:00Z", Version: "dev"},
141+
markdown.CodeBlock{Lang: "bash", Code: "python3 -m http.server $PORT", IsServer: true},
142+
markdown.CodeBlock{Lang: "bash", Code: fmt.Sprintf("curl -s http://localhost:%d/", port)},
143+
}
144+
145+
f, err := os.Create(file)
146+
if err != nil {
147+
t.Fatal(err)
148+
}
149+
if err := markdown.Write(f, blocks); err != nil {
150+
f.Close()
151+
t.Fatal(err)
152+
}
153+
f.Close()
154+
155+
// Verify should start the server, run the curl, and not error
156+
diffs, err := Verify(file, "", "")
157+
if err != nil {
158+
t.Fatalf("Verify failed: %v", err)
159+
}
160+
161+
// We don't check diffs content since the curl output is dynamic,
162+
// but there should be no error (server started and was reachable)
163+
_ = diffs
164+
}
165+
166+
func TestVerifySkipsServerBlockExecution(t *testing.T) {
167+
dir := t.TempDir()
168+
file := filepath.Join(dir, "demo.md")
169+
170+
// Build a document with a server block followed by a normal block
171+
blocks := []markdown.Block{
172+
markdown.TitleBlock{Title: "Test", Timestamp: "2026-02-14T00:00:00Z", Version: "dev"},
173+
markdown.CodeBlock{Lang: "bash", Code: "python3 -m http.server $PORT", IsServer: true},
174+
markdown.CodeBlock{Lang: "bash", Code: "echo hello"},
175+
markdown.OutputBlock{Content: "hello\n"},
176+
}
177+
178+
f, err := os.Create(file)
179+
if err != nil {
180+
t.Fatal(err)
181+
}
182+
if err := markdown.Write(f, blocks); err != nil {
183+
f.Close()
184+
t.Fatal(err)
185+
}
186+
f.Close()
187+
188+
diffs, err := Verify(file, "", "")
189+
if err != nil {
190+
t.Fatalf("Verify failed: %v", err)
191+
}
192+
193+
if len(diffs) != 0 {
194+
t.Errorf("expected no diffs, got %d: %v", len(diffs), diffs)
195+
}
196+
}

0 commit comments

Comments
 (0)