Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
*.test
*.out
.DS_Store
/.claude/worktrees/
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ OpsQuest validates the result, not a prescribed command. Equivalent supported so
## What is included

- 23 Linux missions across four ordered learning worlds
- 16 optional, disposable Docker missions across three worlds: Foundations, Container Triage, and Network Plumbing
- 16 optional, disposable Docker missions across three worlds: It Works on My Machine, Container Triage, and Network Plumbing
- An isolated in-memory filesystem, environment, process table, and archives
- Quote-aware globs and variables, pipelines, redirection, and command history
- A compact virtual `vi` and bounded virtual shell scripts
Expand Down Expand Up @@ -101,8 +101,6 @@ Docker input is parsed into a small set of typed teaching actions. OpsQuest cons

The optional web companion binds only to an ephemeral `127.0.0.1` port, uses a one-time pairing URL, and exposes a read-only mission projection. It cannot submit commands, mutate the profile or attempt, address Docker resources, or approve completion.

The optional web companion binds only to an ephemeral `127.0.0.1` port, uses a one-time pairing URL, and exposes a read-only mission projection. It cannot submit commands, mutate the profile or attempt, address Docker resources, or approve completion.

Read [Sandbox and safety](docs/technical/sandbox-and-safety.md) for trust boundaries, quotas, cleanup behavior, and threat controls.

## Documentation
Expand Down
2 changes: 2 additions & 0 deletions docs/play/controls-and-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ rmdir sed sh sort stat tail tar touch tr uniq vi wc whoami

The shell also supports quote-aware variables and globs, pipelines (`|`), input redirection (`<`), and output redirection (`>` and `>>`). It intentionally implements a teaching subset of each command. Use `help COMMAND` for the exact supported flags and examples.

Enter one command per line. Command lists (`;`, `&&`, `||`), background jobs (`&`), subshells, command substitution, special parameters such as `$?`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data.

## Mission navigation

