diff --git a/Cargo.lock b/Cargo.lock index 2de11de..76fb608 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1218,7 +1218,6 @@ dependencies = [ "serde_yaml", "sha2", "sqlformat", - "sysinfo", "tabled", "tar", "tempfile", @@ -1902,15 +1901,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - [[package]] name = "num" version = "0.4.3" @@ -1997,25 +1987,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", -] - -[[package]] -name = "objc2-io-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" -dependencies = [ - "libc", - "objc2-core-foundation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -3070,20 +3041,6 @@ dependencies = [ "syn", ] -[[package]] -name = "sysinfo" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -3773,27 +3730,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -3807,17 +3743,6 @@ dependencies = [ "windows-strings", ] -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -3846,16 +3771,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -3954,15 +3869,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index a7e6d35..2dbee23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ flate2 = "1" tar = "0.4" semver = "1" sqlformat = "0.5.0" -sysinfo = { version = "0.38.4", default-features = false, features = ["system"] } self_update = { version = "0.42", default-features = false, features = ["rustls"] } lzma-rs = "0.3" tempfile = "3" diff --git a/README.md b/README.md index 9aa6f2c..2c10197 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,6 @@ API key priority (lowest to highest): config file → `HOTDATA_API_KEY` env var | `embedding-providers` | `list`, `get`, `create`, `update`, `delete` | Manage embedding providers used by vector indexes | | `results` | `list` | Retrieve stored query results | | `jobs` | `list` | Manage background jobs | -| `sandbox` | `list`, `new`, `set`, `read`, `update`, `run` | Manage sandboxes | | `skills` | `install`, `status` | Manage the hotdata agent skill | ## Global options @@ -321,28 +320,6 @@ hotdata jobs [--workspace-id ] [--format table|json|yaml] - `--job-type` accepts: `data_refresh_table`, `data_refresh_connection`, `dataset_refresh`, `create_index`, `create_dataset_index`. - `--status` accepts: `pending`, `running`, `succeeded`, `partially_succeeded`, `failed`. -## Sandboxes - -Sandboxes group related CLI activity (queries, dataset operations, etc.) under a single context. - -```sh -hotdata sandbox list [-w ] [-o table|json|yaml] -hotdata sandbox [-w ] [-o table|json|yaml] -hotdata sandbox new [--name "My Sandbox"] [-o table|json|yaml] -hotdata sandbox set [] -hotdata sandbox read -hotdata sandbox update [] [--name "New Name"] [--markdown "..."] [-o table|json|yaml] -hotdata sandbox run [args...] -hotdata sandbox run [args...] -``` - -- `list` shows all sandboxes with a `*` marker on the active one. -- `new` creates a sandbox and sets it as active. -- `set` switches the active sandbox. Omit the ID to clear the active sandbox. -- `read` prints the markdown content of the current sandbox. -- `update` modifies the name or markdown of a sandbox (defaults to the active sandbox). -- `run` runs a command with the hotdata CLI scoped to a sandbox. Creates a new sandbox unless a sandbox ID is provided before `run`. Useful for launching an agent that can only access sandbox data. Nesting sandboxes is not allowed. - ## Configuration Config is stored at `~/.hotdata/config.yml` keyed by profile (default: `default`). diff --git a/skills/hotdata-analytics/SKILL.md b/skills/hotdata-analytics/SKILL.md index b75408f..00e4668 100644 --- a/skills/hotdata-analytics/SKILL.md +++ b/skills/hotdata-analytics/SKILL.md @@ -117,8 +117,3 @@ hotdata indexes create --connection-id --schema --table \ List and delete use the same `hotdata indexes` commands as in the search skill; only **`--type sorted`** is the analytics focus here. ---- - -## Sandboxes and chains - -Sandbox datasets use **`datasets..
`**, not `datasets.main`. Run queries with active sandbox config or `hotdata sandbox run hotdata query "..."`. See **`hotdata`** skill **Sandboxes**. diff --git a/skills/hotdata-analytics/references/WORKFLOWS.md b/skills/hotdata-analytics/references/WORKFLOWS.md index a62aaf6..4c7a1cd 100644 --- a/skills/hotdata-analytics/references/WORKFLOWS.md +++ b/skills/hotdata-analytics/references/WORKFLOWS.md @@ -84,30 +84,20 @@ Note the printed **`full_name`** (e.g. `datasets.main.chain_revenue_slice` or `c ### 3. Chain query -Query using that name — do not hardcode `datasets.main` if the schema segment is a sandbox id: +Query using the actual `full_name` from create or list — do not hardcode `datasets.main`; use whatever qualified name was printed: ```bash hotdata datasets list hotdata query "SELECT * FROM datasets.main.chain_revenue_slice WHERE ..." -# Sandbox example (use actual full_name from create or list): -# hotdata query "SELECT * FROM datasets.s_ufmblmvq.chain_revenue_slice WHERE ..." # Managed database: # hotdata query "SELECT * FROM chain_db.public.revenue_slice WHERE ..." ``` -### Sandbox context - -For **sandbox-scoped** chain tables: - -- Qualified name is **`datasets..
`**, not `datasets.main`. -- Run queries with **active sandbox** in config (`hotdata sandbox set`) **or** inside **`hotdata sandbox run hotdata query "…"`**. -- Without sandbox context, you may get **access denied** on sandbox-only tables. - ### Naming and documentation - Prefer predictable `--table-name` values: `chain__`. - Record long-lived chains in **context:DATAMODEL → Derived tables (Chain)** with the **full** SQL name you use (`datasets.…` or `database.schema.table`). -- Promote join/grain findings to **context:DATAMODEL** when they should outlive the sandbox (**`hotdata`** skill). +- Promote join/grain findings to **context:DATAMODEL** when they should be shared or persisted (**`hotdata`** skill). ### Guardrails diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index 13b4aee..1981b3a 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -1,6 +1,6 @@ --- name: hotdata -description: Use this skill when the user wants to run core hotdata CLI commands — auth, workspaces, connections, managed databases, datasets, tables, basic SQL query, sandboxes, database context (context:DATAMODEL), jobs, and skill install. Activate for "run hotdata", "list workspaces", "list connections", "create a connection", "list databases", "managed database", "load parquet", "list tables", "list datasets", "create a dataset", "execute a query", "list sandboxes", "database context", "context:DATAMODEL", or general Hotdata CLI usage. For full-text/vector search and retrieval indexes use hotdata-search; for OLAP analytics, query history, stored results, and Chain materializations use hotdata-analytics; for geospatial/GIS use hotdata-geospatial. +description: Use this skill when the user wants to run core hotdata CLI commands — auth, workspaces, connections, managed databases, datasets, tables, basic SQL query, database context (context:DATAMODEL), jobs, and skill install. Activate for "run hotdata", "list workspaces", "list connections", "create a connection", "list databases", "managed database", "load parquet", "list tables", "list datasets", "create a dataset", "execute a query", "database context", "context:DATAMODEL", or general Hotdata CLI usage. For full-text/vector search and retrieval indexes use hotdata-search; for OLAP analytics, query history, stored results, and Chain materializations use hotdata-analytics; for geospatial/GIS use hotdata-geospatial. version: 0.4.0 --- @@ -20,7 +20,7 @@ Install all skills with **`hotdata skills install`**. Load specialized skills on | Skill | Use for | |-------|---------| -| **`hotdata`** (this file) | Auth, workspaces, connections, databases, datasets, tables, basic `query`, context, sandboxes, jobs | +| **`hotdata`** (this file) | Auth, workspaces, connections, databases, datasets, tables, basic `query`, context, jobs | | **`hotdata-search`** | BM25, vector search, `hotdata search`, bm25/vector indexes, embedding providers | | **`hotdata-analytics`** | OLAP SQL, aggregations, query/results history, Chain materializations, sorted indexes | | **`hotdata-geospatial`** | PostGIS-style `ST_*`, WKB, spatial joins | @@ -44,7 +44,7 @@ Commands that accept `--workspace-id` default to the active workspace from confi **`hotdata queries` does not accept `--workspace-id`:** query run history always uses the active workspace—set it with `workspaces set` first if needed. -If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked** to that value: passing a different `--workspace-id` is an error, and **`hotdata workspaces set` fails** (“workspace is locked”). **`workspaces set` is also blocked** while the current process was started under **`hotdata sandbox run`** (nested workspace changes are not allowed in that tree). +If **`HOTDATA_WORKSPACE`** is set in the environment, the workspace is **locked** to that value: passing a different `--workspace-id` is an error, and **`hotdata workspaces set` fails** (“workspace is locked”). **Omit `--workspace-id` unless you need to target a specific workspace** (and it is not locked by env or session). @@ -58,7 +58,7 @@ The CLI command **`hotdata context push`** reads **`./.md`** and **`pull`* > **Agents: do not blindly run `hotdata context show DATAMODEL` on session start.** Run **`hotdata context list`** first (optional `--prefix DATAMODEL`). Call **`hotdata context show DATAMODEL` only if** the list includes the `DATAMODEL` stem. If **`show` exits 1** with *no context named …*, that is **normal** when nothing has been pushed yet—**not a hard failure**; do not retry in a loop, and **avoid speculative `show` in parallel** with other shell tools where one failure cancels sibling calls. Proceed without **context:DATAMODEL** until the user asks to create or load one. -**Agents (Claude and similar):** use database context as the only durable store for **context:DATAMODEL**, **context:GLOSSARY**, and any other **`context:`** documents you introduce. Keep transient analysis notes in **sandbox markdown** or the conversation until you **promote** them into **context:DATAMODEL** when they should guide the whole database ([details below](#analysis-modeling-vs-contextdatamodel)). +**Agents (Claude and similar):** use database context as the only durable store for **context:DATAMODEL**, **context:GLOSSARY**, and any other **`context:`** documents you introduce. Keep transient analysis notes in the conversation or local scratch until you **promote** them into **context:DATAMODEL** when they should guide the whole database ([details below](#analysis-modeling-vs-contextdatamodel)). 1. **Before** planning non-trivial queries, explaining schema to others, or editing **context:DATAMODEL**, **discover** stored names with `hotdata context list` (and other stems such as **context:GLOSSARY** as needed). **Only if** `DATAMODEL` appears in the list, load it: `hotdata context show DATAMODEL`. If it is **absent**, skip `show` and treat **context:DATAMODEL** as unset—use [references/DATA_MODEL.template.md](references/DATA_MODEL.template.md) when the user wants to bootstrap, then `push` when ready. 2. **After** you change **context:DATAMODEL**, persist with **`hotdata context push DATAMODEL`**. The CLI requires a local `./DATAMODEL.md` for that step: write the body there (from `context show`, the template, or your edits), then run `push` from the project directory. @@ -70,11 +70,11 @@ The standard stem for the database semantic map is **`DATAMODEL`** (skill notati Keep two layers separate: -- **Analysis modeling (day to day)** — Understanding data *for the current task*: exploratory SQL, join checks, column semantics for one report, hypotheses, scratch notes. Often conversational or short-lived. **Sandbox markdown** (`sandbox update --markdown`) is the right home while you explore; it dies with the sandbox unless you copy it elsewhere. +- **Analysis modeling (day to day)** — Understanding data *for the current task*: exploratory SQL, join checks, column semantics for one report, hypotheses, scratch notes. Often conversational or short-lived. **The conversation or local scratch notes** are the right home while you explore; keep them there until you decide they are worth promoting. - **context:DATAMODEL (Hotdata database data model)** — A **durable, database-scoped** map stored only via the **context API**: entities and tables across connections, PK/FK relationships, how datasets tie back to sources, naming and query conventions the **whole team** should rely on. This is **higher-level shared structure**, not a transcript of one investigation. -**Promotion:** When analysis findings should **outlive** the sandbox or session and **guide everyone**, merge them into **context:DATAMODEL** (`hotdata context list` → if `DATAMODEL` is listed, `hotdata context show DATAMODEL` → reconcile → `hotdata context push DATAMODEL`). You do **not** need to update **context:DATAMODEL** after every ad-hoc query—only when the database story or join graph meaningfully changes. +**Promotion:** When analysis findings should **outlive the current session** and **guide everyone**, merge them into **context:DATAMODEL** (`hotdata context list` → if `DATAMODEL` is listed, `hotdata context show DATAMODEL` → reconcile → `hotdata context push DATAMODEL`). You do **not** need to update **context:DATAMODEL** after every ad-hoc query—only when the database story or join graph meaningfully changes. Use [references/DATA_MODEL.template.md](references/DATA_MODEL.template.md) and [references/MODEL_BUILD.md](references/MODEL_BUILD.md) for **what to write inside** the Markdown you store under **context:** stems. Never put database-specific model text inside agent skill install paths—only in **database context** (and transient `./.md` for push/pull when needed). @@ -82,15 +82,15 @@ Use [references/DATA_MODEL.template.md](references/DATA_MODEL.template.md) and [ These are **patterns** built from the commands below—not separate CLI subcommands: -- **Model (`context:DATAMODEL`)** — The **shared** Markdown semantic map of the active database (entities, keys, joins across connections). **Store and read it only via database context** (`hotdata context list`, then `hotdata context show DATAMODEL` **only when listed**, `context push DATAMODEL`); refresh using `connections`, `connections refresh`, `tables list`, and `datasets list`. For a **deep** pass (connector enrichment, indexes, per-table detail), see [references/MODEL_BUILD.md](references/MODEL_BUILD.md). Contrast **analysis modeling** in sandboxes or chat (see [Analysis modeling vs context:DATAMODEL](#analysis-modeling-vs-contextdatamodel)). +- **Model (`context:DATAMODEL`)** — The **shared** Markdown semantic map of the active database (entities, keys, joins across connections). **Store and read it only via database context** (`hotdata context list`, then `hotdata context show DATAMODEL` **only when listed**, `context push DATAMODEL`); refresh using `connections`, `connections refresh`, `tables list`, and `datasets list`. For a **deep** pass (connector enrichment, indexes, per-table detail), see [references/MODEL_BUILD.md](references/MODEL_BUILD.md). Contrast **analysis modeling** in the conversation or local scratch (see [Analysis modeling vs context:DATAMODEL](#analysis-modeling-vs-contextdatamodel)). - **History / Chain / OLAP SQL** — See **`hotdata-analytics`** and [references/WORKFLOWS.md](references/WORKFLOWS.md). - **Search / retrieval indexes** — See **`hotdata-search`**. -Catalog, skill decision tree, epic flows (onboard, chain, retrieval), datasets vs databases, and sandbox procedures: [references/WORKFLOWS.md](references/WORKFLOWS.md). +Catalog, skill decision tree, epic flows (onboard, chain, retrieval), and datasets vs databases: [references/WORKFLOWS.md](references/WORKFLOWS.md). ## Available Commands -Top-level subcommands (each detailed below): **`auth`**, **`datasets`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`sandbox`**, **`context`**, **`completions`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**. +Top-level subcommands (each detailed below): **`auth`**, **`datasets`**, **`query`**, **`workspaces`**, **`connections`**, **`databases`**, **`tables`**, **`skills`**, **`results`**, **`jobs`**, **`indexes`**, **`embedding-providers`**, **`search`**, **`queries`**, **`context`**, **`completions`**. Search, indexes (bm25/vector), and embedding providers are documented in **`hotdata-search`**; query history, results, Chain, and OLAP patterns in **`hotdata-analytics`**. Global CLI options: **`--api-key`**, **`-v` / `--version`**, **`-h` / `--help`**. Hidden developer flag: **`--debug`** (verbose HTTP logs). @@ -214,7 +214,7 @@ hotdata databases tables delete
[--database ] [--schema publ - `tables list` — lists tables with `TABLE` (`..
`), `SYNCED`, `LAST_SYNC`. Uses active database when `--database` is omitted. - `tables load` — uploads a local parquet file (`--file`), a remote parquet URL (`--url`), or a pre-staged upload (`--upload-id`) and publishes with **replace** mode. - `tables delete` — drops a table from the managed database. -- `run` — mints a database-scoped JWT (via `POST /v1/auth/database`) and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected. Pass a database id as a group positional (`hotdata databases run ...`, sandbox-style) or via `--database `; omit both to auto-create a scratch database using `--name` / `--schema` / `--table` / `--expires-at`. Use this to launch an agent or child process whose API access is scoped to a single database. The minted JWT carries `database`, `workspaces`, `permissions:["read","write"]`, `source:"database_token"`. The session is persisted at `~/.hotdata/database_session.json` (mode `0600`); the child's exit code is propagated. +- `run` — mints a database-scoped JWT (via `POST /v1/auth/database`) and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected. Pass a database id as a group positional (`hotdata databases run ...`) or via `--database `; omit both to auto-create a scratch database using `--name` / `--schema` / `--table` / `--expires-at`. Use this to launch an agent or child process whose API access is scoped to a single database. The minted JWT carries `database`, `workspaces`, `permissions:["read","write"]`, `source:"database_token"`. The session is persisted at `~/.hotdata/database_session.json` (mode `0600`); the child's exit code is propagated. Example: @@ -247,7 +247,7 @@ hotdata datasets list [--workspace-id ] [--limit ] [--offset - Default format is `table`. - Returns `id`, `label`, and `created_at`; table output includes a **`FULL NAME`** column (`datasets..
`). - Results are paginated (default 100). Use `--offset` to fetch further pages. -- **There is no filter for “this sandbox only.”** `datasets list` always returns **all** datasets in the workspace. To tell sandbox-scoped datasets from workspace-wide ones, read **`FULL NAME`**: the middle segment is the sandbox id (e.g. `datasets.s_ufmblmvq.tac_csat`) for sandbox data, and usually **`main`** (e.g. `datasets.main.my_table`) for ordinary uploads. +- `datasets list` always returns **all** datasets in the workspace. Read **`FULL NAME`** to identify the schema: the middle segment is usually **`main`** (e.g. `datasets.main.my_table`) for ordinary uploads. #### Get dataset details ``` @@ -255,7 +255,7 @@ hotdata datasets [--workspace-id ] [--output table|js ``` - Shows dataset metadata and a full column listing with `name`, `data_type`, `nullable`. - Use this to inspect schema before querying. -- For the **qualified SQL name**, prefer **`FULL NAME` from `datasets list`** or the **`full_name` printed by `datasets create`**—especially for sandbox datasets, where the schema is **`datasets.`**, not `datasets.main`. +- For the **qualified SQL name**, prefer **`FULL NAME` from `datasets list`** or the **`full_name` printed by `datasets create`**—do not assume `datasets.main`. #### Update a dataset ``` @@ -285,7 +285,7 @@ hotdata datasets refresh [--workspace-id ] [--async] #### Querying datasets -Qualified dataset tables are **`datasets..`**: **`main`** for workspace-scoped datasets (created outside a sandbox), or the **sandbox id** for sandbox-created data (e.g. `datasets.s_ufmblmvq.tac_csat`). The create output’s **`full_name`** is authoritative—copy it into `FROM` / `JOIN` clauses instead of guessing `datasets.main.…`. +Qualified dataset tables are **`datasets..`**, normally **`datasets.main.`**. The create output’s **`full_name`** is authoritative—copy it into `FROM` / `JOIN` clauses instead of guessing `datasets.main.…`. Example (workspace dataset on `main`): ``` @@ -366,73 +366,9 @@ hotdata auth status # Check current auth status hotdata auth logout # Remove saved auth for the default profile ``` -### Sandboxes +### Analysis notes and promotion to context:DATAMODEL -Sandboxes are for **ad-hoc, exploratory work** that does not need to be long-lived. They group related CLI activity (queries, dataset operations, etc.) under a single context so it can be tracked and cleaned up together. **Datasets created inside a sandbox are tied to that sandbox and will be removed when the sandbox ends.** If you need data to persist beyond the sandbox, create datasets outside of a sandbox context. - -**Active sandbox in config vs `sandbox run`:** If you already have the right sandbox selected (`hotdata sandbox new` or `hotdata sandbox set ` shows it with `*` in `sandbox list`), run follow-up commands **directly** (`hotdata datasets create …`, `hotdata query …`, etc.). The CLI attaches the sandbox from saved config to API requests. **`hotdata sandbox run ` with no sandbox ID before `run` always creates a brand-new sandbox** and runs the child under that new ID—it does **not** reuse the active sandbox from config. To wrap a command in an **existing** sandbox, use **`hotdata sandbox run [args…]`**. - -> **IMPORTANT: If `HOTDATA_SANDBOX` is set in the environment, you are inside an active sandbox. NEVER attempt to unset, override, or work around this variable. Do not clear it, do not start a new sandbox, do not run `sandbox run` or `sandbox new` or `sandbox set`. All your work should be attributed to the current sandbox. Attempting to nest or escape a sandbox will fail with an error.** - -``` -hotdata sandbox list [--workspace-id ] [--output table|json|yaml] -hotdata sandbox [--workspace-id ] [--output table|json|yaml] -hotdata sandbox new [--name "Sandbox Name"] [--output table|json|yaml] -hotdata sandbox set [] -hotdata sandbox read -hotdata sandbox update [] [--name "New Name"] [--markdown "..."] [--output table|json|yaml] -hotdata sandbox run [args...] -hotdata sandbox run [args...] -``` - -- `list` shows all sandboxes with a `*` marker on the active one. -- `new` creates a sandbox and sets it as active. Blocked inside an existing sandbox. -- `set` switches the active sandbox. Omit the ID to clear. Blocked inside an existing sandbox. -- `read` prints the markdown content of the current sandbox. Use this to retrieve sandbox state at the start of work or between steps. -- `update` modifies a sandbox's name or markdown. Defaults to the active sandbox if no ID is given. The `--markdown` field is for writing details about the work being done in the sandbox — observations, intermediate findings, next steps, etc. This state persists for the life of the sandbox and is the primary way to record context that should survive across commands or agent invocations within the sandbox. -- `run` launches a command with `HOTDATA_SANDBOX` and `HOTDATA_WORKSPACE` set in the child process environment. **`hotdata sandbox run `** (no ID before `run`) **always POSTs a new sandbox**; it never picks up the active sandbox from `sandbox set` / `sandbox new`. Use **`hotdata sandbox run `** to run under an existing sandbox. Blocked inside an existing sandbox. -- When `HOTDATA_SANDBOX` is set **or** a sandbox is the saved default (`sandbox new` / `sandbox set`), the CLI includes sandbox scope on API calls — no extra sandbox flags on `query`, `datasets`, etc. - -**Sandbox-scoped data access:** Queries and other operations against **sandbox-only** resources must run with sandbox context attached—either the **active sandbox** in config (`sandbox set`) or a child process started with **`hotdata sandbox run …`** (which sets `HOTDATA_SANDBOX`). Running `hotdata query` or similar **with no sandbox in config and not under `sandbox … run`** can produce **access denied** for tables or datasets that exist only inside a sandbox. - -#### Example: Building a sales pipeline - -Use a sandbox to explore tables and capture **analysis-oriented** notes in sandbox markdown (keys, joins, open questions)—**day-to-day modeling** for this investigation, not **context:DATAMODEL** until you promote it. - -1. Start a sandbox: - ``` - hotdata sandbox new --name "Sales pipeline" - ``` -2. Inspect tables and columns: - ``` - hotdata tables list --connection-id - ``` -3. Run exploratory queries to understand relationships, cardinality, and key columns: - ``` - hotdata query "SELECT DISTINCT status FROM sales.public.deals LIMIT 20" - hotdata query "SELECT count(*), count(DISTINCT account_id) FROM sales.public.deals" - ``` -4. Write findings into the sandbox markdown as you go: - ``` - hotdata sandbox update --markdown "## sales pipeline model - - ### deals (sales.public.deals) - - PK: id - - FK: account_id -> accounts.id - - status: open | won | lost - - ~50k rows, one row per deal - - ### accounts (sales.public.accounts) - - PK: id - - name, industry, created_at - - ~12k rows, one row per company - - ### TODO - - check how line_items joins to deals - - confirm revenue column semantics" - ``` -5. Continue exploring and update the markdown as your **analysis picture** takes shape. Sandbox markdown is the living artifact for **that sandbox** only. -6. When that picture should become **context:DATAMODEL** (outlive the sandbox or be shared with everyone), promote it: save consolidated Markdown as `./DATAMODEL.md` in the project directory and run `hotdata context push DATAMODEL` (if **context:DATAMODEL** already exists on the server, merge with `hotdata context show DATAMODEL` first—confirm `DATAMODEL` appears in `hotdata context list` before `show`). +Exploratory analysis notes (keys, joins, open questions for the current task) belong in **the conversation or local scratch notes**. When those findings should guide the whole database and be shared with everyone, promote them to **context:DATAMODEL**: save consolidated Markdown as `./DATAMODEL.md` and run `hotdata context push DATAMODEL` (merge with `hotdata context show DATAMODEL` first if `DATAMODEL` is already listed in `hotdata context list`). **Also available:** `hotdata connections new` — interactive connection wizard (no substitute for the programmatic **`connections create`** flow above). diff --git a/skills/hotdata/references/WORKFLOWS.md b/skills/hotdata/references/WORKFLOWS.md index 30ee980..fe4cfd5 100644 --- a/skills/hotdata/references/WORKFLOWS.md +++ b/skills/hotdata/references/WORKFLOWS.md @@ -10,7 +10,7 @@ Load **`hotdata`** first for auth and workspace setup. Add a sub-skill only when | User goal | Skill | Key commands | |-----------|--------|----------------| -| Login, workspaces, connections, tables, context, sandboxes | **`hotdata`** | `auth`, `workspaces`, `connections`, `tables`, `context`, `sandbox` | +| Login, workspaces, connections, tables, context | **`hotdata`** | `auth`, `workspaces`, `connections`, `tables`, `context` | | Upload CSV/JSON/URL or SQL-derived tables | **`hotdata`** | `datasets create`, `databases …` (see below) | | SQL analytics, aggregations, history, Chain | **`hotdata-analytics`** | `query`, `queries`, `results`, `datasets create --sql` | | BM25 / vector search, retrieval indexes | **`hotdata-search`** | `search`, `indexes create`, `embedding-providers` | @@ -20,7 +20,6 @@ Load **`hotdata`** first for auth and workspace setup. Add a sub-skill only when |--------|------------------| | **Model** | This file — [Model](#model) | | **Upload path (datasets vs databases)** | This file — [Datasets vs managed databases](#datasets-vs-managed-databases) | -| **Sandboxes** | This file — [Sandboxes and datasets](#sandboxes-and-datasets) | | **History / Chain** | **`hotdata-analytics`** — [WORKFLOWS.md](../../hotdata-analytics/references/WORKFLOWS.md) | | **Search indexes** | **`hotdata-search`** — [INDEXES.md](../../hotdata-search/references/INDEXES.md) | | **Epic flows** | This file — [Epic flows](#epic-flows) | @@ -56,8 +55,7 @@ End-to-end checklists. Use the linked sections for command detail and guardrails - [ ] **Managed DB:** `hotdata databases create --name … --table …` then `hotdata databases tables load … --file ./….parquet` 3. [ ] Copy **`full_name`** from create output (or `datasets list` **FULL NAME**) 4. [ ] Chain: `hotdata query "SELECT … FROM WHERE …"` -5. [ ] (Sandbox) Use `datasets..
` and active sandbox or `hotdata sandbox run …` -6. [ ] Record stable chains in **context:DATAMODEL** when they should outlive the session +5. [ ] Record stable chains in **context:DATAMODEL** when they should outlive the session **Detail:** [hotdata-analytics WORKFLOWS — Chain](../../hotdata-analytics/references/WORKFLOWS.md#chain) @@ -173,22 +171,6 @@ Use `hotdata tables list` for discovery; do not query `information_schema` for t --- -## Sandboxes and datasets - -Use this when work is isolated in a **sandbox** (exploratory runs, ephemeral datasets). - -**Active sandbox vs `sandbox run`:** After `sandbox new` or `sandbox set`, run **`datasets create`**, **`query`**, etc. **directly**. **`sandbox run `** (no id before `run`) **always creates a new sandbox**. - -**Qualified names:** Workspace datasets → **`datasets.main.
`**. Sandbox datasets → **`datasets..
`**. Use **`full_name`** from create or **FULL NAME** from `datasets list`. - -**Access:** Sandbox-only tables need active sandbox config or **`hotdata sandbox run …`**. - -**SQL:** Quote mixed-case columns with double quotes. - -**Listing:** `datasets list` returns all workspace datasets; use **FULL NAME** to spot sandbox vs `main` rows. - ---- - ## Cross-cutting - **Workspace:** Active workspace or `--workspace-id`. **`hotdata queries`** uses the active workspace only (no `--workspace-id`). diff --git a/src/auth.rs b/src/auth.rs index f2540fb..bd535ca 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,37 +26,24 @@ pub enum AuthStatus { } pub fn check_status(profile_config: &config::ProfileConfig) -> AuthStatus { - // Same precedence as `ApiClient::new`: - // 1. `sandbox run` child via env var - // 2. on-disk sandbox session (sandbox set ) - // 3. user-scoped CLI session / api_key fallback - let api_url = profile_config.api_url.to_string(); - let access_token = - if let Some((sandbox_jwt, _)) = crate::sandbox_session::sandbox_token_in_use() { - sandbox_jwt - } else if crate::sandbox_session::load().is_some() { - match crate::sandbox_session::ensure_access_token(&api_url) { - Some(t) => t, - None => return AuthStatus::Invalid(401), - } - } else { - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // PKCE-origin sessions don't write an api_key, so absence of a key - // alone isn't "not configured" — only true if there's also no - // cached JWT session to validate. - if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { - return AuthStatus::NotConfigured; - } + // Same precedence as the SDK seam: user-scoped CLI session / api_key + // fallback. + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); + + // PKCE-origin sessions don't write an api_key, so absence of a key + // alone isn't "not configured" — only true if there's also no + // cached JWT session to validate. + if api_key_fallback.is_none() && crate::jwt::load_session().is_none() { + return AuthStatus::NotConfigured; + } - match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { - Ok(t) => t, - Err(_) => return AuthStatus::Invalid(401), - } - }; + let access_token = match crate::jwt::ensure_access_token(profile_config, api_key_fallback) { + Ok(t) => t, + Err(_) => return AuthStatus::Invalid(401), + }; let url = format!("{}/workspaces", profile_config.api_url); let client = reqwest::blocking::Client::new(); @@ -79,52 +66,26 @@ pub fn status(profile: &str) { } }; - // The credential the CLI is *about to use*. Precedence matches - // `ApiClient::new`: env-var sandbox token (sandbox run child) > - // on-disk sandbox session (sandbox set ) > user CLI session. - let env_sandbox = crate::sandbox_session::sandbox_token_in_use(); - let disk_sandbox = if env_sandbox.is_none() { - crate::sandbox_session::load() - } else { - None + // The credential the CLI is *about to use*. Precedence matches the SDK + // seam: user CLI session / api_key fallback. + let method_label = match profile_config.api_key_source { + ApiKeySource::Flag => "API Key flag", + ApiKeySource::Env => "API Key env", + ApiKeySource::Config => "CLI Session", }; - let (method_label, credential_tail) = if let Some((token, sandbox_id)) = &env_sandbox { - let label = match sandbox_id { - Some(id) => format!("Sandbox {id}"), - None => "Sandbox Session".to_string(), - }; - (label, Some(crate::util::mask_credential(token))) - } else if let Some(s) = &disk_sandbox { - // Use the refresh token for the displayed tail — it's stable - // across refreshes (the access token rotates every 3 days), so - // the tail stays recognizable between runs. - let label = if s.sandbox_id.is_empty() { - "Sandbox Session".to_string() - } else { - format!("Sandbox {}", s.sandbox_id) - }; - (label, Some(crate::util::mask_credential(&s.refresh_token))) - } else { - let label = match profile_config.api_key_source { - ApiKeySource::Flag => "API Key flag", - ApiKeySource::Env => "API Key env", - ApiKeySource::Config => "CLI Session", - }; - // For Flag/Env we mask the api_key the user supplied. For - // the CLI session path we mask the refresh_token — it's - // stable across commands (unlike the 5-min access_token), - // so the tail stays recognizable between runs. - let tail = match profile_config.api_key_source { - ApiKeySource::Flag | ApiKeySource::Env => profile_config - .api_key - .as_deref() - .map(crate::util::mask_credential), - ApiKeySource::Config => { - crate::jwt::load_session().map(|s| crate::util::mask_credential(&s.refresh_token)) - } - }; - (label.to_string(), tail) + // For Flag/Env we mask the api_key the user supplied. For the CLI session + // path we mask the refresh_token — it's stable across commands (unlike the + // 5-min access_token), so the tail stays recognizable between runs. + let credential_tail = match profile_config.api_key_source { + ApiKeySource::Flag | ApiKeySource::Env => profile_config + .api_key + .as_deref() + .map(crate::util::mask_credential), + ApiKeySource::Config => { + crate::jwt::load_session().map(|s| crate::util::mask_credential(&s.refresh_token)) + } }; + let method_label = method_label.to_string(); let method_suffix = match credential_tail { Some(tail) => format!(" - {method_label} [{tail}]"), None => format!(" - {method_label}"), diff --git a/src/command.rs b/src/command.rs index 7fb45e7..b006607 100644 --- a/src/command.rs +++ b/src/command.rs @@ -211,23 +211,6 @@ pub enum Commands { command: Option, }, - /// Manage sandboxes - Sandbox { - /// Sandbox ID to show details - id: Option, - - /// Workspace ID (defaults to first workspace from login) - #[arg(long, short = 'w', global = true)] - workspace_id: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - - #[command(subcommand)] - command: Option, - }, - /// Sync database context with local Markdown (`./.md` in the current directory) Context { /// Workspace ID (defaults to first workspace from login) @@ -844,73 +827,6 @@ pub enum QueriesCommands { }, } -#[derive(Subcommand)] -pub enum SandboxCommands { - /// List all sandboxes in a workspace - List { - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Create a new sandbox and set it as active - New { - /// Sandbox name - #[arg(long)] - name: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Update a sandbox's markdown or name - Update { - /// Sandbox ID (defaults to active sandbox) - id: Option, - - /// New sandbox name - #[arg(long)] - name: Option, - - /// Markdown content - #[arg(long)] - markdown: Option, - - /// Output format - #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] - output: String, - }, - - /// Print the markdown content of the current sandbox - Read, - - /// Set the active sandbox (omit ID to clear) - Set { - /// Sandbox ID to set as active (omit to clear) - id: Option, - }, - - /// Run a command inside a hotdata sandbox. Creates a new sandbox unless an ID was provided. - /// Example: hotdata sandbox run claude - /// Example: hotdata sandbox run claude - Run { - /// Sandbox name (only used when creating a new sandbox) - #[arg(long)] - name: Option, - - /// Command and arguments to execute - #[arg(trailing_var_arg = true, required = true)] - cmd: Vec, - }, - - /// Delete a sandbox permanently - Delete { - /// Sandbox ID to delete - id: String, - }, -} - #[derive(Subcommand)] pub enum ContextCommands { /// List named contexts in the workspace diff --git a/src/config.rs b/src/config.rs index 6a4224a..7ff1662 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,8 +99,6 @@ pub struct ProfileConfig { pub api_key_source: ApiKeySource, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub workspaces: Vec, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "session")] - pub sandbox: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub current_databases: HashMap, } @@ -190,51 +188,6 @@ pub fn save_default_workspace(profile: &str, workspace: WorkspaceEntry) -> Resul write_config(&config_path, &content) } -pub fn save_sandbox(profile: &str, sandbox_id: &str) -> Result<(), String> { - let config_path = config_path()?; - - let mut config_file: ConfigFile = if config_path.exists() { - let content = fs::read_to_string(&config_path) - .map_err(|e| format!("error reading config file: {e}"))?; - serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))? - } else { - ConfigFile { - profiles: HashMap::new(), - } - }; - - config_file - .profiles - .entry(profile.to_string()) - .or_default() - .sandbox = Some(sandbox_id.to_string()); - - let content = serde_yaml::to_string(&config_file) - .map_err(|e| format!("error serializing config: {e}"))?; - write_config(&config_path, &content) -} - -pub fn clear_sandbox(profile: &str) -> Result<(), String> { - let config_path = config_path()?; - - if !config_path.exists() { - return Ok(()); - } - - let content = - fs::read_to_string(&config_path).map_err(|e| format!("error reading config file: {e}"))?; - let mut config_file: ConfigFile = - serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?; - - if let Some(entry) = config_file.profiles.get_mut(profile) { - entry.sandbox = None; - } - - let content = serde_yaml::to_string(&config_file) - .map_err(|e| format!("error serializing config: {e}"))?; - write_config(&config_path, &content) -} - pub fn save_current_database( profile: &str, workspace_id: &str, diff --git a/src/context.rs b/src/context.rs index 7a4c035..bfd1b31 100644 --- a/src/context.rs +++ b/src/context.rs @@ -300,17 +300,7 @@ pub fn push(workspace_id: &str, database_id: &str, name: &str, dry_run: bool) { Ok(resp) => resp, Err(ApiError::Status { status: _, body }) => { let msg = crate::util::api_error(body); - if msg.to_lowercase().contains("not allowed within a session") { - eprintln!("{}", msg.red()); - eprintln!( - "{}", - "hint: context push is blocked inside an active sandbox. \ -Run 'hotdata sandbox set' (no args) to clear the active sandbox first." - .dark_grey() - ); - } else { - eprintln!("{}", msg.red()); - } + eprintln!("{}", msg.red()); std::process::exit(1); } Err(e @ ApiError::Transport(_)) => e.exit(), diff --git a/src/datasets.rs b/src/datasets.rs index 801532f..ab88ed7 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -59,8 +59,8 @@ struct Column { /// Output shape for `update`, preserving the CLI's field order and optional /// `schema_name`. runtimedb's `UpdateDatasetResponse` does not currently send -/// `schema_name`, so we don't synthesize one — sandbox-scoped datasets live -/// under `datasets..
`, not `datasets.main.*`. +/// `schema_name`, so we don't synthesize one — schema-scoped datasets live +/// under `datasets..
`, not `datasets.main.*`. #[derive(Serialize)] struct UpdateView { id: String, @@ -396,7 +396,7 @@ mod tests { assert_eq!(view.label, "url_test"); assert_eq!(view.table_name, "url_test"); // The server doesn't send schema_name and we never synthesize "main", - // so sandbox-scoped datasets aren't mislabeled. + // so schema-scoped datasets aren't mislabeled. assert!(view.schema_name.is_none()); assert_eq!(view.latest_version, Some(3)); assert!(view.pinned_version.is_none()); diff --git a/src/jwt.rs b/src/jwt.rs index c9e3b60..e5b2514 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -368,9 +368,8 @@ pub fn ensure_access_token( /// Which credential source the [`CliTokenProvider`] serves bearers from. /// -/// Mirrors the 4-level auth-source precedence the old `ApiClient::new` -/// applied (database env -> sandbox env -> on-disk sandbox session -> -/// user session/api_key). The wrapper (`src/sdk.rs`) picks the variant at +/// Mirrors the auth-source precedence the wrapper (`src/sdk.rs`) applies +/// (database env -> user session/api_key). The wrapper picks the variant at /// construction time; the provider re-runs the corresponding *existing* /// blocking CLI function on every request so session.json, the 30s leeway /// table, no-clobber for Flag/Env, and clear-on-dead-refresh stay owned by @@ -379,10 +378,6 @@ pub fn ensure_access_token( pub enum AuthMode { /// `HOTDATA_DATABASE_TOKEN` env var (a `databases run` child). DatabaseEnv { api_url: String }, - /// `HOTDATA_SANDBOX_TOKEN` env var (a `sandbox run` child). - SandboxEnv { api_url: String }, - /// `~/.hotdata/sandbox_session.json` is present (`sandbox set `). - SandboxSession { api_url: String }, /// Normal user-scoped CLI session in `~/.hotdata/session.json`, with an /// optional `hd_...` api-key fallback to mint from. Session { @@ -418,12 +413,6 @@ impl CliTokenProvider { match mode { AuthMode::DatabaseEnv { api_url } => crate::database_session::refresh_from_env(api_url) .ok_or_else(|| "HOTDATA_DATABASE_TOKEN is empty".to_string()), - AuthMode::SandboxEnv { api_url } => crate::sandbox_session::refresh_from_env(api_url) - .ok_or_else(|| "HOTDATA_SANDBOX_TOKEN is empty".to_string()), - AuthMode::SandboxSession { api_url } => { - crate::sandbox_session::ensure_access_token(api_url) - .ok_or_else(|| "sandbox session expired".to_string()) - } AuthMode::Session { profile, api_key_fallback, diff --git a/src/main.rs b/src/main.rs index ec0e5b9..5b4b5be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,8 +15,6 @@ mod queries; mod query; mod raw_http; mod results; -mod sandbox; -mod sandbox_session; mod sdk; mod skill; mod table; @@ -30,8 +28,8 @@ use clap::{Parser, builder::Styles}; use command::{ AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, ContextCommands, DatabaseTablesCommands, DatabasesCommands, DatasetsCommands, EmbeddingProvidersCommands, - IndexesCommands, JobsCommands, QueriesCommands, QueryCommands, ResultsCommands, - SandboxCommands, SkillCommands, TablesCommands, WorkspaceCommands, + IndexesCommands, JobsCommands, QueriesCommands, QueryCommands, ResultsCommands, SkillCommands, + TablesCommands, WorkspaceCommands, }; #[derive(Parser)] @@ -76,10 +74,6 @@ fn resolve_workspace(provided: Option) -> String { let _ = ACTIVE_WORKSPACE_ID.set(ws.clone()); return ws; } - if sandbox::find_sandbox_run_ancestor().is_some() { - eprintln!("error: workspace has been lost -- restart the process"); - std::process::exit(1); - } match config::load("default") { Ok(profile) => match config::resolve_workspace_id(provided, &profile) { Ok(id) => { @@ -105,40 +99,10 @@ unsafe extern "C" { fn atexit(callback: extern "C" fn()) -> i32; } -/// Runs once at process exit. Prints a sandbox footer on stderr when -/// the CLI is running under an on-disk sandbox session (i.e. the user -/// ran `hotdata sandbox set ` to enter it from this shell). Stays -/// silent when the sandbox comes from `HOTDATA_SANDBOX_TOKEN` in the -/// environment: that means we're inside a `sandbox run` child, and -/// the parent already announced the sandbox once at spawn time. -/// Stderr keeps stdout clean for callers parsing JSON/YAML output. -extern "C" fn print_sandbox_footer() { - use crossterm::style::Stylize; - - // Inside a `sandbox run` child — parent printed the banner already. - if sandbox_session::sandbox_token_in_use().is_some() { - return; - } - let Some(session) = sandbox_session::load() else { - return; - }; - if session.sandbox_id.is_empty() { - return; - } - eprintln!( - "{}", - format!( - "current sandbox: {} use 'hotdata sandbox set' to change", - session.sandbox_id - ) - .dark_grey(), - ); -} - extern "C" fn print_database_footer() { use crossterm::style::Stylize; // Inside a `databases run` child the parent already announced the - // database at spawn; mirror sandbox's footer suppression. + // database at spawn, so stay silent here. if database_session::database_token_in_use().is_some() { return; } @@ -156,7 +120,6 @@ fn main() { // Register before `Cli::parse`, since `--help` / `--version` exit // from inside the parser. Safety: `atexit` is async-signal-safe; // the callback only reads env vars / files and writes to stderr. - unsafe { atexit(print_sandbox_footer) }; unsafe { atexit(print_database_footer) }; dotenvy::dotenv().ok(); @@ -943,80 +906,6 @@ fn main() { } } } - Commands::Sandbox { - id, - workspace_id, - output, - command, - } => { - let workspace_id = resolve_workspace(workspace_id); - match command { - Some(SandboxCommands::Run { name, cmd }) => { - sandbox::run(id.as_deref(), &workspace_id, name.as_deref(), &cmd) - } - Some(SandboxCommands::List { output }) => sandbox::list(&workspace_id, &output), - Some(SandboxCommands::New { name, output }) => { - sandbox::new(&workspace_id, name.as_deref(), &output) - } - Some(SandboxCommands::Update { - id: update_id, - name, - markdown, - output, - }) => { - let sandbox_id = update_id - .or(id) - .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); - match sandbox_id { - Some(sid) => sandbox::update( - &workspace_id, - &sid, - name.as_deref(), - markdown.as_deref(), - &output, - ), - None => { - eprintln!( - "error: no sandbox ID provided and no active sandbox set. Use 'sandbox new' or 'sandbox set '." - ); - std::process::exit(1); - } - } - } - Some(SandboxCommands::Read) => { - let sandbox_id = id - .or_else(|| std::env::var("HOTDATA_SANDBOX").ok()) - .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); - match sandbox_id { - Some(sid) => sandbox::read(&sid, &workspace_id), - None => { - eprintln!( - "error: no active sandbox. Use 'sandbox new' or 'sandbox set '." - ); - std::process::exit(1); - } - } - } - Some(SandboxCommands::Set { id: set_id }) => { - sandbox::set(set_id.as_deref(), &workspace_id) - } - Some(SandboxCommands::Delete { id: delete_id }) => { - sandbox::delete(&delete_id, &workspace_id) - } - None => match id { - Some(id) => sandbox::get(&id, &workspace_id, &output), - None => { - use clap::CommandFactory; - let mut cmd = Cli::command(); - cmd.build(); - cmd.find_subcommand_mut("sandbox") - .unwrap() - .print_help() - .unwrap(); - } - }, - } - } Commands::Context { workspace_id, database_id, diff --git a/src/raw_http.rs b/src/raw_http.rs index d899672..cd197f1 100644 --- a/src/raw_http.rs +++ b/src/raw_http.rs @@ -5,9 +5,8 @@ //! //! * the PKCE / OAuth token endpoints (`/o/token/`, `/v1/auth/token`) — owned //! by `jwt.rs`, no SDK equivalent for the `authorization_code` grant; -//! * the session-token mints (`/v1/auth/database`, `/v1/auth/sandbox`) — a -//! distinct grant on distinct endpoints (`database_session.rs` / -//! `sandbox_session.rs`); +//! * the database session-token mint (`/v1/auth/database`) — a distinct grant +//! on a distinct endpoint (`database_session.rs`); //! * `skill.rs`'s arbitrary-URL markdown fetch. //! //! This module owns the timeout-bounded blocking client builder and a thin diff --git a/src/sandbox.rs b/src/sandbox.rs deleted file mode 100644 index dad2a71..0000000 --- a/src/sandbox.rs +++ /dev/null @@ -1,385 +0,0 @@ -use crate::config; -use crate::sandbox_session::{self, SandboxSession}; -use crate::sdk::{Api, ApiError, block}; -use crossterm::style::Stylize; -use hotdata::models::UpdateSandboxRequest; -use serde::Deserialize; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Response shape of `/v1/auth/sandbox` and `/v1/auth/sandbox/`. -#[derive(Deserialize)] -struct SandboxTokenResponse { - token: String, - refresh_token: String, - sandbox_id: String, - expires_in: u64, - refresh_expires_in: u64, -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// Mint (or re-mint) a sandbox-scoped JWT via `POST /v1/auth/sandbox`. -/// -/// This token-mint endpoint has no SDK operation, so it stays on the raw seam. -/// [`Api::post_raw`] still carries the user bearer + `X-Workspace-Id` like every -/// SDK call. Reproduces the old `ApiClient::post` behavior exactly: a transport -/// error or a non-success status prints the standard error and exits, and a -/// malformed body exits the same way `parse_json` did. -fn mint_sandbox_token(api: &Api, body: &serde_json::Value) -> SandboxTokenResponse { - let (status, resp_body) = api - .post_raw("/auth/sandbox", body) - .unwrap_or_else(|e| e.exit()); - if !status.is_success() { - ApiError::Status { - status, - body: resp_body, - } - .exit(); - } - serde_json::from_str(&resp_body).unwrap_or_else(|e| { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - }) -} - -fn persist_sandbox_session(resp: SandboxTokenResponse, workspace_id: &str) { - let now = now_unix(); - let session = SandboxSession { - access_token: resp.token, - refresh_token: resp.refresh_token, - sandbox_id: resp.sandbox_id, - workspace_id: workspace_id.to_string(), - access_expires_at: now + resp.expires_in, - refresh_expires_at: now + resp.refresh_expires_in, - }; - if let Err(e) = sandbox_session::save(&session) { - eprintln!("warning: could not persist sandbox session: {e}"); - } -} - -pub fn list(workspace_id: &str, format: &str) { - let api = Api::new(Some(workspace_id)); - let body = block(api.client().sandboxes().list()).unwrap_or_else(|e| e.exit()); - - let current_sandbox = std::env::var("HOTDATA_SANDBOX") - .ok() - .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); - - match format { - "json" => println!("{}", serde_json::to_string_pretty(&body.sandboxes).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&body.sandboxes).unwrap()), - "table" => { - if body.sandboxes.is_empty() { - eprintln!("{}", "No sandboxes found.".dark_grey()); - } else { - let rows: Vec> = body - .sandboxes - .iter() - .map(|s| { - let marker = if current_sandbox.as_deref() == Some(&s.public_id) { - "*" - } else { - "" - }; - vec![ - marker.to_string(), - s.public_id.clone(), - s.name.clone(), - crate::util::format_date(&s.updated_at), - ] - }) - .collect(); - crate::table::print(&["ACTIVE", "ID", "NAME", "UPDATED"], &rows); - } - } - _ => unreachable!(), - } -} - -pub fn get(sandbox_id: &str, workspace_id: &str, format: &str) { - let api = Api::new(Some(workspace_id)); - let body = block(api.client().sandboxes().get(sandbox_id)).unwrap_or_else(|e| e.exit()); - let s = &*body.sandbox; - - match format { - "json" => println!("{}", serde_json::to_string_pretty(s).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(s).unwrap()), - "table" => { - let label = |l: &str| format!("{:<12}", l).dark_grey().to_string(); - println!("{}{}", label("id:"), s.public_id); - println!("{}{}", label("name:"), s.name); - println!( - "{}{}", - label("created:"), - crate::util::format_date(&s.created_at) - ); - println!( - "{}{}", - label("updated:"), - crate::util::format_date(&s.updated_at) - ); - if !s.markdown.is_empty() { - println!(); - println!("{}", "Markdown:".dark_grey()); - println!("{}", s.markdown); - } - } - _ => unreachable!(), - } -} - -pub fn read(sandbox_id: &str, workspace_id: &str) { - let api = Api::new(Some(workspace_id)); - let body = block(api.client().sandboxes().get(sandbox_id)).unwrap_or_else(|e| e.exit()); - if body.sandbox.markdown.is_empty() { - eprintln!("{}", "Sandbox markdown is empty.".dark_grey()); - } else { - println!("{}", body.sandbox.markdown); - } -} - -fn check_sandbox_lock() { - if std::env::var("HOTDATA_SANDBOX").is_ok() || find_sandbox_run_ancestor().is_some() { - eprintln!("error: sandbox is locked"); - std::process::exit(1); - } -} - -pub fn find_sandbox_run_ancestor() -> Option { - static CACHED: std::sync::OnceLock> = std::sync::OnceLock::new(); - *CACHED.get_or_init(find_sandbox_run_ancestor_inner) -} - -fn find_sandbox_run_ancestor_inner() -> Option { - use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind}; - - let sys = System::new_with_specifics( - RefreshKind::nothing() - .with_processes(ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always)), - ); - - let current_pid = sysinfo::get_current_pid().ok()?; - let mut pid = sys.process(current_pid)?.parent()?; - - for _ in 0..64 { - let proc = sys.process(pid)?; - let name = proc.name().to_string_lossy(); - if name == "hotdata" - && proc.cmd().iter().any(|a| a == "sandbox") - && proc.cmd().iter().any(|a| a == "run") - { - return Some(pid); - } - pid = proc.parent()?; - } - None -} - -pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { - check_sandbox_lock(); - let api = Api::new(Some(workspace_id)); - - let mut body = serde_json::json!({}); - if let Some(n) = name { - body["name"] = serde_json::json!(n); - } - - // POST /auth/sandbox creates the sandbox AND mints a sandbox-scoped - // JWT (+ refresh token) in one round-trip. This token-mint endpoint has - // no SDK operation, so it stays on the raw seam (which still carries the - // user bearer + X-Workspace-Id like every SDK call). - let resp = mint_sandbox_token(&api, &body); - let sandbox_id = resp.sandbox_id.clone(); - persist_sandbox_session(resp, workspace_id); - - if let Err(e) = config::save_sandbox("default", &sandbox_id) { - eprintln!("warning: could not save sandbox to config: {e}"); - } - - eprintln!("{}", "Sandbox created".green()); - match format { - "json" => println!("{}", serde_json::json!({"public_id": sandbox_id})), - "yaml" => print!( - "{}", - serde_yaml::to_string(&serde_json::json!({"public_id": sandbox_id})).unwrap() - ), - "table" => { - println!("id: {}", sandbox_id); - if let Some(n) = name { - println!("name: {}", n); - } - } - _ => unreachable!(), - } -} - -pub fn update( - workspace_id: &str, - sandbox_id: &str, - name: Option<&str>, - markdown: Option<&str>, - format: &str, -) { - if name.is_none() && markdown.is_none() { - eprintln!("error: provide at least one of --name or --markdown."); - std::process::exit(1); - } - - let api = Api::new(Some(workspace_id)); - - let request = UpdateSandboxRequest { - name: name.map(String::from), - markdown: markdown.map(String::from), - }; - - let resp = - block(api.client().sandboxes().update(sandbox_id, request)).unwrap_or_else(|e| e.exit()); - let s = &*resp.sandbox; - - eprintln!("{}", "Sandbox updated".green()); - match format { - "json" => println!("{}", serde_json::to_string_pretty(s).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(s).unwrap()), - "table" => { - let label = |l: &str| format!("{:<12}", l).dark_grey().to_string(); - println!("{}{}", label("id:"), s.public_id); - println!("{}{}", label("name:"), s.name); - println!( - "{}{}", - label("updated:"), - crate::util::format_date(&s.updated_at) - ); - } - _ => unreachable!(), - } -} - -pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd: &[String]) { - check_sandbox_lock(); - let api = Api::new(Some(workspace_id)); - - // Mint (or re-mint, for an existing sandbox) a sandbox-scoped JWT - // by dispatching on grant_type at /auth/sandbox. Either way we - // end up with a fresh bundle persisted to sandbox_session.json - // before we spawn. - let body = match sandbox_id { - Some(id) => serde_json::json!({ - "grant_type": "existing_sandbox", - "sandbox_id": id, - }), - None => { - let mut b = serde_json::json!({}); - if let Some(n) = name { - b["name"] = serde_json::json!(n); - } - b - } - }; - let resp = mint_sandbox_token(&api, &body); - - let sid = resp.sandbox_id.clone(); - let sandbox_jwt = resp.token.clone(); - let sandbox_refresh = resp.refresh_token.clone(); - persist_sandbox_session(resp, workspace_id); - - eprintln!("{} {}", "sandbox:".dark_grey(), sid); - eprintln!("{} {}", "workspace:".dark_grey(), workspace_id); - - let status = std::process::Command::new(&cmd[0]) - .args(&cmd[1..]) - .env("HOTDATA_SANDBOX", &sid) - .env("HOTDATA_WORKSPACE", workspace_id) - .env("HOTDATA_API_URL", &api.api_url) - .env("HOTDATA_SANDBOX_TOKEN", &sandbox_jwt) - .env("HOTDATA_SANDBOX_REFRESH_TOKEN", &sandbox_refresh) - .status(); - - match status { - Ok(s) => std::process::exit(s.code().unwrap_or(1)), - Err(e) => { - eprintln!("error: failed to execute '{}': {e}", cmd[0]); - std::process::exit(1); - } - } -} - -pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { - check_sandbox_lock(); - match sandbox_id { - Some(id) => { - // Mint a sandbox-scoped JWT against this existing id via - // the grant_type=existing_sandbox dispatch. The call - // doubles as an existence + access check (404/403 if the - // user can't reach it). - let api = Api::new(Some(workspace_id)); - let body = serde_json::json!({ - "grant_type": "existing_sandbox", - "sandbox_id": id, - }); - let resp = mint_sandbox_token(&api, &body); - persist_sandbox_session(resp, workspace_id); - - if let Err(e) = config::save_sandbox("default", id) { - eprintln!("error saving config: {e}"); - std::process::exit(1); - } - println!("{}", "Active sandbox updated".green()); - println!("id: {}", id); - } - None => { - // Clear the active sandbox + its cached session. - sandbox_session::clear(); - if let Err(e) = config::clear_sandbox("default") { - eprintln!("error saving config: {e}"); - std::process::exit(1); - } - println!("{}", "Active sandbox cleared".green()); - } - } -} - -pub fn delete(sandbox_id: &str, workspace_id: &str) { - check_sandbox_lock(); - let api = Api::new(Some(workspace_id)); - block(api.client().sandboxes().delete(sandbox_id)).unwrap_or_else(|e| e.exit()); - - // If the deleted sandbox was the active one, clear the cached session - // and config pointer so subsequent commands don't keep routing through - // a stale sandbox JWT — mirroring what `sandbox set` (no args) does. - let active = std::env::var("HOTDATA_SANDBOX") - .ok() - .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); - if active.as_deref() == Some(sandbox_id) { - sandbox_session::clear(); - if let Err(e) = config::clear_sandbox("default") { - eprintln!("warning: could not clear sandbox from config: {e}"); - } - } - - eprintln!("{}", "Sandbox deleted".green()); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn find_sandbox_run_ancestor_returns_none_in_test() { - // No `hotdata sandbox run` ancestor exists in the test runner - assert!(find_sandbox_run_ancestor_inner().is_none()); - } - - #[test] - fn find_sandbox_run_ancestor_cached_matches_inner() { - // The cached version should agree with the inner function - assert_eq!( - find_sandbox_run_ancestor(), - find_sandbox_run_ancestor_inner() - ); - } -} diff --git a/src/sandbox_session.rs b/src/sandbox_session.rs deleted file mode 100644 index e0ca3bb..0000000 --- a/src/sandbox_session.rs +++ /dev/null @@ -1,388 +0,0 @@ -//! Persisted sandbox-scoped JWT session. -//! -//! Distinct from the user-scoped session in [`crate::jwt`]: -//! -//! * Minted by `POST /v1/auth/sandbox` (with no body, or -//! `grant_type=existing_sandbox` + `sandbox_id`), not `/o/token/`. -//! * Bound to a single sandbox + workspace; the JWT carries only -//! workspace-read + sandbox-read/write scope. -//! * Refreshed via `POST /v1/auth/sandbox` with -//! `grant_type=refresh_token` — same endpoint as the new-mint path, -//! dispatched by body field (mirrors `POST /o/token/`). The server -//! does **not** rotate the refresh token. The user's own credentials -//! are never involved — possession of the sandbox refresh token is -//! enough. -//! -//! Stored at `~/.hotdata/sandbox_session.json` (mode 0600). - -use crate::config; -use crate::util; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Refresh ahead of expiry to avoid racing it. -const REFRESH_LEEWAY_SECONDS: u64 = 60; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SandboxSession { - pub access_token: String, - pub refresh_token: String, - pub sandbox_id: String, - pub workspace_id: String, - pub access_expires_at: u64, - pub refresh_expires_at: u64, -} - -pub fn session_path() -> Option { - config::config_dir() - .ok() - .map(|d| d.join("sandbox_session.json")) -} - -#[allow(dead_code)] // Reserved for parent-side flows that resurrect a session. -pub fn load() -> Option { - let path = session_path()?; - let raw = fs::read_to_string(&path).ok()?; - serde_json::from_str(&raw).ok() -} - -pub fn save(session: &SandboxSession) -> Result<(), String> { - let path = session_path().ok_or_else(|| "no sandbox session path available".to_string())?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?; - } - let json = - serde_json::to_string_pretty(session).map_err(|e| format!("serialize failed: {e}"))?; - - use std::os::unix::fs::OpenOptionsExt; - let mut f = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o600) - .open(&path) - .map_err(|e| format!("open failed: {e}"))?; - f.write_all(json.as_bytes()) - .map_err(|e| format!("write failed: {e}"))?; - Ok(()) -} - -pub fn clear() { - if let Some(path) = session_path() { - let _ = fs::remove_file(path); - } -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -#[derive(Deserialize)] -pub(crate) struct MintResponse { - token: String, - refresh_token: String, - sandbox_id: String, - expires_in: u64, - refresh_expires_in: u64, -} - -fn redact(s: &str) -> String { - util::mask_credential(s) -} - -/// Trade a refresh token for a fresh sandbox JWT. The server does -/// **not** rotate the refresh token (matches DOT's -/// ``ROTATE_REFRESH_TOKEN=False``), so the same value is returned on -/// every call. Same endpoint as the new-mint path — -/// ``POST /v1/auth/sandbox`` with ``grant_type=refresh_token`` in the -/// body, mirroring ``POST /o/token/``. -pub fn refresh(api_url: &str, refresh_token: &str) -> Result { - let url = format!("{}/auth/sandbox", api_url.trim_end_matches('/')); - let body = serde_json::json!({ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - }); - let body_log = serde_json::json!({ - "grant_type": "refresh_token", - "refresh_token": redact(refresh_token), - }); - - let client = reqwest::blocking::Client::new(); - let req = client.post(&url).json(&body); - let (status, body_text) = - util::send_debug_with_redaction(&client, req, Some(&body_log), &["token", "refresh_token"]) - .map_err(|e| format!("connection error: {e}"))?; - if !status.is_success() { - return Err(format!( - "sandbox refresh failed: HTTP {status}: {body_text}" - )); - } - let resp: MintResponse = - serde_json::from_str(&body_text).map_err(|e| format!("malformed refresh response: {e}"))?; - Ok(session_from_response( - resp, - /*workspace_id*/ String::new(), - )) -} - -/// Build a [`SandboxSession`] from a mint/refresh response. The mint -/// response itself doesn't include the workspace public_id, so the -/// caller passes it in (the workspace the sandbox was created against -/// is what the JWT's `workspaces` claim restricts the bearer to). For -/// refresh, workspace_id is left blank — the caller fills it in from -/// the prior session, since the sandbox-id ↔ workspace mapping is -/// invariant across refreshes. -pub(crate) fn session_from_response(resp: MintResponse, workspace_id: String) -> SandboxSession { - let now = now_unix(); - SandboxSession { - access_token: resp.token, - refresh_token: resp.refresh_token, - sandbox_id: resp.sandbox_id, - workspace_id, - access_expires_at: now + resp.expires_in, - refresh_expires_at: now + resp.refresh_expires_in, - } -} - -/// Decode a JWT's payload (without verifying the signature) and pull -/// out the named string claim. Returns `None` if the token is -/// unparseable or the claim is missing. -fn jwt_string_claim(token: &str, claim: &str) -> Option { - use base64::Engine; - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() < 2 { - return None; - } - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(parts[1].as_bytes()) - .ok()?; - let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; - value.get(claim).and_then(|v| v.as_str()).map(String::from) -} - -/// Decode the `exp` claim out of a JWT without verifying the signature. -/// Returns `None` if the token is unparseable; in that case the caller -/// should treat it as expired (force-refresh or fail). -fn jwt_exp(token: &str) -> Option { - use base64::Engine; - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() < 2 { - return None; - } - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(parts[1].as_bytes()) - .ok()?; - let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; - value.get("exp").and_then(|v| v.as_u64()) -} - -/// If `HOTDATA_SANDBOX_TOKEN` is set in the environment, return -/// `(token, sandbox_public_id)` — the sandbox public_id read from the -/// JWT's `sandbox` claim. Returns `None` if no env var is set, or if -/// the token isn't a parseable JWT (in which case we can still use it -/// as a bearer but can't identify the sandbox). -pub fn sandbox_token_in_use() -> Option<(String, Option)> { - let token = std::env::var("HOTDATA_SANDBOX_TOKEN").ok()?; - if token.is_empty() { - return None; - } - let sandbox_id = jwt_string_claim(&token, "sandbox"); - Some((token, sandbox_id)) -} - -/// In-child equivalent of [`ensure_access_token`] that operates on env -/// vars only — used by [`crate::sdk::Api`] when the parent -/// `sandbox run` already passed in `HOTDATA_SANDBOX_TOKEN` and -/// `HOTDATA_SANDBOX_REFRESH_TOKEN`. The new tokens are *not* persisted -/// to disk: the child may not have write access to the parent's -/// config dir (sandboxed FS), and re-doing the refresh on the next -/// invocation costs one HTTP call. -/// -/// Falls back to the current `HOTDATA_SANDBOX_TOKEN` value if a -/// refresh isn't needed or fails. -pub fn refresh_from_env(api_url: &str) -> Option { - let current = std::env::var("HOTDATA_SANDBOX_TOKEN").ok()?; - let needs_refresh = match jwt_exp(¤t) { - Some(exp) => exp.saturating_sub(REFRESH_LEEWAY_SECONDS) <= now_unix(), - None => true, - }; - if !needs_refresh { - return Some(current); - } - let rt = std::env::var("HOTDATA_SANDBOX_REFRESH_TOKEN").ok()?; - if rt.is_empty() { - return Some(current); - } - match refresh(api_url, &rt) { - Ok(new_session) => Some(new_session.access_token), - Err(_) => Some(current), - } -} - -/// Return the cached sandbox session's access token, refreshing if -/// it's about to expire. Returns `None` if no session is cached, the -/// refresh token is past its TTL, or the refresh call failed. -#[allow(dead_code)] // Reserved for parent-side flows that re-use a cached session. -pub fn ensure_access_token(api_url: &str) -> Option { - let session = load()?; - let now = now_unix(); - - if !session.access_token.is_empty() && now + REFRESH_LEEWAY_SECONDS < session.access_expires_at - { - return Some(session.access_token); - } - - if session.refresh_token.is_empty() || now >= session.refresh_expires_at { - return None; - } - - match refresh(api_url, &session.refresh_token) { - Ok(mut new_session) => { - // Carry workspace_id over (refresh response omits it). - new_session.workspace_id = session.workspace_id.clone(); - let tok = new_session.access_token.clone(); - let _ = save(&new_session); - Some(tok) - } - Err(_) => { - clear(); - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::test_helpers::with_temp_config_dir; - - fn mk_session(access_offset: i64, refresh_offset: i64) -> SandboxSession { - let now = now_unix() as i64; - SandboxSession { - access_token: "cached".into(), - refresh_token: "cached-refresh".into(), - sandbox_id: "s_abc12345".into(), - workspace_id: "work_xyz".into(), - access_expires_at: (now + access_offset).max(0) as u64, - refresh_expires_at: (now + refresh_offset).max(0) as u64, - } - } - - #[test] - fn round_trip() { - let (_tmp, _guard) = with_temp_config_dir(); - let s = mk_session(3600, 86400); - save(&s).unwrap(); - let loaded = load().unwrap(); - assert_eq!(loaded.access_token, "cached"); - assert_eq!(loaded.sandbox_id, "s_abc12345"); - assert_eq!(loaded.workspace_id, "work_xyz"); - } - - #[test] - fn file_is_mode_0600() { - use std::os::unix::fs::PermissionsExt; - let (_tmp, _guard) = with_temp_config_dir(); - save(&mk_session(60, 60)).unwrap(); - let mode = fs::metadata(session_path().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - } - - #[test] - fn ensure_returns_cached_when_fresh() { - let (_tmp, _guard) = with_temp_config_dir(); - save(&mk_session(3600, 86400)).unwrap(); - // Unreachable URL — if the code reached the network we'd see an error here. - let tok = ensure_access_token("http://127.0.0.1:1"); - assert_eq!(tok.as_deref(), Some("cached")); - } - - #[test] - fn ensure_returns_none_when_no_session() { - let (_tmp, _guard) = with_temp_config_dir(); - assert!(ensure_access_token("http://127.0.0.1:1").is_none()); - } - - #[test] - fn ensure_returns_none_when_refresh_dead() { - let (_tmp, _guard) = with_temp_config_dir(); - // Access and refresh both expired. - save(&mk_session(-10, -10)).unwrap(); - assert!(ensure_access_token("http://127.0.0.1:1").is_none()); - } - - #[test] - fn refresh_posts_grant_type_to_sandbox_endpoint() { - let mut server = mockito::Server::new(); - let m = server - .mock("POST", "/auth/sandbox") - .match_body(mockito::Matcher::AllOf(vec![ - mockito::Matcher::JsonString( - r#"{"grant_type":"refresh_token","refresh_token":"stable-refresh"}"# - .to_string(), - ), - ])) - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - // Server does not rotate — same refresh_token comes back. - r#"{"ok":true,"token":"new-jwt","refresh_token":"stable-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#, - ) - .create(); - - let s = refresh(&server.url(), "stable-refresh").unwrap(); - m.assert(); - assert_eq!(s.access_token, "new-jwt"); - assert_eq!(s.refresh_token, "stable-refresh"); - assert_eq!(s.sandbox_id, "s_abc12345"); - } - - #[test] - fn refresh_http_error() { - let mut server = mockito::Server::new(); - let m = server - .mock("POST", "/auth/sandbox") - .with_status(401) - .create(); - let err = refresh(&server.url(), "x").unwrap_err(); - m.assert(); - assert!(err.contains("401")); - } - - #[test] - fn ensure_refreshes_and_persists() { - let (_tmp, _guard) = with_temp_config_dir(); - // Access expired but refresh still good. - let mut existing = mk_session(-10, 86400); - existing.workspace_id = "work_xyz".into(); - save(&existing).unwrap(); - - let mut server = mockito::Server::new(); - let m = server - .mock("POST", "/auth/sandbox") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - r#"{"ok":true,"token":"refreshed","refresh_token":"cached-refresh","sandbox_id":"s_abc12345","expires_in":300,"refresh_expires_in":259200}"#, - ) - .create(); - let tok = ensure_access_token(&server.url()); - m.assert(); - assert_eq!(tok.as_deref(), Some("refreshed")); - let after = load().unwrap(); - assert_eq!(after.access_token, "refreshed"); - // No rotation — same refresh_token as before. - assert_eq!(after.refresh_token, "cached-refresh"); - assert_eq!(after.workspace_id, "work_xyz"); - } -} diff --git a/src/sdk.rs b/src/sdk.rs index 0001946..a2404d0 100644 --- a/src/sdk.rs +++ b/src/sdk.rs @@ -63,9 +63,6 @@ pub struct Api { /// session-token mints, which target `/v1/auth/*` directly). pub api_url: String, workspace_id: Option, - /// Sandbox/session id, sent as `X-Session-Id` to scope requests to a - /// sandbox. `None` when no sandbox is active. - session_id: Option, database_id: Option, } @@ -280,15 +277,13 @@ fn sdk_base_path(api_url: &str) -> String { } /// Apply the seam's common request headers to a raw `RequestBuilder`: User-Agent, -/// the `X-Workspace-Id` api_key, the sandbox `X-Session-Id` and database -/// `X-Database-Id` scope, and the resolved bearer. Generated SDK ops inject the -/// api_key headers themselves; the raw seam helpers ([`Api::get_json`] etc.) -/// bypass the generated client, so they funnel through this one place rather -/// than repeating the block per verb. +/// the `X-Workspace-Id` api_key, the database `X-Database-Id` scope, and the +/// resolved bearer. Generated SDK ops inject the api_key headers themselves; the +/// raw seam helpers ([`Api::get_json`] etc.) bypass the generated client, so +/// they funnel through this one place rather than repeating the block per verb. async fn apply_seam_headers( mut req: reqwest::RequestBuilder, cfg: &Configuration, - session_id: Option<&str>, database_id: Option<&str>, ) -> reqwest::RequestBuilder { if let Some(ref user_agent) = cfg.user_agent { @@ -301,10 +296,6 @@ async fn apply_seam_headers( }; req = req.header(hotdata::client::WORKSPACE_ID_HEADER, value); } - // Sandbox session scope (also forwarded from api_keys on generated ops). - if let Some(sid) = session_id { - req = req.header("X-Session-Id", sid); - } // Database scope — generated ops don't forward it, so the seam must // (e.g. `hotdata query --database`). if let Some(db) = database_id { @@ -331,15 +322,13 @@ impl Api { }; let api_url = profile_config.api_url.to_string(); - // Auth-source precedence (verbatim from the old ApiClient::new): + // Auth-source precedence: // 1. HOTDATA_DATABASE_TOKEN env (databases run child) - // 2. HOTDATA_SANDBOX_TOKEN env (sandbox run child) - // 3. ~/.hotdata/sandbox_session.json present (sandbox set ) - // 4. ~/.hotdata/session.json + optional api_key fallback + // 2. ~/.hotdata/session.json + optional api_key fallback // - // We pre-flight the same way the old client did (so a dead/unusable - // credential exits at startup with the right hint), then hand the - // CliTokenProvider the matching mode to re-resolve on every request. + // We pre-flight (so a dead/unusable credential exits at startup with + // the right hint), then hand the CliTokenProvider the matching mode to + // re-resolve on every request. let mode = if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() { if crate::database_session::refresh_from_env(&api_url).is_none() { eprintln!( @@ -351,31 +340,6 @@ impl Api { AuthMode::DatabaseEnv { api_url: api_url.clone(), } - } else if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { - if crate::sandbox_session::refresh_from_env(&api_url).is_none() { - eprintln!( - "{}", - crossterm::style::Stylize::red("error: HOTDATA_SANDBOX_TOKEN is empty") - ); - std::process::exit(1); - } - AuthMode::SandboxEnv { - api_url: api_url.clone(), - } - } else if crate::sandbox_session::load().is_some() { - if crate::sandbox_session::ensure_access_token(&api_url).is_none() { - use crossterm::style::Stylize; - eprintln!("{}", "error: sandbox session expired".red()); - eprintln!( - "Run {} to clear it, or {} to re-mint.", - "hotdata sandbox set".cyan(), - "hotdata sandbox set ".cyan(), - ); - std::process::exit(1); - } - AuthMode::SandboxSession { - api_url: api_url.clone(), - } } else { let api_key_fallback = profile_config .api_key @@ -400,19 +364,6 @@ impl Api { } }; - // Resolve the sandbox/session id exactly as the old ApiClient::new did: - // HOTDATA_SANDBOX wins; otherwise, if we are a descendant of a - // `sandbox run` whose sandbox context was lost, exit (a restart is - // required); else fall back to the persisted sandbox in config. This id - // is sent as X-Session-Id to scope requests to the sandbox. - let session_id = std::env::var("HOTDATA_SANDBOX").ok().or_else(|| { - if crate::sandbox::find_sandbox_run_ancestor().is_some() { - eprintln!("error: sandbox has been lost -- restart the process"); - std::process::exit(1); - } - profile_config.sandbox.clone() - }); - let database_id = std::env::var("HOTDATA_DATABASE").ok().or_else(|| { workspace_id.and_then(|ws| crate::config::load_current_database("default", ws)) }); @@ -420,7 +371,6 @@ impl Api { Self::from_configuration( &api_url, workspace_id.map(String::from), - session_id, database_id, CliTokenProvider::new(mode), ) @@ -431,7 +381,6 @@ impl Api { fn from_configuration( api_url: &str, workspace_id: Option, - session_id: Option, database_id: Option, provider: CliTokenProvider, ) -> Self { @@ -454,23 +403,10 @@ impl Api { }, ); } - // Scope generated SDK ops to the sandbox session: the SDK forwards - // X-Session-Id from api_keys. - if let Some(ref sid) = session_id { - configuration.api_keys.insert( - hotdata::client::SESSION_ID_HEADER.to_string(), - ApiKey { - prefix: None, - key: sid.clone(), - }, - ); - } - Api { client: Arc::new(Client::from_configuration(configuration)), api_url: api_url.to_string(), workspace_id, - session_id, database_id, } } @@ -500,20 +436,17 @@ impl Api { client: Arc::new(Client::from_configuration(configuration)), api_url: api_url.to_string(), workspace_id, - session_id: None, database_id: None, } } - /// Test-only constructor that also scopes the client to a sandbox session - /// and/or database, so tests can assert the `X-Session-Id` (api_keys, on - /// generated ops) and `X-Database-Id` headers reach the wire. + /// Test-only constructor that also scopes the client to a database, so tests + /// can assert the `X-Database-Id` header reaches the wire. #[cfg(test)] pub(crate) fn test_new_scoped( api_url: &str, bearer: &str, workspace_id: Option<&str>, - session_id: Option<&str>, database_id: Option<&str>, ) -> Self { let mut configuration = Configuration { @@ -530,20 +463,10 @@ impl Api { }, ); } - if let Some(sid) = session_id { - configuration.api_keys.insert( - hotdata::client::SESSION_ID_HEADER.to_string(), - ApiKey { - prefix: None, - key: sid.to_string(), - }, - ); - } Api { client: Arc::new(Client::from_configuration(configuration)), api_url: api_url.to_string(), workspace_id: workspace_id.map(String::from), - session_id: session_id.map(String::from), database_id: database_id.map(String::from), } } @@ -571,7 +494,7 @@ impl Api { /// wall-clock cap would abort a healthy-but-slow transfer. We clone the /// configured `Configuration` (same base_path, token_provider, scope /// api_keys, user-agent) and swap only the reqwest client, so the upload - /// carries the identical auth + `X-Workspace-Id`/`X-Session-Id` headers. + /// carries the identical auth + headers. /// /// `reader` is the progress-wrapped blocking source (file or URL response); /// it is bridged into the async byte stream the SDK consumes by @@ -618,13 +541,12 @@ impl Api { let cfg = self.client.configuration(); let url = format!("{}/v1{path}", cfg.base_path); let database_id = self.database_id.clone(); - let session_id = self.session_id.clone(); rt().block_on(async move { let mut req = cfg.client.request(reqwest::Method::GET, &url); if !query.is_empty() { req = req.query(query); } - req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + req = apply_seam_headers(req, cfg, database_id.as_deref()).await; let resp = req .send() @@ -659,10 +581,9 @@ impl Api { let cfg = self.client.configuration(); let url = format!("{}/v1{path}", cfg.base_path); let database_id = self.database_id.clone(); - let session_id = self.session_id.clone(); rt().block_on(async move { let mut req = cfg.client.request(reqwest::Method::POST, &url).json(body); - req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + req = apply_seam_headers(req, cfg, database_id.as_deref()).await; let resp = req .send() @@ -689,10 +610,9 @@ impl Api { let cfg = self.client.configuration(); let url = format!("{}/v1{path}", cfg.base_path); let database_id = self.database_id.clone(); - let session_id = self.session_id.clone(); rt().block_on(async move { let mut req = cfg.client.request(reqwest::Method::DELETE, &url); - req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + req = apply_seam_headers(req, cfg, database_id.as_deref()).await; let resp = req .send() @@ -711,7 +631,7 @@ impl Api { /// `get_result_arrow`, returning the fully-buffered [`hotdata::ArrowResult`]. /// /// The SDK owns transport (same reqwest client, bearer via the - /// `token_provider`, `X-Workspace-Id`/`X-Session-Id`) and decode. Its + /// `token_provider`, `X-Workspace-Id`) and decode. Its /// `ArrowError` (the Arrow-path error type, which is not an `Error`) is /// mapped to [`ApiError`] via [`from_arrow`](ApiError::from_arrow) so callers /// keep the same `.exit()` handling. @@ -1261,7 +1181,7 @@ mod tests { .with_body(r#"{"ok":true}"#) .create(); - let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), None, Some("db-1")); + let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("db-1")); let (status, _body) = api .post_raw("/query", &serde_json::json!({"sql": "select 1"})) .expect("post_raw should succeed"); @@ -1269,49 +1189,6 @@ mod tests { m.assert(); } - // --- sandbox session scope headers -------------------------------------- - - #[test] - fn sandbox_scope_sends_session_id() { - // When a sandbox is active the seam scopes the request with X-Session-Id. - let mut server = mockito::Server::new(); - let m = server - .mock("POST", "/v1/query") - .match_header("X-Session-Id", "sb-1") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"ok":true}"#) - .create(); - - let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("sb-1"), None); - let (status, _body) = api - .post_raw("/query", &serde_json::json!({"sql": "select 1"})) - .expect("post_raw should succeed"); - assert_eq!(status, reqwest::StatusCode::OK); - m.assert(); - } - - #[test] - fn session_id_header_installed_on_scoped_sdk_calls() { - // Generated SDK ops carry X-Session-Id via the apiKey-header auth block, - // the same mechanism as X-Workspace-Id. Assert it reaches the wire on a - // typed call when a sandbox is active. - let mut server = mockito::Server::new(); - let m = server - .mock("GET", "/v1/datasets") - .match_header("X-Workspace-Id", "ws-1") - .match_header("X-Session-Id", "sb-1") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(r#"{"count":0,"datasets":[],"has_more":false,"limit":50,"offset":0}"#) - .create(); - - let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("sb-1"), None); - let resp = block(api.client.datasets().list(None, None)).expect("list datasets"); - assert_eq!(resp.count, 0); - m.assert(); - } - // --- streaming /files upload -------------------------------------------- /// A deterministic ASCII payload of `len` bytes, so a body can be matched diff --git a/src/util.rs b/src/util.rs index d3846dc..eb95796 100644 --- a/src/util.rs +++ b/src/util.rs @@ -109,7 +109,7 @@ pub fn debug_response_redacted( /// Mask a credential to its first + last 4 characters /// (`XXXX...YYYY`), or `***` if it's too short to reveal anything /// safely. The tail makes it easy to distinguish which token is on -/// the wire (e.g. user JWT vs sandbox-scoped JWT vs opaque API token). +/// the wire (e.g. user JWT vs database-scoped JWT vs opaque API token). pub fn mask_credential(s: &str) -> String { if s.len() >= 12 { format!("{}...{}", &s[..4], &s[s.len() - 4..]) @@ -341,7 +341,7 @@ pub fn api_error(body: String) -> String { if let Ok(v) = serde_json::from_str::(&body) { // Two shapes in the wild: // {"error": {"message": "..."}} — RuntimeDB-style - // {"error": "snake_case_code"} — Django-style (e.g. sandbox endpoints) + // {"error": "snake_case_code"} — Django-style (e.g. workspace endpoints) if let Some(m) = v["error"]["message"].as_str() { return m.to_string(); } @@ -356,7 +356,7 @@ pub fn api_error(body: String) -> String { } /// Turn a snake_case error code into a human-friendly sentence: -/// ``sandbox_not_found`` → ``Sandbox not found``. Cheap heuristic — if +/// ``workspace_not_found`` → ``Workspace not found``. Cheap heuristic — if /// a code reads badly after this, the server should be the one to fix /// it by returning a real message. fn humanize_error_code(code: &str) -> String { @@ -376,7 +376,7 @@ mod tests { #[test] fn mask_credential_long_shows_prefix_and_suffix() { // 12+ chars: show both ends so the user can tell which token - // is on the wire (sandbox JWT vs user JWT vs opaque API token). + // is on the wire (database JWT vs user JWT vs opaque API token). assert_eq!(mask_credential("abcdefghijkl"), "abcd...ijkl"); assert_eq!(mask_credential("eyJhMIDDLEYwxyz"), "eyJh...wxyz"); } @@ -395,10 +395,10 @@ mod tests { #[test] fn api_error_humanizes_snake_case_code() { - // Django-style flat shape — `sandbox_not_found` should render + // Django-style flat shape — `workspace_not_found` should render // as a readable sentence, not a raw JSON blob. - let body = r#"{"error": "sandbox_not_found"}"#.to_string(); - assert_eq!(api_error(body), "Sandbox not found"); + let body = r#"{"error": "workspace_not_found"}"#.to_string(); + assert_eq!(api_error(body), "Workspace not found"); } #[test] diff --git a/src/workspace.rs b/src/workspace.rs index e09060b..b60ba78 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -30,12 +30,6 @@ fn fetch_workspaces() -> Vec { } pub fn set(workspace_id: Option<&str>) { - if std::env::var("HOTDATA_SANDBOX").is_ok() - || crate::sandbox::find_sandbox_run_ancestor().is_some() - { - eprintln!("error: workspace cannot be changed inside a sandbox"); - std::process::exit(1); - } let workspaces = fetch_workspaces(); let chosen = match workspace_id { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 097a803..2e3d0d7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -102,7 +102,7 @@ impl Cli { } /// Base command: the binary, an isolated config dir, the test API URL, and a - /// cleared environment so an ambient sandbox/database token can't leak in. + /// cleared environment so an ambient database token can't leak in. /// Does NOT set credentials or a workspace. fn base(&self) -> Command { let mut cmd = Command::new(env!("CARGO_BIN_EXE_hotdata")); @@ -110,8 +110,6 @@ impl Cli { .env("HOTDATA_API_URL", &self.env.api_url) .env_remove("HOTDATA_API_KEY") .env_remove("HOTDATA_WORKSPACE") - .env_remove("HOTDATA_SANDBOX") - .env_remove("HOTDATA_SANDBOX_TOKEN") .env_remove("HOTDATA_DATABASE") .env_remove("HOTDATA_DATABASE_TOKEN") .arg("--no-input"); @@ -167,8 +165,6 @@ pub fn unauthenticated_output(api_url: &str, args: &[&str]) -> Output { .env("HOTDATA_API_URL", api_url) .env_remove("HOTDATA_API_KEY") .env_remove("HOTDATA_WORKSPACE") - .env_remove("HOTDATA_SANDBOX") - .env_remove("HOTDATA_SANDBOX_TOKEN") .env_remove("HOTDATA_DATABASE") .env_remove("HOTDATA_DATABASE_TOKEN") .arg("--no-input") diff --git a/tests/sandbox_env.rs b/tests/sandbox_env.rs deleted file mode 100644 index c1d5b07..0000000 --- a/tests/sandbox_env.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::process::Command; - -fn hotdata() -> Command { - Command::new(env!("CARGO_BIN_EXE_hotdata")) -} - -// --- sandbox lock tests --- - -#[test] -fn sandbox_run_blocked_when_hotdata_sandbox_set() { - let output = hotdata() - .args(["sandbox", "run", "echo", "hi"]) - .env("HOTDATA_SANDBOX", "existing-sandbox") - .env("HOTDATA_WORKSPACE", "ws-1") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("sandbox is locked"), "stderr: {stderr}"); -} - -#[test] -fn sandbox_new_blocked_when_hotdata_sandbox_set() { - let output = hotdata() - .args(["sandbox", "new"]) - .env("HOTDATA_SANDBOX", "existing-sandbox") - .env("HOTDATA_WORKSPACE", "ws-1") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("sandbox is locked"), "stderr: {stderr}"); -} - -#[test] -fn sandbox_set_blocked_when_hotdata_sandbox_set() { - let output = hotdata() - .args(["sandbox", "set", "some-id"]) - .env("HOTDATA_SANDBOX", "existing-sandbox") - .env("HOTDATA_WORKSPACE", "ws-1") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("sandbox is locked"), "stderr: {stderr}"); -} - -// --- workspace env lock tests --- - -#[test] -fn workspace_env_blocks_conflicting_flag() { - let output = hotdata() - .args(["sandbox", "-w", "other-ws", "list"]) - .env("HOTDATA_WORKSPACE", "locked-ws") - .env_remove("HOTDATA_SANDBOX") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("locked by HOTDATA_WORKSPACE"), - "stderr: {stderr}" - ); -} - -#[test] -fn workspace_env_allows_matching_flag() { - // When the flag matches the env var, no workspace conflict error. - // Will fail later on auth, but should NOT fail on workspace lock. - let output = hotdata() - .args(["sandbox", "-w", "ws-1", "list"]) - .env("HOTDATA_WORKSPACE", "ws-1") - .env_remove("HOTDATA_SANDBOX") - .output() - .unwrap(); - - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stderr.contains("locked by HOTDATA_WORKSPACE"), - "unexpected workspace lock error: {stderr}" - ); -} diff --git a/tests/workspace_env.rs b/tests/workspace_env.rs new file mode 100644 index 0000000..c46429f --- /dev/null +++ b/tests/workspace_env.rs @@ -0,0 +1,45 @@ +use std::process::Command; + +fn hotdata() -> Command { + Command::new(env!("CARGO_BIN_EXE_hotdata")) +} + +// --- workspace env lock tests --- +// +// `resolve_workspace` refuses to let a `--workspace-id`/`-w` flag override a +// workspace pinned by the HOTDATA_WORKSPACE env var. That check runs before +// any auth or network I/O, so any workspace-scoped subcommand exercises it; +// we use `datasets list` here. + +#[test] +fn workspace_env_blocks_conflicting_flag() { + let output = hotdata() + .args(["datasets", "list", "-w", "other-ws"]) + .env("HOTDATA_WORKSPACE", "locked-ws") + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("locked by HOTDATA_WORKSPACE"), + "stderr: {stderr}" + ); +} + +#[test] +fn workspace_env_allows_matching_flag() { + // When the flag matches the env var, no workspace conflict error. + // Will fail later on auth, but should NOT fail on the workspace lock. + let output = hotdata() + .args(["datasets", "list", "-w", "ws-1"]) + .env("HOTDATA_WORKSPACE", "ws-1") + .output() + .unwrap(); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("locked by HOTDATA_WORKSPACE"), + "unexpected workspace lock error: {stderr}" + ); +}