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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@
.env
.env.local
!.env.staging

# Recorded demos and the scripts for them, written per PR (demo/README.md)
demo/casts/
demo/scripts/
__pycache__/
70 changes: 70 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Recording demos for PR descriptions

`demo/` records terminal demos of the CLI as [asciicasts](https://docs.asciinema.org/manual/asciicast/v2/), so a PR can show what a change looks like to use. Each recording runs against a stub of the source.coop API filled with made-up records, so nothing touches a real account or a real login.

```bash
cargo build
# write demo/scripts/pr-60.py for your change (see "Writing a demo"), then:
python3 -I demo/record.py demo/scripts/pr-60.py # every demo in the script, to demo/casts/
python3 -I demo/record.py demo/scripts/pr-60.py product-create # just one
```

It needs Python 3.10 or later on macOS or Linux. To render GIFs it needs [agg](https://github.com/asciinema/agg), and to upload, [asciinema](https://asciinema.org) (`brew install agg asciinema`). Demos that pipe through `jq` need it installed.

## What's here

- `stub.py` serves `fixtures.json` under `/api/v1` from memory. What a demo creates, edits or deletes, the commands after it see. Every demo starts from a fresh copy. It answers what the CLI asks, in the API's shapes, and checks nothing: it is for demos, not tests, which use `wiremock`.
- `fixtures.json` holds the records: the signed-in `demo-user`, who owns the organization `demo-org`, with its products, data connections, service accounts, trusts, API keys and memberships. Add what a new demo needs here.
- `record.py` runs bash in a pseudo-terminal at 120×34 and types each demo's commands like a person. It records with real timing, capping idle time at 2.5s. The recording ends at the last prompt, and with agg installed each is also rendered as a GIF beside its cast.
- No demo scripts are kept. Each PR writes its own, for what it changes, in `demo/scripts/`, which git ignores. A script that isn't run between PRs only goes stale, and whoever made a change knows what to show of it.

## Writing a demo

A script is a Python file defining `DEMOS = {name: (title, function)}`, where each function drives a `Session`. `record.py` provides `DOWN`, `UP`, `RIGHT`, `LEFT`, `ESC` and `BACKSPACE`.

```python
# demo/scripts/pr-60.py
def create(s):
s.run("source-coop product create") # type a command at the prompt and run it
s.see("Account") # wait for text (a regex) to appear
s.key(DOWN) # press keys: DOWN UP RIGHT LEFT ESC BACKSPACE, or any text
s.enter()
s.see("Product ID")
s.type("sea-ice") # type, a character at a time
s.enter()
s.see("Create this product")
s.pause(3) # give the viewer time to read
s.enter()


DEMOS = {"product-create": ("source-coop product create (prompted)", create)}
```

Write one demo for each command or flow the PR changes, showing what changed: prompts and their order, `-i` screens and their keys, refusals and exit codes. Add whatever the demos need to `stub.py` and `fixtures.json`. Those are kept, so the next PR starts from them.

Wait with `see` before every keypress that answers a prompt. Run each command after the first with `s.prompt()` in between, so it waits for the one before. If a `see` never matches, recording stops and prints the end of the screen.

Each session gets `SOURCE_TOKEN=demo`, `SOURCE_API_URL` pointing at the stub, and a throwaway `HOME`. So the CLI never reads the real login session or the OS keyring, and drafts land in the throwaway `HOME`. The one thing that does reach the network is the CLI's own lookups elsewhere, such as a GitHub repository's subject claim. Use a public repository for those, like `source-cooperative/source.coop`.

## In a PR description

GitHub fetches images in PR descriptions through its proxy, which can't load asciinema.org's previews. So a PR shows each demo as a GIF served from this repo's `demo-assets` branch, linking to its asciinema.org player. For PR 60:

```bash
python3 -I demo/record.py demo/scripts/pr-60.py --upload \
--gif-base https://github.com/source-cooperative/source-coop-cli/raw/demo-assets/pr-60 > demos.md

git worktree add ../demo-assets demo-assets # the branch holds only images
mkdir -p ../demo-assets/pr-60 && cp demo/casts/*.gif ../demo-assets/pr-60/
git -C ../demo-assets add pr-60 && git -C ../demo-assets commit -m "Demo GIFs for #60" && git -C ../demo-assets push
```

Paste `demos.md` into the description. Each entry looks like this:

```markdown
**source-coop product create (prompted)**

[![product-create](https://github.com/source-cooperative/source-coop-cli/raw/demo-assets/pr-60/product-create.gif)](https://asciinema.org/a/ID)
```

The GIF plays inline, and clicking it opens the player, where you can pause and copy text. Anyone with a link can watch the recording, so record only the stub's made-up data. URLs in the demos name the stub (`http://localhost:PORT`): say so in the PR.
254 changes: 254 additions & 0 deletions demo/fixtures.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
{
"whoami": {
"identity_id": "00000000-0000-0000-0000-000000000001",
"account": {
"account_id": "demo-user",
"type": "individual",
"name": "Demo User",
"flags": [
"create_repositories"
],
"disabled": false
},
"memberships": [
{
"membership_id": "00000000-0000-0000-0000-0000000000a1",
"account_id": "demo-user",
"membership_account_id": "demo-org",
"role": "owners",
"state": "member",
"state_changed": "2026-01-05T10:00:00Z"
}
]
},
"accounts": [
{
"account_id": "demo-org",
"type": "organization",
"name": "Demo Org",
"disabled": false,
"flags": [
"create_service_accounts"
]
},
{
"account_id": "demo-org--nightly-sync",
"type": "service",
"name": "Nightly sync",
"owner_account_id": "demo-org",
"disabled": false,
"flags": [],
"metadata_public": {},
"created_at": "2026-09-02T17:04:11Z",
"updated_at": "2026-09-02T17:04:11Z"
},
{
"account_id": "demo-org--stac-indexer",
"type": "service",
"name": "STAC indexer",
"owner_account_id": "demo-org",
"disabled": false,
"flags": [],
"metadata_public": {},
"created_at": "2026-09-02T17:04:11Z",
"updated_at": "2026-09-02T17:04:11Z"
},
{
"account_id": "demo-org--old-backfill",
"type": "service",
"name": "Old backfill",
"owner_account_id": "demo-org",
"disabled": true,
"flags": [],
"metadata_public": {},
"created_at": "2025-03-11T08:00:00Z",
"updated_at": "2026-02-01T08:00:00Z"
}
],
"trusts": [
{
"account_id": "demo-org--nightly-sync",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:source-cooperative/source.coop:ref:refs/heads/main",
"created_at": "2026-09-02T17:04:11Z",
"created_by": "demo-user"
},
{
"account_id": "demo-org--stac-indexer",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:source-cooperative/source.coop:environment:production",
"created_at": "2026-09-02T17:04:11Z",
"created_by": "demo-user"
}
],
"memberships": [
{
"membership_id": "00000000-0000-0000-0000-0000000000b1",
"account_id": "demo-org--nightly-sync",
"membership_account_id": "demo-org",
"product_id": "climate-grid",
"role": "write_data",
"state": "member",
"state_changed": "2026-09-02T17:04:11Z"
},
{
"membership_id": "00000000-0000-0000-0000-0000000000b2",
"account_id": "demo-org--stac-indexer",
"membership_account_id": "demo-org",
"product_id": "demo-data",
"role": "read_data",
"state": "member",
"state_changed": "2026-09-02T17:04:11Z"
}
],
"data_connections": [
{
"data_connection_id": "demo-s3",
"name": "Demo S3",
"read_only": false,
"allowed_visibilities": [
"public",
"unlisted",
"restricted"
],
"details": {
"provider": "s3"
}
},
{
"data_connection_id": "public-archive",
"name": "Public archive",
"read_only": true,
"allowed_visibilities": [
"public"
],
"details": {
"provider": "s3"
}
}
],
"products": [
{
"account_id": "demo-org",
"product_id": "climate-grid",
"title": "Climate Grid",
"description": "Daily gridded temperature and precipitation, 1950 to today.",
"visibility": "public",
"disabled": false,
"featured": 1,
"created_at": "2025-06-01T12:00:00Z",
"updated_at": "2026-09-30T08:15:00Z",
"metadata": {
"tags": [
"climate",
"zarr"
],
"primary_mirror": "demo-s3",
"mirrors": {
"demo-s3": {
"connection_id": "demo-s3",
"prefix": "demo-org/climate-grid/",
"is_primary": true
}
}
}
},
{
"account_id": "demo-org",
"product_id": "demo-data",
"title": "Demo Data",
"description": "A small sample dataset for trying things out.",
"visibility": "unlisted",
"disabled": false,
"featured": 0,
"created_at": "2026-02-14T09:30:00Z",
"updated_at": "2026-02-14T09:30:00Z",
"metadata": {
"tags": [
"sample"
],
"primary_mirror": "demo-s3",
"mirrors": {
"demo-s3": {
"connection_id": "demo-s3",
"prefix": "demo-org/demo-data/",
"is_primary": true
}
}
}
},
{
"account_id": "demo-org",
"product_id": "landsat-mirror",
"title": "Landsat Mirror",
"description": "Cloud-optimized GeoTIFFs mirrored from the USGS archive.",
"visibility": "restricted",
"disabled": false,
"featured": 0,
"created_at": "2025-11-20T16:45:00Z",
"updated_at": "2026-08-01T11:00:00Z",
"metadata": {
"tags": [
"landsat",
"cog"
],
"primary_mirror": "demo-s3",
"mirrors": {
"demo-s3": {
"connection_id": "demo-s3",
"prefix": "demo-org/landsat-mirror/",
"is_primary": true
}
}
}
},
{
"account_id": "demo-user",
"product_id": "field-notes",
"title": "Field Notes",
"description": "Survey photos and GPS tracks.",
"visibility": "public",
"disabled": false,
"featured": 0,
"created_at": "2026-04-02T07:00:00Z",
"updated_at": "2026-04-02T07:00:00Z",
"metadata": {
"tags": [
"survey"
],
"primary_mirror": "demo-s3",
"mirrors": {
"demo-s3": {
"connection_id": "demo-s3",
"prefix": "demo-user/field-notes/",
"is_primary": true
}
}
}
}
],
"keys": [
{
"key_id": "00000000-0000-0000-0000-0000000000c1",
"account_id": "demo-org--nightly-sync",
"label": "HPC cron job",
"hint": "Qx7Lm2",
"created_at": "2026-08-20T10:00:00Z",
"created_by": "demo-user",
"expires_at": "2027-02-01T00:00:00Z",
"last_used_at": "2026-10-13T02:00:05Z"
},
{
"key_id": "00000000-0000-0000-0000-0000000000c2",
"account_id": "demo-org--nightly-sync",
"label": "Old laptop",
"hint": "bR4tZ9",
"created_at": "2025-12-01T10:00:00Z",
"created_by": "demo-user",
"expires_at": null,
"last_used_at": "2026-03-02T18:30:00Z",
"revoked_at": "2026-03-05T09:12:00Z",
"revoked_via": "github"
}
]
}
Loading
Loading