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.

Service refreshes verify the source feature count against the number of features fetched before publishing the replacement. A mismatch rejects the run and leaves the current dataset serving. The same gate blocks a refresh when the source count is unavailable, the replacement is empty, or columns were removed or retyped. These runs are recorded as blocked for review.

--accept-blocked-run is a one-time acceptance. It applies only when the next fetch has the same source, attributes, and geometries as the reviewed result. It cannot approve a different result or a later source change. Matching counts still do not prove that a mutable provider served every page from one snapshot.

Use --auth-file with a protected JSON file for a source bearer token, Basic credential, or named header. It is mutually exclusive with --token. With --wait and global --json, the CLI waits for the durable run and emits its terminal result as JSON:

Terminal window
geolens --json refresh <dataset_id> --auth-file ./source-auth.json --wait
geolens --json refresh <dataset_id> --auth-file ./source-auth.json --wait \
--accept-blocked-run <run_id>

The durable run remains in refresh history after the background job is purged, so automation can retain the run id and terminal status.

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. From 1.19.1 the preview refuses a file with no geometry over a dataset that stores geometry, with a message saying nothing was changed and to import the file as a new dataset; a Parquet file over the ingest ceiling gets the ceiling message there too, rather than being reported as malformed. --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. From 1.19.1 it must be an EPSG code the instance knows: a code that neither PROJ nor PostGIS has is refused at commit with a message naming it, and nothing is queued. --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. --yes does not acknowledge review reasons: with --wait, a replacement that needs review (for example, a removed column) still stops as a blocked run, and the CLI prints the geolens refresh <id> --accept-blocked-run <run_id> command and exits 6. Confirming at the prompt does acknowledge them.

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.

After a successful file replacement or service refresh of a vector dataset, GeoLens keeps the replaced table as the previous version until the next replacement, and POST /api/datasets/{id}/previous-version/restore publishes it again. Registered PostGIS refreshes, partitioned tables, rasters, mosaics, tilesets and point clouds keep no previous version, so getting their earlier data back takes a backup or a re-import.

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.

geolens apply --wait follows each queued job to its end. The result table gains a STATUS column (complete, failed, blocked, cancelled or timed_out), and with --json each result gains final_status and run_id.

An update that removes or retypes a column, or otherwise needs review, ends blocked. Applying that entry again unchanged does not queue it a second time: the entry comes back blocked with the run to accept and its reasons, apply prints geolens refresh <dataset_id> --accept-blocked-run <run_id>, and the exit code is 6. A changed entry is queued as usual.

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, http(s):// URL, or s3:// 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).

From 1.19.1, license, organization and tags reach the dataset, both when the manifest creates it and on a later apply that answers update for the entry (an unchanged entry skips, and a skip writes nothing). license and organization overwrite the stored value whenever the entry carries one; an entry that omits a field leaves the dataset’s value alone. tags are added to the dataset’s keywords (the Tags field on the dataset page), compared without regard to case against the keywords already there and stored in lower case; a tag the dataset already has is not added again, and a tag dropped from the manifest stays on the dataset, so removing one is a dataset edit. crs must be an EPSG code the instance knows: one that neither PROJ nor PostGIS has is reported as an error for that entry, by --dry-run as well as by a real apply, before anything is staged.

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

geolens arcgis inventory lists an ArcGIS Online or Portal for ArcGIS organization’s content and reports what would carry over to GeoLens. It is read-only: it changes nothing on the portal, needs no GeoLens instance or sign-in, and talks only to the portal URL you give it. It never contacts the services an item points at.

Terminal window
export ARCGIS_TOKEN=... # or --token-stdin, or --username with a password prompt
geolens arcgis inventory --portal-url https://<org>.maps.arcgis.com -o ./inventory
geolens arcgis inventory --portal-url https://gis.example.com/portal --scope org
geolens --json arcgis inventory --portal-url https://<org>.maps.arcgis.com > inventory.json

Each item is classified as one of:

  • supported: hosted feature layers and views, and feature and map services GeoLens can reach.
  • partial: web maps (their layers import, but styling and popups need translation), hosted tile layers, OGC services and data files.
  • unsupported: apps, dashboards, StoryMaps, scenes, image and vector tile services, and anything the CLI does not recognize.

Web AppBuilder apps and classic Esri Story Maps are flagged with Esri’s retirement dates. Web maps and apps are read to record which layers and maps they depend on, and a dependency is marked external when it belongs to another organization, such as Living Atlas content. Hosted views, hosted layers and Survey123 forms also record their source service, source file or results service, at one extra request each.

