diff --git a/specs/dclient-v2.md b/specs/dclient-v2.md new file mode 100644 index 0000000..85dad50 --- /dev/null +++ b/specs/dclient-v2.md @@ -0,0 +1,408 @@ +# dclient v2 — Changes from v1 + +## Summary of changes + +1. Aliases point at Datasette instances, not database URLs +2. Instance is always a flag (`-i`/`--instance`), never a positional argument +3. `query` and `insert`/`upsert` take database as a required positional argument +4. Introspection commands (`tables`, `schema`, etc.) use `-d`/`--database` with defaults +5. Default instance and default database reduce typing +6. Bare SQL defaults to `query` command via `click-default-group`, using defaults + `-d` override +7. New commands: `databases`, `tables`, `schema`, `plugins` +8. Config and auth remain separate files, with a new config format +9. New `DATASETTE_DATABASE` environment variable + +--- + +## Config changes + +### config.json (replaces aliases.json) + +```json +{ + "default_instance": "prod", + "instances": { + "prod": { + "url": "https://myapp.datasette.cloud", + "default_database": "main" + }, + "local": { + "url": "http://localhost:8001", + "default_database": null + } + } +} +``` + +### auth.json (same file, new key structure) + +Keys are now alias names instead of URLs: + +```json +{ + "prod": "dstok_abc123", + "local": "dstok_def456" +} +``` + +When no alias exists (using `-i` with a raw URL), tokens are looked up by URL with the existing prefix-matching logic as a fallback. + +### Migration + +On first run, if `config.json` doesn't exist but `aliases.json` does: + +- Parse each v1 alias URL. If it has a single path segment (e.g., `https://example.com/mydb`), split into instance URL + default database. Otherwise store the URL as-is. +- Migrate auth.json keys from URLs to alias names where a match exists, keep URL-keyed entries as fallbacks. +- Write new files, rename originals to `.bak`. + +### Environment variables + +| Variable | Purpose | Precedence | +|----------|---------|------------| +| `DATASETTE_URL` | Default instance URL (existing) | Below config default, above nothing | +| `DATASETTE_DATABASE` | Default database name (new) | Below config default, above auto-detect | +| `DATASETTE_TOKEN` | Auth token (existing) | Below stored token, above nothing | + +--- + +## Two ways to query: explicit and shortcut + +The design provides two distinct interfaces for running queries, optimized for different workflows. + +### Explicit: `dclient query ` + +Database is a required positional argument. No defaults involved. This is unambiguous and works well for zero-config usage and when switching between databases frequently. + +```bash +dclient query fixtures "select * from facetable limit 5" +dclient query analytics "select count(*) from events" -i staging +``` + +### Shortcut: `dclient ` + +When the first argument doesn't match any subcommand, `click-default-group` routes it to a default command that uses the configured default instance and default database. Override either with flags. + +```bash +dclient "select count(*) from users" # default instance + default db +dclient "select count(*) from users" -d analytics # override database +dclient "select count(*) from users" -i staging # override instance +``` + +This is the daily-driver mode for people with a configured default instance and database. + +### Implementation + +```python +from click_default_group import DefaultGroup + +@click.group(cls=DefaultGroup, default="default_query", default_if_no_args=False) +@click.version_option() +def cli(): + "A client CLI utility for Datasette instances" + +@cli.command() +@click.argument("database") +@click.argument("sql") +@click.option("-i", "--instance") +@click.option("--token") +@click.option("-v", "--verbose", is_flag=True) +# ... output format options ... +def query(database, sql, instance, token, verbose, **kwargs): + """Run a SQL query against a Datasette database + + Requires both a database name and a SQL string. + + Example: + + dclient query fixtures "select * from facetable limit 5" + """ + ... + +@cli.command(name="default_query", hidden=True) +@click.argument("sql") +@click.option("-i", "--instance") +@click.option("-d", "--database") +@click.option("--token") +@click.option("-v", "--verbose", is_flag=True) +# ... output format options ... +def default_query(sql, instance, database, token, verbose, **kwargs): + """Run a SQL query using default instance and database.""" + # Resolve instance and database from defaults/env vars + # Error if no default database can be resolved + ... +``` + +The `default_query` command is hidden from help output. Users see `query` in the help text; the bare-SQL shortcut just works without being documented as a separate command. + +--- + +## Instance resolution + +Every command that talks to a Datasette server accepts `-i, --instance TEXT`. Resolution order: + +1. `-i` flag (alias name or URL) +2. `config.default_instance` +3. `DATASETTE_URL` environment variable +4. Error + +If the value starts with `http://` or `https://`, use it as a URL directly. Otherwise look it up as an alias name in config. + +--- + +## Database resolution + +There are two modes of database resolution depending on the command. + +### Commands with required positional database + +`query`, `insert`, `upsert`: the database is always the first positional argument. No resolution logic, no defaults. You must name the database. + +### Commands with optional `-d` flag + +`tables`, `schema`, and the default query shortcut: resolve in order: + +1. `-d` flag +2. Instance's `default_database` from config +3. `DATASETTE_DATABASE` environment variable +4. Auto-detect if instance has exactly one (non-internal) database +5. Error with a helpful message listing available databases + +### Commands that don't need a database + +`databases`, `plugins`, `actor`, `alias`, `auth`: no database argument or flag. + +--- + +## New commands + +### `dclient databases` + +List databases on an instance. + +```bash +$ dclient databases +main +extra + +$ dclient databases --json +[{"name": "main", "tables_count": 12, ...}, ...] + +$ dclient databases -i https://latest.datasette.io +fixtures +``` + +Options: `-i`, `--json`. + +Implementation: `GET /.json` → `databases` key. + +### `dclient tables` + +List tables (and optionally views) in a database. + +```bash +$ dclient tables +facetable 15 rows +facet_cities 4 rows + +$ dclient tables -d analytics +events 1503 rows + +$ dclient tables --views --json +[{"name": "facetable", "columns": [...], "count": 15, ...}, ...] +``` + +Options: `-i`, `-d`, `--views`, `--views-only`, `--hidden`, `--json`. + +Implementation: `GET /.json` → `tables` and `views` keys. + +### `dclient schema` + +Show SQL schema for a database or a specific table. + +```bash +$ dclient schema +# all CREATE TABLE/VIEW statements for default database + +$ dclient schema -d analytics +# all schemas for the analytics database + +$ dclient schema facetable +# just that table's schema (in default database) + +$ dclient schema facetable -d analytics +# that table in a specific database +``` + +The optional table name is a positional argument. Options: `-i`, `-d`, `--json`. + +### `dclient plugins` + +List installed plugins on an instance. + +```bash +$ dclient plugins +datasette-files +datasette-auth-tokens + +$ dclient plugins --json +[{"name": "datasette-files", "version": "0.3.1", ...}, ...] +``` + +Options: `-i`, `--json`. + +Implementation: `GET /-/plugins.json`. + +--- + +## Changed commands + +### `dclient query` + +```bash +# v1 +dclient query https://datasette.io/content "select * from news" + +# v2: database is required positional, instance is a flag +dclient query content "select * from news" -i https://datasette.io +dclient query fixtures "select * from facetable" # uses default instance +``` + +Signature: `dclient query [-i instance] [--csv|--tsv|--nl|--table] [-o file] [--token TOKEN] [-v]` + +### `dclient insert` + +```bash +# v1 +dclient insert https://myapp.datasette.cloud/data mytable data.csv --csv + +# v2: database and table are required positionals, instance is a flag +dclient insert main mytable data.csv --csv -i myapp +dclient insert main mytable data.csv --csv # uses default instance +``` + +Signature: `dclient insert [-i instance] [--csv|--tsv|--json|--nl] [--create] [--replace] [--ignore] [--alter] [--pk col] [--batch-size N] [--interval N] [--token TOKEN] [-v] [--silent]` + +### `dclient upsert` + +New command. Same shape as `insert` but hits `/-/upsert`. + +Signature: `dclient upsert
[-i instance] [--csv|--tsv|--json|--nl] [--alter] [--pk col] [--batch-size N] [--interval N] [--token TOKEN] [-v] [--silent]` + +### `dclient alias` + +```bash +dclient alias add # url is the instance root, stored as-is +dclient alias remove +dclient alias list # shows * for default, (db: x) for default db +dclient alias default [name] # set/show default instance +dclient alias default --clear +dclient alias default-db [db] # set/show default database for an alias +dclient alias default-db --clear +``` + +**Tip: multiple aliases for the same instance.** You can create several aliases pointing at the same instance URL with different default databases. This gives you short names for databases you switch between frequently: + +```bash +dclient alias add prod https://myapp.datasette.cloud +dclient alias add prod-analytics https://myapp.datasette.cloud +dclient alias default-db prod main +dclient alias default-db prod-analytics analytics + +dclient tables -i prod # → main +dclient tables -i prod-analytics # → analytics +``` + +Auth tokens are resolved by alias name first, then by URL match as a fallback, so a token stored for either alias will work for both. + +### `dclient auth` + +```bash +dclient auth add # prompt for token +dclient auth add --token TOKEN +dclient auth remove +dclient auth list # shows which aliases have tokens, never values +dclient auth status [-i instance] # calls /-/actor.json to verify +``` + +### `dclient actor` + +Uses `-i` flag instead of positional URL: + +```bash +dclient actor # default instance +dclient actor -i prod +``` + +--- + +## Worked examples for the three usage modes + +### Mode 1: Zero config, public instance + +```bash +# Explicit database, instance as flag +dclient databases -i https://latest.datasette.io +dclient tables -i https://latest.datasette.io -d fixtures +dclient query fixtures "select * from facetable limit 3" -i https://latest.datasette.io + +# Or set env vars for a session +export DATASETTE_URL=https://latest.datasette.io +export DATASETTE_DATABASE=fixtures +dclient tables +dclient "select * from facetable limit 3" +``` + +### Mode 2: Single default instance + +```bash +# One-time setup +dclient alias add work https://myapp.datasette.cloud +dclient alias default work +dclient alias default-db work main +dclient auth add work + +# Daily use — bare SQL shortcut uses defaults +dclient tables +dclient "select count(*) from users" +dclient "select * from events" -d analytics + +# Explicit query when switching databases frequently +dclient query main "select count(*) from users" +dclient query analytics "select count(*) from events" + +# Insert always names the database +dclient insert main events data.csv --csv --create --pk id +dclient plugins +``` + +### Mode 3: Multiple aliases + +```bash +dclient alias add prod https://prod.datasette.cloud +dclient alias add staging https://staging.datasette.cloud +dclient alias default prod +dclient alias default-db prod main +dclient alias default-db staging main + +# Bare shortcut hits prod/main +dclient "select count(*) from users" + +# Explicit query — name the database, override instance with -i +dclient query main "select count(*) from users" -i staging +dclient query analytics "select * from events" -i staging + +# Insert always explicit +dclient insert main events data.csv --csv -i staging +``` + +--- + +## Not in this version (reserved) + +These command names are reserved for future work: + +- `dclient cloud` — Datasette Cloud integration +- `dclient files` — datasette-files management +- `dclient get`, `dclient rows`, `dclient update`, `dclient delete` — row-level CRUD +- `dclient create-table`, `dclient drop-table` — DDL +- `dclient queries` — canned queries \ No newline at end of file