```console
Expand Down
4 changes: 2 additions & 2 deletions docs/play/mission-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Editable source: [`learning-journey.excalidraw`](diagrams/learning-journey.excal
The curriculum has two independent tracks:

- **Linux:** 23 missions across four ordered worlds. Bare `opsquest play` follows this track.
- **Docker:** 16 optional missions across three worlds: Foundations, Container Triage, then Network Plumbing. Docker readiness never blocks Linux play.
- **Docker:** 16 optional missions across three worlds: It Works on My Machine, Container Triage, then Network Plumbing. Docker readiness never blocks Linux play.

Display numbers are global across both tracks: Docker World 1 occupies Missions 20–25, Linux World 4 resumes at Mission 26, Docker World 2 continues at Mission 30, and Docker World 3 at Mission 35. World and stage positions are derived separately inside each track. Persisted completions use stable mission IDs rather than display numbers, so profiles created before this expansion remain compatible.

Expand Down Expand Up @@ -106,7 +106,7 @@ The final Linux world moves from executing a supplied script to repairing reusab

### Docker World 1: It Works on My Machine

Docker Foundations progresses through listing, logs, sanitized exit status, targeted stop, multi-service recovery, and a mixed lifecycle handoff. Logical aliases hide real container names and IDs, keeping every lesson focused on observable state for exact attempt-owned resources.
The first Docker world progresses through listing, logs, sanitized exit status, targeted stop, multi-service recovery, and a mixed lifecycle handoff. Logical aliases hide real container names and IDs, keeping every lesson focused on observable state for exact attempt-owned resources.

### Docker World 2: Container Triage

Expand Down
21 changes: 16 additions & 5 deletions docs/play/missions-and-progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ Inside a mission:
| `quit` | Leave the current attempt |

The command guide, objective reminder, and `help COMMAND` are free. Progressive
hints trade some XP for increasingly specific guidance. Hint progress remains
recorded while a mission is incomplete, so quitting cannot erase the cost.
hints trade some XP for increasingly specific guidance: each one lowers the
mission's reward, which never falls below a quarter of its base XP. Hint
progress remains recorded while a mission is incomplete, so quitting cannot
erase the cost. Hints you have already revealed are repeated when you resume the
mission and when `hint` runs out, at no extra cost.

## Choose a route

Expand All @@ -56,9 +59,12 @@ starts fresh disposable state while retaining saved profile progress.
## Completion and rewards

When every observable condition passes, OpsQuest closes the active environment
before recording completion. The first completion awards hint-adjusted XP,
records practiced commands, and may unlock achievements. Replays retain the
original completion and reward.
before recording completion. The first completion awards hint-adjusted XP and
may unlock achievements. Replays retain the original completion and reward.

Every successful teaching command counts toward command mastery as soon as it
runs, including during replays. Lab utilities such as `help`, `man`, `clear`,
and `history` are free to use and do not count.

The displayed level is derived from total XP. Ranks progress through:

Expand All @@ -84,5 +90,10 @@ Completions use stable mission IDs rather than display positions. The mission
map can therefore grow without invalidating existing completions, provided
those IDs remain compatible.

`opsquest profile --name NAME` changes your display name, and `opsquest doctor`
shows where the profile is saved. `opsquest reset` asks before erasing all
progress; add `--yes` to skip the question. If the profile file is damaged,
OpsQuest names the file so you can move it aside or reset.

See [Profiles and compatibility](../technical/profiles-and-compatibility.md) for
storage and migration behavior.
4 changes: 2 additions & 2 deletions docs/technical/sandbox-and-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Editable source: [`command-execution-pipeline.mmd`](diagrams/command-execution-p
[`Sandbox.Execute`](https://github.com/Aleksandergreg/go-cli-tool/blob/main/internal/sandbox/shell.go) processes a line in explicit stages:

1. **Bound input:** reject a command line over 64 KiB before adding it to the attempt's 100-entry history.
2. **Lex:** recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Expansion reads only the sandbox environment.
2. **Lex:** reject unquoted shell syntax outside the teaching subset (command lists, background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment.
3. **Parse:** build pipeline stages and attach at most one input and output redirection to each stage.
4. **Expand:** resolve eligible globs against the virtual filesystem and enforce expanded token and argument budgets.
5. **Preflight compositions:** reject unsupported interactive-editor or script placement before an earlier pipeline stage can mutate state.
Expand All @@ -77,7 +77,7 @@ One `sandbox.Sandbox` owns:

| State | Representation | Persistence |
| --- | --- | --- |
| Files and directories | Normalized absolute paths to typed entries with content, mode, and owner | Attempt only |
| Files and directories | Normalized absolute paths to typed entries with content, mode, and owner; the virtual `HOME` directory always exists | Attempt only |
| Working directory | Virtual absolute path | Attempt only; child scripts restore caller scope |
| Environment | String map initialized with virtual `HOME` and `USER` | Attempt only; exported child-script values restore on return |
| Processes | Mission-provided PID map and running flags | Attempt only; no host PID is visible or signalable |
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ func (a *App) Run(args []string) error {
case "reset":
return a.runReset(args[1:])
case "version", "--version", "-v":
if len(args) > 1 {
return fmt.Errorf("version does not accept arguments")
if help, err := a.parseNoArgs("version", args[1:]); help || err != nil {
return err
}
fmt.Fprintf(a.out, "%s %s\n", a.style.Header("OpsQuest"), a.style.Accent(buildinfo.Version))
return nil
Expand Down
73 changes: 73 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,77 @@ func TestSubcommandHelpIsSuccessful(t *testing.T) {
if !strings.Contains(errOut.String(), "Usage: opsquest play") {
t.Fatalf("help output = %s", errOut.String())
}

for _, args := range [][]string{{"guide", "--help"}, {"guide", "-h"}, {"version", "--help"}, {"--version", "-h"}, {"show", "3", "--help"}} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")
app, out, errOut := testApp(t, "", store)
if err := app.Run(args); err != nil {
t.Fatalf("%v returned error: %v", args, err)
}
if !strings.Contains(errOut.String(), "Usage: opsquest ") || out.Len() != 0 {
t.Fatalf("%v stdout = %q, stderr = %q; want usage only", args, out.String(), errOut.String())
}
if player, err := store.Load(); err != nil || player.Onboarded {
t.Fatalf("%v changed the profile: %#v, %v", args, player, err)
}
})
}
}

func TestNoArgumentCommandsRejectArguments(t *testing.T) {
for _, args := range [][]string{{"guide", "extra"}, {"version", "extra"}, {"guide", "--bogus"}} {
store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")
app, _, _ := testApp(t, "", store)
if err := app.Run(args); err == nil {
t.Errorf("%v was accepted", args)
}
}
}

func TestFlagErrorsAreReportedOnce(t *testing.T) {
store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")
app, _, errOut := testApp(t, "", store)
err := app.Run([]string{"show", "--bogus"})
if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -bogus") {
t.Fatalf("show --bogus error = %v", err)
}
if strings.Contains(errOut.String(), "bogus") || strings.Count(errOut.String(), "Usage: opsquest show") != 1 {
t.Fatalf("stderr = %q; want usage once and the error left to the caller", errOut.String())
}
}

func TestFlagsMayFollowTheMissionArgument(t *testing.T) {
tests := []struct {
args []string
wantMission string
wantOnce bool
wantErr string
}{
{args: []string{"4", "--once"}, wantMission: "4", wantOnce: true},
{args: []string{"--once", "4"}, wantMission: "4", wantOnce: true},
{args: []string{"linux-workspace", "-web", "--once"}, wantMission: "linux-workspace", wantOnce: true},
{args: []string{"--", "--once"}, wantMission: "--once"},
{args: []string{"4", "--track", "docker"}, wantErr: "MISSION cannot be combined"},
{args: []string{"4", "5"}, wantErr: "usage: opsquest play"},
{args: []string{"4", "--bogus"}, wantErr: "flag provided but not defined"},
}
for _, test := range tests {
t.Run(strings.Join(test.args, " "), func(t *testing.T) {
app, _, _ := testApp(t, "", profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex"))
options, help, err := app.parsePlayOptions(test.args)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("parsePlayOptions(%q) error = %v, want %q", test.args, err, test.wantErr)
}
return
}
if err != nil || help {
t.Fatalf("parsePlayOptions(%q) = help %v, error %v", test.args, help, err)
}
if options.mission != test.wantMission || options.once != test.wantOnce {
t.Fatalf("parsePlayOptions(%q) = %#v", test.args, options)
}
})
}
}
37 changes: 37 additions & 0 deletions internal/cli/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,40 @@ func TestDockerMissionToolHintsAndOutcomeCompletionPersist(t *testing.T) {
t.Fatal("completed Docker mission retained in-progress hints")
}
}

func TestDockerFollowUpCommandsStayOnTheDockerTrack(t *testing.T) {
store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")
app, out, errOut := testApp(t, "docker start api\n", store)
app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"}
if err := app.Run([]string{"play", "--once", "20"}); err != nil {
t.Fatalf("play Docker mission error = %v; stderr = %s", err, errOut.String())
}
if want := "Continue with opsquest play --track docker, or jump anywhere with opsquest map --track docker."; !strings.Contains(out.String(), want) {
t.Fatalf("Docker recommendation lacks %q:\n%s", want, out.String())
}

tests := []struct {
name string
ref string
route playRoute
want string
}{
{name: "Docker world", ref: "20", route: playRoute{kind: playRouteWorld, worldNumber: 1}, want: "Resume with 'opsquest play --track docker --world 1'."},
{name: "Docker sequence", ref: "20", route: playRoute{kind: playRouteSequential}, want: "Resume unfinished missions with 'opsquest play --track docker'."},
{name: "Linux world", ref: "1", route: playRoute{kind: playRouteWorld, worldNumber: 1}, want: "Resume with 'opsquest play --world 1'."},
{name: "Linux sequence", ref: "1", route: playRoute{kind: playRouteSequential}, want: "Resume unfinished missions with 'opsquest play'."},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
app, out, _ := testApp(t, "", profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex"))
item, found := app.catalog.Find(test.ref)
if !found {
t.Fatalf("mission %s not found", test.ref)
}
app.printRouteFinished(test.route, item, profile.New("alex"))
if !strings.Contains(out.String(), test.want) {
t.Fatalf("route end = %q, want %q", out.String(), test.want)
}
})
}
}
20 changes: 14 additions & 6 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"errors"
"fmt"

"github.com/aleksandergregersen/opsquest/internal/game"
Expand All @@ -16,14 +17,17 @@ func (a *App) runDoctor(args []string) error {
if flags.NArg() > 0 {
return fmt.Errorf("doctor does not accept positional arguments")
}
player, err := a.loadPlayer()
if err != nil {
return fmt.Errorf("profile check failed: %w", err)
}
fmt.Fprintln(a.out, a.style.Header("OpsQuest diagnostics"))
check := a.style.Success("✓")
fmt.Fprintf(a.out, " %s embedded catalog: %d missions (%d Linux, %d Docker)\n", check, len(a.catalog.All()), len(a.catalog.InTrack(mission.TrackLinux)), len(a.catalog.InTrack(mission.TrackDocker)))
fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed))
// A broken profile is reported like any other failed check so the
// remaining diagnostics still run.
player, profileErr := a.loadPlayer()
if profileErr != nil {
fmt.Fprintf(a.out, " %s profile: %v\n", a.style.Failure("✗"), profileErr)
} else {
fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed))
}
fmt.Fprintf(a.out, " %s profile path: %s\n", check, a.store.Path())
fmt.Fprintf(a.out, " %s Linux labs: in-memory; no host shell or filesystem access\n", check)
dockerReady := false
Expand All @@ -36,7 +40,11 @@ func (a *App) runDoctor(args []string) error {
fmt.Fprintf(a.out, " %s docker labs: unavailable · %s\n", a.style.Warning("!"), availability.Detail)
}
}
return a.reportOrphanedLabs(*cleanup, dockerReady)
cleanupErr := a.reportOrphanedLabs(*cleanup, dockerReady)
if profileErr != nil {
return errors.Join(cleanupErr, fmt.Errorf("profile check failed; see the diagnostics above"))
}
return cleanupErr
}

// reportOrphanedLabs checks for, or with cleanup removes, lab resources left
Expand Down
37 changes: 37 additions & 0 deletions internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -98,3 +99,39 @@ func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) {
}
})
}

func TestDoctorReportsACorruptProfileAndKeepsChecking(t *testing.T) {
path := filepath.Join(t.TempDir(), "profile.json")
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
catalog, err := mission.LoadCatalog()
if err != nil {
t.Fatal(err)
}
out := &bytes.Buffer{}
factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "Docker is ready for this mission."}}
app := New(Config{Out: out, ErrOut: &bytes.Buffer{}, Catalog: catalog, Store: profile.NewStore(path, "alex"), Factory: factory})

err = app.Run([]string{"doctor"})
if err == nil || !strings.Contains(err.Error(), "profile check failed") {
t.Fatalf("doctor error = %v, want a failed profile check", err)
}
output := out.String()
for _, want := range []string{
"embedded catalog:",
"✗ profile: decode profile " + path,
"opsquest reset",
"profile path: " + path,
"Linux labs: in-memory",
"docker labs: ready",
"docker cleanup: no orphaned lab resources",
} {
if !strings.Contains(output, want) {
t.Errorf("doctor output lacks %q:\n%s", want, output)
}
}
if content, readErr := os.ReadFile(path); readErr != nil || string(content) != "{not json" {
t.Fatalf("doctor changed the corrupt profile: %q, %v", content, readErr)
}
}
Loading
Loading