Every item records its snippet, description, tags, credits, license, extent, thumbnail name, spatial reference, folder, the groups it is shared with, and its owner’s name and email. Folder titles, owner names and group lists come back null where the portal does not let the signed-in account read them, and the group list holds only the groups that account can see. An anonymous run skips group reads, and --no-groups skips the one-request-per-item group read.

  • --scope user (the default) lists the signed-in user’s folders. --scope org lists every organization item the account can see. Without credentials only --scope org works, and it lists public items.
  • Bring an existing token through ARCGIS_TOKEN or --token-stdin. A value passed as --token <value> is visible in the process list and usually lands in shell history, so avoid it. Or use --username for a built-in account. The password comes from ARCGIS_PASSWORD, --password-stdin or a prompt, and mints a 60-minute token bound to the portal URL as its referer. The password stays in memory for the run, so when the portal rejects the token as expired the command mints a new one and retries the failed read. A token you bring is sent without a Referer. SAML and OpenID Connect accounts need a token.
  • The token travels in the X-Esri-Authorization header, never in a URL, and the command does not write it to the report or to disk. Redirects are refused. --allow-insecure-http permits an http:// portal URL, for test portals.

Output is Markdown on stdout, JSON with the global --json, or two files (arcgis-inventory.json and arcgis-inventory.md, mode 0600) with -o/--output-dir. The JSON schema ships in the package as geolens_cli/manifest/schemas/arcgis-inventory-v2.schema.json.

With -o, each web map’s configuration is also saved as webmaps/<item id>.json and each app’s as apps/<item id>.json (StoryMaps, Experiences, Dashboards, Hub sites and pages, Web Scenes, Notebooks and the other app types), mode 0600. Before writing, the value under any key named like a credential (token, password, apiKey, secret, clientSecret, credentials, customParameters) becomes [REDACTED] at any depth. Other strings are also scrubbed by pattern: credential parameters in URLs, URL userinfo, authorization and cookie headers, bearer-style values, JWTs, private keys and AWS access key ids. Redaction is best effort. A secret under an unrecognized key or in an unusual shape can remain, so treat the output as sensitive and review it before sharing. --read-all-item-data reads the data of every other item too, and records any item ids it names.

  • --max-items stops listing after that many items (default 10,000).
  • --concurrency sets parallel item reads, 1 to 4. Requests are capped at ten a second, with backoff and retry on 429 and 502 to 504.
  • ArcGIS search returns only the first 10,000 results of a query, so a --scope org listing that reaches that limit is marked truncated (search_ceiling) whatever --max-items is.

Exit codes for inventory: 0 when the report is complete, even if some items could not be read (they are listed under errors, and --strict makes that exit 1); 2 for usage errors or a redirecting portal; 3 when the token is rejected; 4 for network errors; 5 for portal server errors. If a token you brought expires mid-run, or a new one cannot be minted, the partial report is still written and marked "complete": false. Rerun with a fresh token.

geolens arcgis plan turns an inventory into an editable plan. It reads only the inventory file, so it needs no portal or GeoLens access.

Terminal window
geolens arcgis inventory --portal-url https://<org>.maps.arcgis.com -o ./inventory
geolens arcgis plan ./inventory/arcgis-inventory.json
geolens arcgis plan ./inventory/arcgis-inventory.json --owner-map owners.json

It writes arcgis-migration-plan.json (mode 0600) next to the inventory, or into -o/--output-dir, and prints how many items each action covers and which dependencies point at items that are not in the inventory. It refuses a v1 inventory and tells you to rerun the inventory. Running it again gives the same file. If the file on disk differs because you edited it, plan stops unless you pass --force.

These are the fields you are expected to edit:

  • items[].action is one of import_layers, import_file, import_map, export_only or skip. The default follows the inventory: supported feature and map services import as layers, data files import as files, web maps import as maps, and apps, scenes and unsupported types are export_only. Set skip for anything you do not want. A web map whose data could not be read defaults to export_only and the plan warns, because its layer dependencies are unknown.
  • items[].target holds the visibility (ArcGIS private, org and public become private, internal and public), the collections (the item’s folder), the groups (the ArcGIS group ids it is shared with, null when the inventory did not read group sharing), the title and the tags. target.owner is null to use owner_map, or a GeoLens username or email to override it for one item.
  • owner_map lists every ArcGIS owner with an empty value to fill in with the GeoLens user who should end up owning the item. --owner-map file.json fills it from a prepared {"arcgis_user": "geolens_user"} object.
  • group_map lists every group seen, with the collection its items join and that collection’s visibility. Items join these collections through target.groups, so renaming a group’s collection here renames it for every item.
  • basemap_map lists every basemap URL used by a web map, to fill in with the GeoLens basemap that replaces it.
  • items[].alias_of is set by hand, with action skip, on a hosted view that filters nothing, so web maps using it point at the parent’s dataset. The inventory cannot tell, because a view’s filter lives in the service, which the inventory never contacts.

depends_on lists the items to migrate first. An id that is not among the plan’s items is not in the inventory. Items from other organizations, such as Living Atlas layers, get no row and never appear in depends_on. A data file that a hosted layer was published from is export_only with superseded_by naming the layer, so its rows are not imported twice.

The plan and the sidecar files go through the same redaction as the inventory, with the same limits. Review them before sharing.

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.
6Blocked for reviewreplace --wait, refresh --wait or apply --wait ended with a run waiting for review. For apply --wait, a failure in any entry takes precedence and the exit is 1.

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.