Skip to content
getgeolens.com

CLI & Manifests

The GeoLens CLI (geolens) is the fastest way to get data into an instance from a terminal or CI pipeline. It wraps the Python SDK to publish single files, scan directories, refresh and inspect datasets, run analysis operations, export STAC metadata, and apply declarative manifests (geolens.yaml) that describe a whole catalog.

Terminal window
pip install geolens-cli # installs the `geolens` command
# or, for an isolated tool install:
pipx install geolens-cli

geolens-cli requires Python 3.11 or newer.

Verify the install:

Terminal window
geolens --version
geolens --help

To run a command without installing anything, uvx fetches the package on demand:

Terminal window
uvx --from geolens-cli geolens --version

Pin the version in automation (uvx --from geolens-cli==<version> geolens ...). The examples repo keeps the pinned form of the manifest commands on this page, next to a working manifest and GitHub Actions workflow.

Log in to an instance and store credentials. The CLI talks to the API base and appends /api itself when you leave it off, so https://geolens.example.com and https://geolens.example.com/api resolve to the same stored credential — the examples here spell the suffix out:

Terminal window
geolens login http://localhost:8080/api
# prompts for your admin username and password (auto-generated at install; see your .env)

By default the bearer token is stored in your operating system keyring. Pass --no-keyring to fall back to a credentials.toml file instead (useful on headless hosts without a keyring service):

Terminal window
geolens login https://geolens.example.com/api --no-keyring

You can also store a token or API key non-interactively (handy for scripts):

Terminal window
geolens login https://geolens.example.com/api --token "$JWT"
geolens login https://geolens.example.com/api --api-key "$GEOLENS_API_KEY"
# pass `-` to read the secret from stdin (keeps it out of argv / shell history):
echo "$JWT" | geolens login https://geolens.example.com/api --token -

--token and --api-key are mutually exclusive; pass only one.

Check who you are, or clear credentials:

Terminal window
geolens whoami # prints the active user and instance
geolens logout # removes stored credentials for the active instance

geolens publish uploads one local vector or raster file and runs the full ingest flow (upload -> preview -> commit), then prints the new dataset’s URL:

Terminal window
geolens publish ./city-parks.geojson --name "City Parks"
geolens publish ./elevation.tif --name "Elevation" --description "10m DEM"

By default the command waits for ingestion to resolve the dataset id. Use --no-wait to return immediately with a job-search URL instead:

Terminal window
geolens publish ./big-raster.tif --no-wait

geolens refresh <dataset_id> re-pulls a dataset from its server-stored source binding — the same manual refresh as the dataset page, for datasets with an upstream to re-pull (Service, registered PostGIS table, STAC):

Terminal window
geolens refresh <dataset_id> # queue the job and return
geolens refresh <dataset_id> --wait # follow it to a terminal state
geolens refresh <dataset_id> --wait --timeout 600 # bound the wait

Unlike publish, refresh does not wait by default. --timeout takes seconds (finite, greater than 0) and requires --wait; without it, --wait follows the job however long it queues.

For a protected service, pass --token with no value to get a hidden prompt:

Terminal window
geolens refresh <dataset_id> --token

An explicit --token <value> can be visible in argv and shell history. The token is used for that refresh only — GeoLens never stores it on the dataset, so each refresh of a protected service needs it again.

geolens replace <dataset-id> <file> uploads a local file over an existing dataset’s data — the CLI equivalent of the web app’s Re-Upload dialog:

Terminal window
geolens replace <dataset_id> ./city-parks-2026.geojson
geolens replace <dataset_id> ./parcels.gpkg --layer parcels_current
geolens replace <dataset_id> ./elevation.tif --wait

It runs the same upload, preview, commit flow as publish, pointed at the dataset’s reupload endpoints: uploads the file, prints the preview (detected layer, feature count, SRID), and prompts for confirmation before committing. --layer picks a layer out of a multi-layer file — required when the file has more than one, since the CLI refuses to commit an unnamed default. --srid overrides the detected SRID. --wait polls the replace job to a terminal state and exits non-zero on failure; without it, the command returns once the job is queued. --yes/-y skips the confirmation prompt, and is required together with --json, which never prompts.

replace refuses a dataset whose data comes from a service, a STAC item, or a registered PostGIS table — replacing those with a file would sever the binding that keeps them refreshable. Use geolens refresh for those instead.

geolens status <dataset_id> prints a dataset’s catalog status alongside its source origin, freshness, health, and last refresh:

Terminal window
geolens status <dataset_id>
geolens --json status <dataset_id> # machine-readable payload

geolens scan walks a directory and reports what would be ingested, a dry run with no upload. Use it to preview a bulk import:

Terminal window
geolens scan ./gis-exports
geolens scan ./gis-exports --include-ext .gpkg,.tif --max-depth 2
geolens scan ./gis-exports --json # machine-readable output

