Go library reference¶
sbsh ships a small public Go library so external programs can spawn,
drive, and tear down Terminal and Client processes without shelling
out to sb / sbsh or importing internal/ packages. This page
documents the supported surface and its stability promise.
A complete, build-tested example lives at
docs/examples/library-consumer/.
Supported packages¶
The public library lives under github.com/eminwux/sbsh/pkg:
| Package | Purpose |
|---|---|
pkg/api |
Declarative document types (TerminalDoc, TerminalSpec, ClientDoc, TerminalProfileDoc, …) and RPC payload types (PingMessage, WriteRequest, SubscribeRequest, StopArgs, …). Counterpart of the YAML/JSON manifests. |
pkg/builder |
Construct a TerminalSpec or ClientDoc from inline options. BuildTerminalSpec is the profile-free lane (WithCommand, WithEnv, WithStages, WithOnInit, WithPostAttach, WithPrompt, WithEnvInherit, …); BuildTerminalSpecFromProfile is the profile-driven lane that additionally honors WithProfile / WithProfilesDir. No YAML round-trip. |
pkg/spawn |
Launch detached Terminal and Client subprocesses (NewTerminal, NewClient). Returns TerminalHandle / ClientHandle with PID, SocketPath, WaitReady, WaitClose, Close. |
pkg/discovery |
Enumerate live terminals/clients or look them up by ID/Name under a run path (ScanTerminals, FindTerminalByID, FindTerminalByName, ScanClients, FindClientByName). Load profiles from disk (LoadProfilesFromDir, FindProfileByNameInDir). |
pkg/rpcclient/terminal |
JSON-RPC client for the TerminalController socket (Ping, Resize, Attach, Detach, Metadata, State, Stop, Write, Subscribe). |
pkg/rpcclient/client |
JSON-RPC client for the ClientController socket (Ping, Metadata, State, Stop, Detach). |
pkg/errors |
Curated error sentinels re-exported from internal/errdefs. Use errors.Is to branch on well-known failure modes without importing internal packages. |
Nothing under internal/ is part of the supported surface and
Go's visibility rules prevent external modules from importing it.
StateRoot contract¶
Every operation that touches disk derives its paths from a single
run path (also called StateRoot in kukeon, --run-path on the
CLI). External callers must supply this path explicitly — an
empty run path returns errors.ErrRunPathRequired. There is no
silent fallback to $HOME/.sbsh; the point of the parameter is to
let callers run several isolated sbsh stacks on one host.
Given a run path R, the stack writes:
R/terminals/<id>/{metadata.json,socket,capture,log}— one directory per spawned terminal.R/clients/<id>/{metadata.json,socket,log}— one directory per spawned client.R/.sbsh/profiles.d/— the default profile-lookup directory whenWithProfilesDiris not set. Override withWithProfilesDir(myPath)to point anywhere else.
Library consumers are expected to pass the same run path to every
pkg call in a given operation, and to pass the child processes a
matching --run-path flag. pkg/spawn does this for you via
TerminalOptions.ExtraArgs / ClientOptions.ExtraArgs.
Binary-path contract¶
pkg/spawn deliberately does not consult $PATH. Callers
must set TerminalOptions.BinaryPath / ClientOptions.BinaryPath
to an absolute path of an sbsh or sb binary that supports the
relevant subcommands. This keeps state-root-style isolation from
being silently broken by whatever happens to be first on the
system path.
Both binaries are produced by the canonical make sbsh-sb target:
sbsh is the real ELF and sb is a hard link to it; argv[0]
selects the subtree. Pass the sbsh path to NewTerminal and the
sb path to NewClient.
Pre-v1 stability policy¶
sbsh is pre-v1. The intent of this policy is to give library consumers — notably the kukeon integration that drives umbrella issue #118 — enough stability to ship without freezing the surface before v1 has been validated in anger.
What will not change between minor releases (0.x → 0.x+1):
- Wire schema of documents whose
apiVersionissbsh/v1beta1(TerminalProfileDoc,TerminalDoc,ClientDoc,ConfigurationDoc). Existing fields will not be removed, renamed, or re-typed within the v1beta1 apiVersion; new fields may be added. - The identity of error sentinels re-exported from
pkg/errors.errors.Isagainst those names will continue to match errors produced anywhere in sbsh. - The shape of exported interfaces in
pkg/api(TerminalController,ClientController): method removals and signature-breaking changes will not happen within a minor release.
What may change between minor releases:
- Option /
With*function names and defaults inpkg/builderandpkg/spawn. Callers should expect churn as the library ergonomics settle. - Additions to the curated sentinel set in
pkg/errors. - Additions to exported interfaces in
pkg/api(new methods) and additions of new request/response payload types.
What will not change without a deprecation cycle:
- Removal of any symbol currently documented in
pkg/api,pkg/errors, or any subpackage listed above. Removals will be staged by marking the symbol deprecated in a minor release and removing it no earlier than the next minor release.
v1 will be cut when umbrella issue
#118 closes and the
library surface has shipped at least one downstream integration.
At v1 the apiVersion advances out of v1beta1 and the above "may
change" list collapses into "will not change without a deprecation
cycle". See the godoc for
pkg/api
for the authoritative copy of this policy.
Walkthrough¶
The steps below mirror
docs/examples/library-consumer/main.go.
1. Build a TerminalSpec¶
pkg/builder exposes two entry points. BuildTerminalSpec is the
profile-free lane — it never touches pkg/discovery, never resolves
an implicit "default" profile, and rejects WithProfile /
WithProfilesDir with errdefs.ErrInvalidOption so misuse fails
loud:
spec, err := builder.BuildTerminalSpec(ctx, logger, stateRoot,
builder.WithName("library-consumer-example"),
builder.WithCommand([]string{"/bin/bash", "--norc", "--noprofile"}),
builder.WithEnv(map[string]string{"PS1": "example> "}),
builder.WithPrompt("example> "),
builder.WithOnInit([]api.ExecStep{{Script: "echo init"}}),
)
Use BuildTerminalSpecFromProfile instead when the caller wants a
YAML profile on disk to provide defaults (empty WithProfile
resolves to "default", missing "default" falls back to the
hardcoded profile). Inline With* values override profile values
on this lane:
spec, err := builder.BuildTerminalSpecFromProfile(ctx, logger, stateRoot,
builder.WithProfilesDir("/etc/sbsh/profiles.d"),
builder.WithProfile("dev"),
builder.WithName("library-consumer-example"),
)
runPath is required on both lanes; everything else has a sane
default.
2. Spawn the terminal¶
term, err := spawn.NewTerminal(ctx, spec, spawn.TerminalOptions{
BinaryPath: sbshPath,
ExtraArgs: []string{"--run-path", stateRoot},
Logger: logger,
ReadyTimeout: 15 * time.Second,
})
if err := term.WaitReady(ctx); err != nil { /* handle */ }
WaitReady blocks until the terminal's control socket accepts a
Ping. It returns ErrProcessExited if the child dies before
ready, ErrReadyTimeout on the internal cap, or ctx.Err() on
cancellation.
3. Spawn a client that attaches¶
doc, err := builder.BuildClientDoc(ctx, logger, stateRoot,
builder.WithClientName("library-consumer-client"),
builder.WithClientMode(api.AttachToTerminal),
builder.WithClientTerminalSpec(&api.TerminalSpec{Name: spec.Name}),
builder.WithClientDetachKeystroke(false),
)
cli, err := spawn.NewClient(ctx, doc, spawn.ClientOptions{
BinaryPath: sbPath,
ExtraArgs: []string{"--run-path", stateRoot},
Logger: logger,
ReadyTimeout: 15 * time.Second,
})
if err := cli.WaitReady(ctx); err != nil { /* handle */ }
4. Drive the Terminal RPC surface¶
trpc := terminalrpc.NewUnix(term.SocketPath(), logger)
// Subscribe BEFORE writing so no output is missed.
stream, err := trpc.Subscribe(ctx,
&api.SubscribeRequest{ClientID: api.ID("library-consumer")},
&api.Empty{})
defer stream.Close()
err = trpc.Write(ctx, &api.WriteRequest{Data: []byte("echo hello\n")})
// Read bytes off stream.Read(...) with a deadline.
Subscribe returns a live net.Conn that streams PTY output as raw
bytes — no ANSI stripping, no line framing. Callers typically set
a read deadline and scan for a known marker.
5. Drive the Client RPC surface¶
crpc := clientrpc.NewUnix(cli.SocketPath())
var state api.ClientStatusMode
_ = crpc.State(ctx, &state) // observe lifecycle
_ = crpc.Stop(ctx, &api.StopArgs{Reason: "done"}) // tear down client
6. Close the handles¶
Close is idempotent: it sends a Stop RPC, escalates to SIGTERM
and then SIGKILL if needed, and returns the process exit error
(nil on clean exit). When Stop has already triggered shutdown
the deferred Close becomes a safety net — expect a non-zero
exit status to surface here because the child exited in response
to the Stop signal.
See also¶
- Architecture: process model — how Terminal and Client processes relate on the wire.
- Concepts: client — what a Client is and
what
AttachToTerminalvsRunNewTerminalmean. pkg/apigodoc — authoritative type documentation.