diff --git a/internal/branding/branding.go b/internal/branding/branding.go new file mode 100644 index 0000000..3ad1966 --- /dev/null +++ b/internal/branding/branding.go @@ -0,0 +1,23 @@ +// Package branding renders the devstack ASCII logo for help and version banners +// (spec 30). It is decorative only; callers gate it off under --json/--quiet so +// the machine-output contract (ARCHITECTURE §7.9) always holds. +package branding + +import ( + _ "embed" + "strings" +) + +//go:embed logo.txt +var logo string + +// Tagline is the one-line description shown under the logo. +const Tagline = "Docker dev environments with shared infrastructure" + +// Logo returns the raw ASCII logo, trailing newlines trimmed. +func Logo() string { return strings.TrimRight(logo, "\n") } + +// Banner returns the logo, a tagline, and the version, ready to print above help. +func Banner(version string) string { + return Logo() + "\n\n " + Tagline + " · " + version + "\n" +} diff --git a/internal/branding/logo.txt b/internal/branding/logo.txt new file mode 100644 index 0000000..5983e2c --- /dev/null +++ b/internal/branding/logo.txt @@ -0,0 +1,5 @@ + _ _ _ + __| | _____ _| |_ __ _ ___| | __ + / _` |/ _ \ \ / / __/ _` |/ __| |/ / +| (_| | __/\ V /| || (_| | (__| < + \__,_|\___| \_/ \__\__,_|\___|_|\_\ diff --git a/internal/cli/activectx.go b/internal/cli/activectx.go new file mode 100644 index 0000000..c67cffd --- /dev/null +++ b/internal/cli/activectx.go @@ -0,0 +1,37 @@ +package cli + +import ( + "os" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/state" +) + +// resolveActiveProject picks the default project for a command when none was given +// on the --project flag (spec 30 active-context resolution). Precedence: +// +// DEVSTACK_PROJECT env → the persisted active context → the single/first project. +// +// The persisted active project is only honored when its workspace_root matches the +// workspace discovered at command time, so a stale pointer never leaks a project +// into a different workspace. Any candidate must name a real project in the model. +// db may be nil (callers without a ledger handle); persisted resolution is skipped. +func resolveActiveProject(m *config.Model, db *state.DB) string { + names := sortedProjectNames(m) + inModel := func(p string) bool { return p != "" && contains(names, p) } + + if env := os.Getenv("DEVSTACK_PROJECT"); inModel(env) { + return env + } + if db != nil { + if a, ok, err := db.ActiveContext(); err == nil && ok { + if a.WorkspaceRoot == m.Root && inModel(a.Project) { + return a.Project + } + } + } + if len(names) > 0 { + return names[0] + } + return "" +} diff --git a/internal/cli/context.go b/internal/cli/context.go new file mode 100644 index 0000000..686368e --- /dev/null +++ b/internal/cli/context.go @@ -0,0 +1,256 @@ +package cli + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/lock" + "github.com/open-source-cloud/devstack/internal/version" + "github.com/open-source-cloud/devstack/internal/workspace" +) + +// contextInfo is the resolved active-context projection printed by `context` and +// used for the console header + shell prompt segment (spec 30). +type contextInfo struct { + Workspace string `json:"workspace"` + Root string `json:"root"` + Project string `json:"project,omitempty"` + Role string `json:"role,omitempty"` + Docker string `json:"dockerContext"` + Backend string `json:"backend"` + Version string `json:"version"` +} + +// resolveContext gathers the active-context projection from a manager. All fields +// come from the already-loaded model + ledger; no new data sources. +func resolveContext(mgr *workspace.Manager) contextInfo { + proj := resolveActiveProject(mgr.Model, mgr.DB) + ci := contextInfo{ + Workspace: mgr.Model.Workspace.Name, + Root: mgr.Model.Root, + Project: proj, + Docker: mgr.Docker.ContextName(), + Backend: backendLabel(mgr), + Version: version.Version, + } + if proj != "" { + // The per-project Postgres role is the sanitized project name (hyphens → + // underscores), matching provision.pgIdent. Display-only. + ci.Role = strings.ReplaceAll(proj, "-", "_") + } + return ci +} + +// backendLabel describes where the shared stack runs: "local", or the remote +// docker context / host endpoint. +func backendLabel(mgr *workspace.Manager) string { + b := mgr.Model.Workspace.Backend + if b == nil || !b.IsRemote() { + return "local" + } + if b.Context != "" { + return "remote:" + b.Context + } + return "remote:" + b.Host +} + +// renderContextHeader prints a compact active-context line atop human command +// output (status/up). Suppressed under --json/--quiet — the update-notice +// precedent (root.go). Best-effort: a nil/empty manager prints nothing. +func renderContextHeader(cmd *cobra.Command, mgr *workspace.Manager, g *GlobalOpts) { + if g.JSON || g.Quiet || mgr == nil { + return + } + ci := resolveContext(mgr) + parts := []string{"workspace " + ci.Workspace} + if ci.Project != "" { + parts = append(parts, "project "+ci.Project) + } + if ci.Role != "" { + parts = append(parts, "role "+ci.Role) + } + parts = append(parts, "docker "+ci.Docker, ci.Version) + fmt.Fprintf(cmd.OutOrStdout(), "devstack · %s\n\n", strings.Join(parts, " · ")) +} + +// newContextCmd wires the read-only `context` command: print the resolved active +// workspace/project/role/docker-context/version. Lock-free. +func newContextCmd(g *GlobalOpts) *cobra.Command { + return &cobra.Command{ + Use: "context", + Short: "Show the active workspace, project, role and Docker context", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + ci := resolveContext(mgr) + if g.JSON { + return writeJSON(cmd, ci) + } + tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "workspace\t%s\n", ci.Workspace) + fmt.Fprintf(tw, "root\t%s\n", ci.Root) + if ci.Project != "" { + fmt.Fprintf(tw, "project\t%s\n", ci.Project) + } else { + fmt.Fprintf(tw, "project\t(none — run `devstack use `)\n") + } + if ci.Role != "" { + fmt.Fprintf(tw, "db role\t%s\n", ci.Role) + } + fmt.Fprintf(tw, "docker context\t%s\n", ci.Docker) + fmt.Fprintf(tw, "backend\t%s\n", ci.Backend) + fmt.Fprintf(tw, "version\t%s\n", ci.Version) + return tw.Flush() + }, + } +} + +// newUseCmd wires `use [name]`: set the active project (or switch to a registered +// workspace). It persists the active context to the ledger under the flock, and +// with --print emits an eval-able script (cd + export) so a shell wrapper can make +// the switch mutate the live shell (spec 30; the wrapper ships with `shell-init`). +func newUseCmd(g *GlobalOpts) *cobra.Command { + var project string + var printScript bool + cmd := &cobra.Command{ + Use: "use [name]", + Short: "Set the active project (or switch workspace); persists across terminals", + Long: "Set the active workspace/project. Names a project in the current workspace, or a\n" + + "registered workspace to switch to. The choice is persisted (per Docker context) and\n" + + "becomes the default for db/s3/queue/... commands. A child process cannot change its\n" + + "parent shell, so `--print` emits an eval-able script (cd + export DEVSTACK_*); the\n" + + "`devstack` shell wrapper from `devstack shell-init` runs that for you.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + mgr, closeFn, err := buildManager(cmd) + if err != nil { + return err + } + defer closeFn() + + targetRoot := mgr.Model.Root + targetProject := "" + projects := sortedProjectNames(mgr.Model) + + switch { + case project != "": + if !contains(projects, project) { + return fmt.Errorf("no project %q in workspace %q", project, mgr.Model.Workspace.Name) + } + targetProject = project + case len(args) == 1: + name := args[0] + switch { + case contains(projects, name): + targetProject = name + default: + // Try a registered workspace by name. + root, ok, err := lookupWorkspaceRoot(mgr, name) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("no project or registered workspace named %q", name) + } + targetRoot = root + } + default: + // Bare `use`: report current context + candidates (the fuzzy picker + // TUI lands in the shell-integration phase). + return printUseHint(cmd, mgr, projects) + } + + if err := lock.WithLock(cmd.Context(), mgr.LockPath, func() error { + return mgr.DB.SetActiveContext(targetRoot, targetProject) + }); err != nil { + return err + } + + if printScript { + emitUseScript(cmd, targetRoot, targetProject) + return nil + } + if g.JSON { + return writeJSON(cmd, map[string]string{"workspace": targetRoot, "project": targetProject}) + } + if !g.Quiet { + w := cmd.OutOrStdout() + if targetProject != "" { + fmt.Fprintf(w, "active project → %s\n", targetProject) + } else { + fmt.Fprintf(w, "active workspace → %s\n", targetRoot) + } + fmt.Fprintln(w, "tip: add `eval \"$(devstack shell-init zsh)\"` so `use` also cd's your shell") + } + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "force-select a project in the current workspace") + cmd.Flags().BoolVar(&printScript, "print", false, "emit an eval-able shell script (cd + export) instead of persisting only") + return cmd +} + +// lookupWorkspaceRoot resolves a registered workspace name to its root (lock-free). +func lookupWorkspaceRoot(mgr *workspace.Manager, name string) (string, bool, error) { + rows, err := mgr.DB.ListWorkspaces() + if err != nil { + return "", false, err + } + for _, w := range rows { + if w.Name == name { + return w.Root, true, nil + } + } + return "", false, nil +} + +// emitUseScript writes the POSIX eval script the shell wrapper runs. Fish support +// is handled by the `shell-init` wrapper (it re-emits in fish syntax). +func emitUseScript(cmd *cobra.Command, root, project string) { + w := cmd.OutOrStdout() + fmt.Fprintf(w, "cd %s\n", shellQuote(root)) + fmt.Fprintf(w, "export DEVSTACK_WORKSPACE=%s\n", shellQuote(root)) + if project != "" { + fmt.Fprintf(w, "export DEVSTACK_PROJECT=%s\n", shellQuote(project)) + } else { + fmt.Fprintln(w, "unset DEVSTACK_PROJECT") + } +} + +// printUseHint reports the current active context and the selectable projects when +// `use` is invoked with no target. +func printUseHint(cmd *cobra.Command, mgr *workspace.Manager, projects []string) error { + w := cmd.OutOrStdout() + active := resolveActiveProject(mgr.Model, mgr.DB) + if active != "" { + fmt.Fprintf(w, "active project: %s\n", active) + } else { + fmt.Fprintln(w, "active project: (none)") + } + if len(projects) == 0 { + fmt.Fprintln(w, "no projects in this workspace") + return nil + } + fmt.Fprintln(w, "projects:") + for _, p := range projects { + marker := " " + if p == active { + marker = "* " + } + fmt.Fprintf(w, "%s%s\n", marker, p) + } + fmt.Fprintln(w, "run `devstack use ` to select one") + return nil +} + +// shellQuote single-quotes a value for safe eval in POSIX shells. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/cli/context_test.go b/internal/cli/context_test.go new file mode 100644 index 0000000..c65888e --- /dev/null +++ b/internal/cli/context_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" + "github.com/open-source-cloud/devstack/internal/state" +) + +func TestUseContextRegistered(t *testing.T) { + for _, name := range []string{"use", "context"} { + if !findCmd(t, name) { + t.Fatalf("%q must be a real RunE command", name) + } + } +} + +// twoProjectModel loads a workspace with projects api + web (api sorts first). +func twoProjectModel(t *testing.T) *config.Model { + t.Helper() + proj := func(n string) string { + return "apiVersion: devstack/v1\nkind: Project\nname: " + n + + "\nservices:\n app: { template: node.vite }\n" + } + root := writeWS(t, + "apiVersion: devstack/v1\nkind: Workspace\nname: demo\n"+ + "shared:\n postgres: { template: postgres }\n"+ + "projects:\n - { name: api, path: api }\n - { name: web, path: web }\n", + map[string]string{"api": proj("api"), "web": proj("web")}, + ) + m, err := config.LoadAt(root) + if err != nil { + t.Fatalf("load: %v", err) + } + return m +} + +func TestResolveActiveProject(t *testing.T) { + m := twoProjectModel(t) + db, err := state.Open(context.Background(), t.TempDir(), "ctx") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + // Fallback: first project by name. + if got := resolveActiveProject(m, db); got != "api" { + t.Errorf("fallback = %q, want api", got) + } + // nil db must not panic and still falls back. + if got := resolveActiveProject(m, nil); got != "api" { + t.Errorf("nil-db fallback = %q, want api", got) + } + + // Persisted active (matching root) wins over the fallback. + if err := db.SetActiveContext(m.Root, "web"); err != nil { + t.Fatal(err) + } + if got := resolveActiveProject(m, db); got != "web" { + t.Errorf("persisted = %q, want web", got) + } + + // A persisted project for a DIFFERENT workspace root is ignored. + if err := db.SetActiveContext("/some/other/root", "web"); err != nil { + t.Fatal(err) + } + if got := resolveActiveProject(m, db); got != "api" { + t.Errorf("stale-root persisted = %q, want api (ignored)", got) + } + + // DEVSTACK_PROJECT env wins over everything (when it names a real project). + _ = db.SetActiveContext(m.Root, "api") + t.Setenv("DEVSTACK_PROJECT", "web") + if got := resolveActiveProject(m, db); got != "web" { + t.Errorf("env override = %q, want web", got) + } + // An env value that is not a project in the model is ignored. + t.Setenv("DEVSTACK_PROJECT", "ghost") + if got := resolveActiveProject(m, db); got != "api" { + t.Errorf("bad env = %q, want api (persisted)", got) + } +} diff --git a/internal/cli/messaging.go b/internal/cli/messaging.go index 63ce580..2fe6583 100644 --- a/internal/cli/messaging.go +++ b/internal/cli/messaging.go @@ -5,7 +5,6 @@ import ( "github.com/spf13/cobra" - "github.com/open-source-cloud/devstack/internal/config" "github.com/open-source-cloud/devstack/internal/orchestrate" "github.com/open-source-cloud/devstack/internal/state" ) @@ -90,7 +89,7 @@ func listMessagingRows(cmd *cobra.Command, kind, project string, all bool) ([]st defer closeFn() proj := project if proj == "" { - proj = defaultProjectFromModel(mgr.Model) + proj = resolveActiveProject(mgr.Model, mgr.DB) } var rows []state.Provisioned if proj != "" && !all { @@ -110,15 +109,6 @@ func listMessagingRows(cmd *cobra.Command, kind, project string, all bool) ([]st return out, proj, nil } -// defaultProjectFromModel returns the workspace's single/first project by name. -func defaultProjectFromModel(m *config.Model) string { - names := sortedProjectNames(m) - if len(names) > 0 { - return names[0] - } - return "" -} - func contains(s []string, v string) bool { for _, x := range s { if x == v { diff --git a/internal/cli/resource.go b/internal/cli/resource.go index f0d5186..c701306 100644 --- a/internal/cli/resource.go +++ b/internal/cli/resource.go @@ -334,13 +334,11 @@ func newResourceGcCmd(g *GlobalOpts) *cobra.Command { // --- helpers --------------------------------------------------------------- -// defaultProject returns the workspace's single project, or the first by name. +// defaultProject resolves the default project for a data-plane command: the +// active-context resolution (DEVSTACK_PROJECT → persisted active → single/first), +// spec 30. func defaultProject(d orchestrate.UpDeps) string { - names := sortedProjectNames(d.Model) - if len(names) > 0 { - return names[0] - } - return "" + return resolveActiveProject(d.Model, d.DB) } // engineForKindGuess maps a ledger kind back to its engine for the live set diff --git a/internal/cli/root.go b/internal/cli/root.go index e6fc527..4b21d85 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/open-source-cloud/devstack/internal/alias" + "github.com/open-source-cloud/devstack/internal/branding" "github.com/open-source-cloud/devstack/internal/selfupdate" "github.com/open-source-cloud/devstack/internal/version" "github.com/open-source-cloud/devstack/internal/xdg" @@ -45,8 +46,9 @@ func NewRootCmd(opts Options) *cobra.Command { root := &cobra.Command{ Use: brand.Name, Short: "Docker-based dev environments with infrastructure shared across projects", - // The version is shown on top of the help banner (fang renders Long first). - Long: brand.Name + " " + version.Version + "\n\n" + + // fang renders Long at the top of help; it exposes no logo option, so the + // ASCII logo lives here (the version line follows it). + Long: branding.Logo() + "\n\n" + brand.Name + " " + version.Version + "\n\n" + "devstack manages Docker-based development environments and shares infrastructure\n" + "(one warm Postgres/Redis/MinIO) across many project stacks in a workspace.", Version: version.String(), @@ -81,6 +83,8 @@ func NewRootCmd(opts Options) *cobra.Command { newDownCmd(g), newShellCmd(g), newStatusCmd(g), + newUseCmd(g), + newContextCmd(g), newLogsCmd(g), newDashboardCmd(g), newDnsCmd(g), diff --git a/internal/cli/status.go b/internal/cli/status.go index e891836..0745358 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -40,6 +40,7 @@ func newStatusCmd(g *GlobalOpts) *cobra.Command { if g.JSON { return writeJSON(cmd, map[string]any{"projects": projects, "shared": shared}) } + renderContextHeader(cmd, mgr, g) renderStatus(cmd, projects, shared) return nil }, diff --git a/internal/state/active.go b/internal/state/active.go new file mode 100644 index 0000000..e1d056d --- /dev/null +++ b/internal/state/active.go @@ -0,0 +1,66 @@ +package state + +import ( + "database/sql" + "fmt" +) + +// This file is the spec-30 active-context pointer: the persisted "current" +// workspace root + project for a Docker context. It is the default the CLI +// resolves when neither --project nor DEVSTACK_PROJECT/DEVSTACK_WORKSPACE is set. +// One row per db.Ctx. +// +// SetActiveContext / ClearActiveContext mutate and MUST run while holding the +// machine-global flock. ActiveContext is a lock-free snapshot. + +// ActiveContext is the persisted active workspace+project for a Docker context. +type ActiveContext struct { + WorkspaceRoot string // absolute workspace root (where workspace.yaml lives), "" if unset + Project string // active project name within that workspace, "" if unset + UpdatedAt string +} + +// SetActiveContext upserts the (ctx) active-context row. An empty project clears +// the active project while keeping the workspace root. Mutating — hold the lock. +func (db *DB) SetActiveContext(workspaceRoot, project string) error { + _, err := db.Exec(` + INSERT INTO active_context (ctx, workspace_root, project, updated_at) + VALUES (?,?,?,datetime('now')) + ON CONFLICT(ctx) + DO UPDATE SET workspace_root=excluded.workspace_root, + project=excluded.project, + updated_at=datetime('now')`, + db.Ctx, workspaceRoot, project) + if err != nil { + return fmt.Errorf("set active context (%s/%s): %w", workspaceRoot, project, err) + } + return nil +} + +// ActiveContext returns the persisted active context for this Docker context. +// The bool is false when none is set. Lock-free. +func (db *DB) ActiveContext() (ActiveContext, bool, error) { + var a ActiveContext + var root, project sql.NullString + err := db.QueryRow( + `SELECT workspace_root, project, updated_at FROM active_context WHERE ctx=?`, db.Ctx). + Scan(&root, &project, &a.UpdatedAt) + if err == sql.ErrNoRows { + return ActiveContext{}, false, nil + } + if err != nil { + return ActiveContext{}, false, fmt.Errorf("read active context: %w", err) + } + a.WorkspaceRoot = root.String + a.Project = project.String + return a, true, nil +} + +// ClearActiveContext removes the active-context row for this context (a no-op if +// absent). Mutating — hold the lock. +func (db *DB) ClearActiveContext() error { + if _, err := db.Exec(`DELETE FROM active_context WHERE ctx=?`, db.Ctx); err != nil { + return fmt.Errorf("clear active context: %w", err) + } + return nil +} diff --git a/internal/state/active_test.go b/internal/state/active_test.go new file mode 100644 index 0000000..f7ec5cc --- /dev/null +++ b/internal/state/active_test.go @@ -0,0 +1,90 @@ +package state + +import ( + "context" + "testing" +) + +func TestActiveContextCRUD(t *testing.T) { + db := openTestDB(t) + + // Nothing set yet. + if _, ok, err := db.ActiveContext(); err != nil || ok { + t.Fatalf("fresh ActiveContext = ok %v err %v, want ok=false", ok, err) + } + + // Set a workspace + project. + if err := db.SetActiveContext("/src/acme", "api"); err != nil { + t.Fatalf("set: %v", err) + } + a, ok, err := db.ActiveContext() + if err != nil || !ok { + t.Fatalf("read = ok %v err %v, want ok=true", ok, err) + } + if a.WorkspaceRoot != "/src/acme" || a.Project != "api" { + t.Fatalf("read = %+v, want /src/acme/api", a) + } + if a.UpdatedAt == "" { + t.Error("updated_at should be stamped") + } + + // Upsert: single row per ctx, values replaced. + if err := db.SetActiveContext("/src/demo", "web"); err != nil { + t.Fatalf("re-set: %v", err) + } + a, _, _ = db.ActiveContext() + if a.WorkspaceRoot != "/src/demo" || a.Project != "web" { + t.Fatalf("after upsert = %+v, want /src/demo/web", a) + } + + // Empty project clears the project but keeps the workspace root. + if err := db.SetActiveContext("/src/demo", ""); err != nil { + t.Fatalf("clear project: %v", err) + } + a, _, _ = db.ActiveContext() + if a.WorkspaceRoot != "/src/demo" || a.Project != "" { + t.Fatalf("after clear-project = %+v, want /src/demo with empty project", a) + } + + // Clear removes the row entirely. + if err := db.ClearActiveContext(); err != nil { + t.Fatalf("clear: %v", err) + } + if _, ok, _ := db.ActiveContext(); ok { + t.Error("after clear, ActiveContext should report unset") + } + // Clearing again is a no-op. + if err := db.ClearActiveContext(); err != nil { + t.Fatalf("second clear: %v", err) + } +} + +func TestActiveContextScopedByDockerContext(t *testing.T) { + dir := t.TempDir() + ctx := context.Background() + dbA, err := Open(ctx, dir, "ctxA") + if err != nil { + t.Fatalf("open ctxA: %v", err) + } + defer dbA.Close() + dbB, err := Open(ctx, dir, "ctxB") + if err != nil { + t.Fatalf("open ctxB: %v", err) + } + defer dbB.Close() + + if err := dbA.SetActiveContext("/src/acme", "api"); err != nil { + t.Fatalf("set A: %v", err) + } + // ctxB sees no active context — rows are keyed by Docker context. + if _, ok, _ := dbB.ActiveContext(); ok { + t.Error("ctxB should not see ctxA's active context") + } + if err := dbB.SetActiveContext("/src/other", "worker"); err != nil { + t.Fatalf("set B: %v", err) + } + a, _, _ := dbA.ActiveContext() + if a.Project != "api" { + t.Errorf("ctxA active project = %q, want api (isolated from ctxB)", a.Project) + } +} diff --git a/internal/state/migrations.go b/internal/state/migrations.go index fda7287..a26527b 100644 --- a/internal/state/migrations.go +++ b/internal/state/migrations.go @@ -15,6 +15,7 @@ var migrations = []migration{ {version: 1, stmt: schemaV1}, {version: 2, stmt: schemaV2}, {version: 3, stmt: schemaV3}, + {version: 4, stmt: schemaV4}, } // schemaV1 is the initial ledger (spec 08 §Tables). Every mutable row is scoped @@ -134,6 +135,23 @@ CREATE TABLE IF NOT EXISTS workspace ( ); ` +// schemaV4 (spec 30) adds the active-context pointer: the persisted "current" +// workspace root + project for this Docker context — the default the CLI resolves +// when neither --project nor DEVSTACK_PROJECT/DEVSTACK_WORKSPACE is set. One row +// per ctx (a single active context per machine-context), CASCADE-deleted with its +// docker_context like every other ledger table. It is a POINTER only: the project +// is only honored when its workspace_root matches the workspace discovered at +// command time, so a stale pointer never leaks a project into another workspace. +const schemaV4 = ` +CREATE TABLE IF NOT EXISTS active_context ( + ctx TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL DEFAULT '', + project TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (ctx) REFERENCES docker_context(name) ON DELETE CASCADE +); +` + // migrate applies any pending migrations inside a transaction per step, backing // up the DB file before the first mutating step. Forward-only. func (db *DB) migrate() error {