scan classifies by file extension only. It recognizes GeoJSON, GeoPackage, FlatGeobuf, KML, KMZ, shapefiles (grouped with their sidecars, and flagged when .dbf or .shx is missing), and GeoTIFF, and reports everything else as unsupported — except orphaned sidecars (a stray .prj, .dbf, .shx, .cpg, .tfw and the like with no dataset beside them), which it skips without a row. A .json file counts as GeoJSON only if its first kilobyte looks like one. Read the report as an inventory of the obvious candidates, not a capability check — its extension list and the server’s upload allowlist are not the same list, as Bulk import spells out.

There is no bulk-upload command. geolens publish takes exactly one file, and geolens apply never uploads from your machine. The supported way to import a directory of local files is a shell loop over publish:

Terminal window
for f in ./gis-exports/*.geojson ./gis-exports/*.gpkg; do
[ -e "$f" ] || continue
name=$(basename "$f")
geolens publish "$f" --name "${name%.*}" || exit 1
done

Each iteration runs the whole ingest flow for that one file — upload, preview, commit — so 40 files means 40 uploads and 40 ingest jobs queued on the server. Keep the default --wait and the loop stays serialized instead of flooding the queue; --tags and --collection require it anyway, since both are applied after the commit resolves the dataset id.

publish is not idempotent, so re-running the loop publishes a second copy of every file. Track what succeeded, or delete the earlier datasets first.

For multi-dataset catalogs that you want to version and re-apply, describe your sources in a geolens.yaml manifest and apply it declaratively.

Terminal window
geolens init # scaffold ./geolens.yaml (errors if it already exists)
geolens init --force # overwrite an existing manifest
geolens init catalog.yaml # scaffold at a custom path
geolens validate geolens.yaml # local schema check, no API call
geolens apply geolens.yaml # validate + apply via the GeoLens API
geolens apply geolens.yaml --dry-run # preview apply outcomes without writes

Each dataset carries a stable key, so re-applying an edited manifest updates the matching datasets instead of creating duplicates. The server matches each entry to an existing dataset by key and fingerprints the rest, then answers create (new key), update (known key, changed entry), or skip (unchanged entry) per dataset. An unchanged manifest is therefore safe to apply on every push. --dry-run returns the same verdicts without writing, but the server evaluates it, so it needs a credential like a real apply.

Apply reconciles the declaration rather than the bytes behind it: a source URL that serves new content under an unchanged entry still skips. geolens refresh <dataset_id> is the manual refresh from the dataset page, for datasets with an upstream to re-pull (Service, registered PostGIS table, STAC). A source the server downloaded for a manifest is an ordinary upload once ingested, so refresh refuses it. See Where Your Data Lives.

The top level requires manifest_version: "1", a catalog block, and a datasets array of 1 to 100 entries. Split a larger catalog across several manifests.

Print the packaged JSON Schema for geolens.yaml — for editor validation or CI schema checks — without contacting an API:

Terminal window
geolens schema # print to stdout
geolens schema -o geolens.schema.json # write to a file

catalog: required title; optional description, organization, and a contact object (name, email, url).

datasets[]

FieldRequiredNotes
keyYesStable identity for idempotent apply. Must start with a lowercase letter or digit, then a-z 0-9 . _ -; up to 128 chars.
titleYesHuman-readable dataset title.
descriptionNoLonger description.
sourcesYesExactly one source object (below); the schema caps the array at one entry.
metadataNoTags, CRS, license, bbox (below).
publicationYesPublication intent (below).

sources[]

FieldRequiredNotes
typeYesOne of vector, raster_cog.
uriYesRelative path, https://, or s3:// / gs:// / az:// / abfs:// URI. Must end in an extension the ingest path recognizes: zip, gpkg, geojson, json, csv, xlsx, xls, fgb, kml, or kmz for vector; tif or tiff for raster_cog. A .zip covers a zipped File Geodatabase as well as a Shapefile bundle — the two are told apart by content, not by the manifest. A query string or fragment may follow. Unlike a direct upload, a manifest source cannot be a .parquet file — publish GeoParquet through the UI or geolens publish instead.
formatNoDriver hint, e.g. geojson, gpkg.
layerNoLayer name for multi-layer sources.
title / descriptionNoPer-source overrides.
checksumNosha256:<64 lowercase hex characters>. A declared digest of the source bytes, folded into apply’s change-detection fingerprint alongside the rest of the entry — it is not verified against the fetched bytes. Bump it when the file under a stable URI changes, to force a vector entry to reclassify as an update instead of skipping as unchanged. Do not set or change it on a raster_cog source: manifest raster updates aren’t supported, so a changed checksum there makes apply report that entry as an error rather than a skip.

metadata (all optional): tags (string array), organization, crs (EPSG:NNNN), license, attribution, bbox ([minx, miny, maxx, maxy] in WGS84).

publication: required intent. The schema does not fix the values; they come from the workflow statuses your deployment defines, and the server validates them at apply time. The default set is draft, ready, internal, published, which is why geolens validate can accept an intent your instance then rejects.

manifest_version: "1"
catalog:
title: Regional Open Data
description: Public datasets published by the Regional Data Office.
organization: Regional Open Data Office
datasets:
- key: regional-trails
title: Regional trails
description: Recreational trail network.
sources:
- type: vector
uri: https://data.example.com/trails.geojson
format: geojson
metadata:
tags: [trails, recreation]
crs: EPSG:4326
license: CC-BY-4.0
attribution: Regional Open Data Office
bbox: [-78.0, 38.0, -76.5, 39.5]
publication:
intent: ready

geolens analysis preview computes an operation and writes the resulting GeoJSON to stdout without creating anything. The result is capped at the server’s preview limit, and the cap is announced on stderr so stdout stays a valid GeoJSON document:

Terminal window
geolens analysis preview <dataset_id> --operation buffer --distance 500 > ring.geojson
geolens analysis preview <dataset_id> --operation centroid --compact | jq .

geolens analysis materialize runs the operation over the whole dataset and saves the result as a new dataset:

Terminal window
geolens analysis materialize <dataset_id> \
--operation buffer --distance 500 --title "500 m ring"
geolens analysis materialize <dataset_id> \
--operation dissolve --by-field region \
--title "By region" --no-wait

Both accept buffer, centroid, clip, spatial_join, measure, select_by_location, and intersect; dissolve is materialize-only. Per-operation flags are --distance (buffer, in metres), --mask-dataset (required for clip, select_by_location and intersect — the CLI has no drawn-mask option, so omitting it is a server 422, exit 1), --by-field (dissolve), and --join-dataset-id / --join-fields (spatial_join).

materialize waits for the job by default. Analysis is queued below uploads, so that wait has no upper bound — pass --timeout <seconds> to bound it, or --no-wait to return the job id immediately.

Export STAC API 1.0 item metadata for a raster dataset (vector datasets are rejected with a clear message):

Terminal window
geolens export stac <dataset_id> # pretty JSON to stdout
geolens export stac <dataset_id> -o item.json # write to a file
geolens export stac <dataset_id> --compact # single line, for piping to jq

The CLI is built for pipelines: authenticate through the environment, validate offline, preview with --dry-run on a pull request, apply on a push to main. --json is a global option and goes before the subcommand (geolens --json apply geolens.yaml). Only geolens scan also accepts --json after the subcommand; for every other command a trailing --json is a “No such option” error.

Every command returns a non-zero exit code on failure, and the code says what kind of failure it was:

CodeMeaningWhat produces it
0SuccessThe command did what you asked.
1FailedA request the server answered with a status that is neither auth nor 5xx — a 404 for an unknown dataset, a 400, a 422 the CLI did not catch first. Also a job that finished failed, an apply whose response reported per-dataset errors, and a re-commit of an upload that was already committed.
2Usage errorAn unknown flag or a malformed argument, plus the checks below. apply also maps a server 422 here.
3AuthA 401 or 403 on a request, with the one exception noted below. Also “no instance configured”, but only from publish, whoami, analysis preview and analysis materialize — they check for an instance themselves. status, refresh, export stac, apply and logout treat a missing instance as a usage error and exit 2 instead. whoami also uses 3 for a session it could not refresh.
4NetworkThe request timed out or the instance was unreachable — DNS, TLS, connection refused.
5ServerA 5xx from the API, or an apply response that was not the JSON it should have been.

Two collapses are worth knowing before you branch on these. 401 and 403 both exit 3, so a rejected credential and a permission you do not hold look identical — the distinguishing sentence is on stderr. And every 5xx exits 5, so a 500 and a 503 behind a proxy are the same code.

The exception to 3: a 401 or 403 raised while applying --tags or --collection after a publish commits becomes a warning line on a dataset that now exists, and the command exits 1. That is deliberate — re-running would publish a duplicate, so the failure is not presented as retryable.

That leaves three codes a credential problem can arrive under, so branching on the code alone is not enough: 3 for an ordinary rejection, 2 when a command that resolves its own instance finds none configured, and 1 for the post-commit case above. Read stderr to tell them apart — and treat 1 from publish as “the dataset exists, the extras did not apply”, never as a signal to retry the publish.

Several commands exit 2 for a flag combination rather than a typo, and all but one of them decide it locally, before any request goes out:

  • publish --tags or --collection together with --no-wait, since both are applied after the commit resolves the dataset id.
  • refresh --timeout without --wait, and any --timeout that is not a finite number greater than zero. analysis materialize --timeout enforces the same finite bound.
  • export stac against a vector dataset: a pre-flight GET /datasets/{id} reads the record type and rejects a non-raster one before the STAC endpoint is touched. This is the one that does make a request — and if that pre-flight 404s on an unknown id, the exit is 1, not 2.
  • analysis with spatial_join and no --join-dataset-id. Other bad operation/flag pairings are only caught by the server, which makes them a 422 and therefore exit 1.
  • validate on a manifest that fails the schema, and init against a manifest that already exists without --force.

The examples repo has a ready-made GitHub Actions workflow that splits validate, preview, and apply into three jobs so the write token never reaches a pull request, and a walkthrough of the secrets and environment it expects.

These apply to every command and go before the subcommand:

FlagEffect
--instance <url>Override the active instance for this command.
--jsonMachine-readable JSON output.
-v, --verboseDebug logging to stderr.
-q, --quietSuppress non-error output.
--versionPrint the CLI version and exit.