CLI (oa)
oa is a single-binary command-line client for the OpenArtifact REST API: repositories, users, tokens, scans, webhooks, backups, declarative configuration from manifests (oa apply) and the Nexus/Artifactory importer.
Install
make build-cli # builds bin/oa (version injected via -ldflags -X main.version)
go build -o bin/oa ./cmd/oaRelease builds (.goreleaser.yml) ship oa_<version>_<os>_<arch> archives for linux, darwin and windows (amd64, arm64). Source: cmd/oa.
oa version # prints "oa version <version>" ("dev" for an untagged build)
oa help # full command list (also: oa with no arguments, -h, --help)Server, token and config file
| Setting | Flag | Env | Default |
|---|---|---|---|
| Server URL | --server URL | OA_SERVER | value saved by oa auth login, else http://localhost:8080 |
| API token | --token TOKEN | OA_TOKEN | value saved by oa auth login |
| Output format | --format table|json | n/a | table |
Precedence: flag, then environment variable, then the config file. Global flags go before the command (oa --format json repo list).
The config file is ~/.config/oa/config.json on every OS (it does not follow XDG_CONFIG_HOME or the platform config dir). It holds {"server": "...", "token": "..."}. The directory is created with mode 0700 and the file is written atomically with mode 0600.
Output: --format json prints indented JSON; anything else prints aligned tables. Errors go to stderr as Error (<status>): <message> and the exit code is 1. HTTP redirects that downgrade https to http are refused, and credentials are stripped when a redirect leaves the original host.
Login
oa auth login --server http://localhost:8080 # prompts for username and password (no echo)
oa auth whoami
oa auth logout # clears the saved token locally
printf '%s\n' "$PW" | oa auth logout --revoke --username alice --password-stdin # also revokes it on the serverPasswords are never accepted on the command line (--password is rejected). Non-interactive:
printf '%s\n' "$PW" | oa auth login --username alice --password-stdinHow it works:
- Leave the username blank to paste an existing API token instead.
- Otherwise the CLI signs in with
POST /api/v1/auth/loginon a temporary cookie session. The password is not stored. - If the account must change its password (bootstrap
admin, reset account), the CLI asks for a new one first (with--password-stdin: the second stdin line) and signs in again. - It creates an
accessAPI token namedoa-cli(POST /api/v1/auth/tokens), and saves server and token to the config file.
oa auth logout clears the local token and prints the server-side token id. API tokens cannot revoke tokens, so the oa-cli token stays valid unless you pass --revoke --username U --password-stdin (which signs in and revokes it) or revoke it in the UI under Settings > Access tokens. --revoke needs a login saved by this version of oa. A corrupt ~/.config/oa/config.json no longer blocks oa auth login, help and version; oa auth login rewrites it.
Webhook secrets are read from --secret-file FILE (- for stdin) or OA_WEBHOOK_SECRET, never from argv.
For CI, skip login: create a token in the UI and set OA_SERVER / OA_TOKEN. See authentication for token kinds and scopes.
Commands
Commands marked admin need the global manage permission. IDs are the values shown by the matching list/show command.
| Command | Subcommands and key flags |
|---|---|
auth | login [--server URL] [--username U] [--password-stdin], logout, whoami, passwd [--password-stdin] (stdin: current password, then new, one per line), token list, token create --name N [--kind access|deploy] [--username U] [--password-stdin], token revoke <id> [--username U] [--password-stdin] |
repo | list, create <name> [--format FMT] [--description D] [--public] [--trash-retention-days N] [--immutable-releases] [--sign-packages] (default format npm), show <name>, update <name> [--description D] [--public[=false]] [--trash-retention-days N|default] [--immutable-releases[=false]] [--sign-packages[=false]] [--merge-proxy-versions[=false]] (only the flags you pass are sent; --sign-packages is for hosted rpm repositories; --merge-proxy-versions is for group repositories and administrators only), delete <name> --confirm <name> (see Deleting a repository), packages <name>, members <name> (the member repositories of a group repository) |
trash | list [--repo NAME] [--kind K], restore <kind> <id> [--repo NAME], purge <kind> <id> [--yes], purge --repo NAME --all [--yes] (see Trash) |
artifact | list <repo>, upload <repo> <file>, delete <repo> <id> (moved to the trash unless the repository retention is 0) |
search | [--repo R] [--fmt F] [--limit N] <query> (default limit 50; put flags before the query) |
user admin | list, create --username U --email E [--password-stdin] [--full-name N], show <id>, update <id> [--email E] [--full-name N] [--disabled[=false]], delete <id> |
group admin | list, create <name> [--description D], show <id>, delete <id>, members <id>, add-member <id> <user-id>, rm-member <id> <user-id> |
project (roles: projects) | list (with your role), create <name> [--display-name N] [--description D] [--owner USERNAME] (--owner: global admins; others need projects.self_create), show <name> (includes my_role), update <name> [--display-name N] [--description D] (owner; sends PATCH) plus admin-only policy flags --allowed-formats F,F, --allowed-repo-types T,T, --repo-name-prefix P, --allow-public-repos[=false], --max-repositories N, --token-max-ttl-days N, delete <name>, members <name>, add-member <name> (<user-id> | --username U) [--role viewer|developer|maintainer|owner] (default viewer), set-role <name> <user-id> <role> (sends PATCH), rm-member <name> <user-id>, groups <name>, add-group <name> <group-id|group-name> [--role viewer|developer|maintainer], rm-group <name> <group-id|group-name> |
token admin | list (all tokens), revoke <id> |
gpg admin | list, import --key <armored-file|-> [--name N] [--passphrase-stdin] [--activate|--no-activate], generate [--name N] [--algorithm ed25519|rsa4096] [--user-name U] [--email E] [--comment C] [--activate|--no-activate], activate <key> [--repository R], deactivate <key> [--repository R], export <key> [--output FILE], delete <key> [--force]. <key> is an id, fingerprint or name; the passphrase comes from stdin or OA_GPG_PASSPHRASE, never a flag. See signing |
storage admin | list, create --name N [--type filesystem|s3|gcs|azure|garage|seaweedfs|oci] [--config JSON | --config-file FILE|-], show <name>, update <name> [--config JSON | --config-file FILE|-], delete <name>, test <name> (exits 1 when the backend check fails) |
backup admin | list, create [--type full|metadata-only], show <id>, download <id> <dest>, restore <archive-file> (uploads an archive written by backup download) |
audit admin | list [--limit N] [--action A] [--actor A] (default limit 50; pages through the server's 100-row pages); export [--format jsonl|csv] [--since T] [--until T] [--actor A] [--action A] [-o FILE] streams the whole log, see audit export |
webhook | list, create --name N --url U --events E,E [--secret-file F|-] [--active=false], show <id>, update <id> [--name N] [--url U] [--events E,E] [--active[=false]], delete <id>, test <id>, deliveries <id>, repo list <repo>, repo create <repo> --name N --url U --events E,E [--secret-file F|-] |
sbom | list <repo>, generate <repo> --artifact-id ID [--format cyclonedx|spdx|spdx-json|spdx3-json] [--spec-version V], show <repo> <id>, download <repo> <id> <dest> [--attest true|bundle], delete <repo> <id> |
vex | get <repo> [--artifact-id ID] [--format openvex|cyclonedx] [--spec-version V] [--output FILE] [--attest true|bundle], list <repo> [--artifact-id ID], set <repo> --artifact-id ID --vuln CVE --status S [--justification J] [--impact T] [--action T], delete <repo> <id> |
dtrack admin | show <repo>, test <repo>, push <repo> --artifact-id ID, pushes <repo>: the repository's Dependency-Track integration (the global settings and API keys are set in Settings > Dependency-Track or the API) |
vuln | scan <repo> --artifact-id ID --package P --version V --ecosystem E [--scanner osv|trivy|grype], list <repo>, show <repo> <id>, quarantine <repo> <id>, delete <repo> <id> |
license | scan <repo> --artifact-id ID, list <repo>, show <repo> <id> |
secret | scan <repo> --artifact-id ID [--content-file FILE|-], list <repo>, show <repo> <id>, quarantine <repo> <id>, delete <repo> <id> |
cleanup | list <repo>, create <repo> --name N (--keep-count N | --keep-days N) [--name-pattern REGEX] [--schedule CRON] [--enabled=false], show <repo> <id>, update <repo> <id> [same flags] (only the flags you pass are sent), delete <repo> <id>, run <repo> <id> |
quota | show <repo>, set <repo> [--limit N [--unit MB|GB|TB]] [--max-artifacts N] [--warn-percent P] (default unit GB; at least one flag), delete <repo> |
props | list <repo> <artifact-id>, set <repo> <artifact-id> <key> <value>, delete <repo> <artifact-id> <key>, search <repo> <key> <value> |
upload | <repo> <file|glob>... [--target-path P] [--flat] [--property k=v]... [--build-name N] [--build-number M] [--build-url U] [--vcs-revision SHA]: upload files with build properties, see CI commands |
bundle | create <name> <version> [--description D] [--pipeline P], add <name> <version> (--repo R --path P... | --from-build NAME/NUMBER [--repo R]...), seal <name> <version> [--gpg-key K], show <name> <version>: release bundles for CI, see CI commands |
promote | <name> <version> --stage S [--target-repo R] [--notes T]: promote a sealed bundle, see CI commands |
delta | list <repo> <pkg-id>, get <repo> <pkg-id> --from <sha256> <dest> |
apply, diff, export admin | apply -f PATH [-R] [--kind K] [--prune] [--prune-all] [--force-secrets] [--continue-on-error] [--validate-only] [-o json], diff (same flags; exits 2 when there are changes), export [--kind K] [--with-members] [-o DIR]: declarative configuration from YAML manifests, see declarative configuration |
import | nexus|artifactory ..., see Importing |
system | info (version, status, commit, build time, Go version, OS, arch, uptime, database and storage status; admins get the detailed endpoint, other users only version and status) |
version, help | local only, no server call |
Boolean flags use Go syntax: --public, --disabled, and --active=false / --enabled=false to turn off a default-true flag. Update commands send only the flags you pass, so --public=false or --disabled=false change just that field.
auth passwd, auth token create and auth token revoke sign in with your account password on a temporary cookie session (the server refuses these for API-token callers). The password is prompted without echo, or read from stdin with --password-stdin; --username defaults to the saved token's owner.
storage create|update --config is for non-secret settings only: the CLI rejects an inline config containing a key that looks like a credential (secret, password, key, token, cred, connstr, sas), because argv is visible in the process list. Put credentials in a file and pass --config-file FILE, or --config-file - to read the JSON from stdin.
Deleting a repository
oa repo delete <name> needs the repository name as confirmation, and the server rejects the request with 400 confirmation_required without it:
oa repo delete old-repo --confirm old-repo # scripts and CI
oa repo delete old-repo # on a terminal: prompts "Type the repository name to confirm:"--confirm must equal the name; a mismatch fails locally without a request. With no --confirm and stdin not a terminal, the command exits 1 and tells you to pass --confirm <name>. The CLI sends the value as ?confirm=. The repository is moved to the trash for its retention period (default 7 days) instead of being destroyed, unless its retention is 0.
Trash
oa trash list # every trashed item, repositories included (admin)
oa trash list --repo my-repo --kind maven_artifact
oa trash restore raw_artifact <id> --repo my-repo
oa trash restore repository <id> # admin
oa trash purge docker_tag <id> # asks "Type 'yes' to confirm"; --yes skips the prompt (admin)
oa trash purge --repo my-repo --all --yes # empty that repository's trash (repository manage permission)listprintsID,KIND,REPOSITORY,NAME,VERSION,SIZE,DELETED_AT,DELETED_BYandPURGE_AT(all fields with--format json). Without--repoit uses the admin endpoint; with--repoit uses the repository endpoint, which needs only the delete permission on that repository.--kindfilters by kind (repository,maven_artifact,npm_version,docker_manifest, ...; the full list is in Trash).restore <kind> <id>restores one item and prints it with--format json. A conflict (the path or version was re-published meanwhile, or the repository is itself in the trash) exits 1 with the server's409message. Restoring a repository needs the admin endpoint (no--repo).purge <kind> <id>deletes the item permanently now. Without--yesit asks for confirmation on a terminal and refuses to run when stdin is not one.purge --repo NAME --allpurges every trashed item of the repository (DELETE /api/v1/repositories/{name}/trash) and needs the repositorymanagepermission instead of admin;--allrequires--repo, and it asks for the same confirmation (--yesskips it). The server purges at most 1000 items per request; the CLI repeats until none remain and prints the totals.oa repo create|update --trash-retention-days Nsets the per-repository retention (0deletes immediately,defaultonupdategoes back to the server-wide setting);--immutable-releases[=false]blocks deleting or overwriting released versions. See immutable releases.
Transfers
Uploads and downloads stream through the CLI without buffering whole files:
| Command | Behavior |
|---|---|
oa artifact upload <repo> <file> | multipart upload. *.deb goes to POST /api/v1/repositories/{repo}/debs; *.apk goes to .../apks (with --branch, --apk-repo and --arch, see Alpine); any other file goes to .../packages (the RPM upload endpoint). Other formats are published with their native client (npm publish, mvn deploy, curl -T, ...), see formats |
oa artifact upload --presigned [--path P] [--content-type T] [--multipart] [--part-size SIZE] [--parallel N] <repo> <file> | raw repositories on any storage backend with presigned uploads (S3, GCS, Azure, and filesystem/OCI through the server's signed relay URLs): hashes the file, asks POST /api/v1/repositories/{repo}/uploads for a presigned PUT, uploads the bytes straight to the object store (the OpenArtifact token is never sent there) and calls .../uploads/{id}/complete, which reads the object back from storage, verifies size and sha256 and records the artifact. Files above 5 GiB are uploaded in parts automatically (--multipart forces it, --part-size such as 128M sets the part size, --parallel the parts in flight, default 4, at most 16); each part is retried with backoff and the upload is aborted on the server if a part fails for good. The complete call has no response-header timeout and is retried while the server answers 409 upload_in_progress. --path defaults to the file name. See Presigned URLs |
oa backup download <id> <dest> | writes to a temp file next to <dest>, syncs, then renames onto <dest> |
oa sbom download <repo> <id> <dest> | same |
oa delta get <repo> <pkg-id> --from <sha256> <dest> | same; downloads the delta patch from the given base version |
There are no top-level oa upload / oa download commands.
CI commands
oa upload, oa bundle and oa promote are made for CI jobs: they print one JSON document with --format json (put the global flags before the command) and exit 0 on success, 1 on any failure, with Error: <message> on stderr. Setup, a non-admin token and plugin usage are in CI integration. They need oa 0.10.0 or newer; oa --format json version prints {"version","go","os","arch"} (version is dev for an unstamped build).
oa upload
oa --format json upload <repo> <file|glob>... [--target-path P] [--flat] \
[--property k=v]... [--build-name N] [--build-number M] [--build-url U] [--vcs-revision SHA]Uploads to a hosted raw, maven, ivy, sbt, deb, rpm or pacman repository (pacman: the package and its .sig, stored under os/<arch>/ by file name; see pacman) and records properties on every uploaded artifact: build.name, build.number, build.url, vcs.revision from the flags and any --property k=v (a custom property wins over a build flag of the same key). Other formats are refused with a hint to use the native client (npm publish, docker push, twine, ...), and so are proxy and group repositories. Needs read and write on the repository.
Arguments are files or globs (*, ?, [...], and ** for any depth); quote globs to keep the shell out of it. A pattern without a match, a directory or a missing file is an error before anything is uploaded.
Remote path (raw, maven, ivy, sbt; deb and rpm keep the name the server derives from the package and reject --target-path):
| Flags | Remote path of dist/a/b.tgz |
|---|---|
| none | dist/a/b.tgz (the path as given, relative to the working directory) |
--flat | b.tgz |
--target-path rel/1.0/ | rel/1.0/dist/a/b.tgz (a trailing / makes it a directory prefix) |
--target-path rel/1.0/ --flat | rel/1.0/b.tgz |
--target-path rel/b-1.0.tgz, a single file | rel/b-1.0.tgz (exact path) |
--target-path rel, several files | rel/<path> (a directory) |
An absolute path or one that climbs out of the working directory (..) needs --flat. A Maven, Ivy or sbt path must be the repository layout (com/acme/lib/1.0/lib-1.0.jar).
Every file is hashed locally and the digest the server stored is compared with it (Maven through the .sha256 sidecar); a mismatch fails the upload. Files are uploaded in order and the command stops at the first failure.
{
"uploaded": [
{
"repo": "releases",
"path": "builds/42/dist/app.tar.gz",
"sha256": "4be899fc...",
"size": 7,
"url": "https://oa.example.com/repos/raw/releases/builds/42/dist/app.tar.gz",
"id": "659e256b-bbb2-4ef7-8daa-b4065fa505e0"
}
]
}url is the download URL (for deb and rpm the package download endpoint) and id the artifact id used by properties. For deb and rpm path is the local file name; the stored file name, which bundle add --path and the bundle items use, comes from the package metadata. On failure, with --format json, stdout still carries what was uploaded before the error, plus the message:
{"uploaded": [ ... ], "error": "unexpected response: PUT /repos/raw/releases/a.txt: HTTP 403: forbidden"}oa bundle and oa promote
oa --format json bundle create <name> <version> [--description D] [--pipeline NAME]
oa --format json bundle add <name> <version> --repo R --path P [--path P]...
oa --format json bundle add <name> <version> --from-build NAME/NUMBER [--repo R]...
oa --format json bundle seal <name> <version> [--gpg-key K]
oa --format json bundle show <name> <version>
oa --format json promote <name> <version> --stage S [--target-repo R] [--notes T]createmakes a draft bundle;--pipelineattaches an existing pipeline by name.add --repo R --path P...adds artifacts of one repository by their path asoa uploadprinted it (raw: the path; Maven: the repository path; Ivy and sbt: the repository path; deb and rpm: the stored file name). A path that matches nothing, or more than one artifact, is an error.add --from-build NAME/NUMBERadds every artifact whose properties arebuild.name=NAMEandbuild.number=NUMBER(split at the last/, soNAMEmay contain slashes such as a Jenkinsfolder/job/main). It searches the hosted repositories the token can read, or only the--repoones (repeatable). It is an error when nothing carries both properties. The same content added twice (by repository and SHA-256) counts once.sealfreezes the bundle;--gpg-keysigns its manifest with a key id, fingerprint orconfig(the operator hands the id to the job, the key list is admin-only).promotecopies a sealed bundle into the repository of--stage.--target-repois optional when the pipeline stage is bound to a repository.
All bundle commands print the bundle:
{
"bundle": "shop",
"version": "1.0.0",
"state": "sealed",
"url": "https://oa.example.com/bundles/shop/1.0.0",
"description": "shop 1.0.0",
"pipeline_id": "c8bafcff-1f5b-4665-bd51-ab6240c1e1cc",
"current_stage": "prod",
"created_by": "ci-bot",
"sealed_at": "2026-10-24T12:02:14+02:00",
"added": 3,
"items": [{"repo": "ci-dev", "path": "builds/42/dist/app.tar.gz", "sha256": "4be899fc...", "size": 7, "format": "raw"}],
"promoted": {"stage": "prod", "target_repo": "ci-prod", "items": 3, "copied": 3, "existing": 0, "replaced": 0}
}| Field | Present |
|---|---|
bundle, version, state (draft, sealed, archived), url | always; url is the web UI page <server>/bundles/<name>/<version>, which needs an administrator |
description, pipeline_id, current_stage, created_by, sealed_at, gpg_key_id | when set |
added | bundle add: the number of items sent (after removing duplicates) |
items | bundle add (the items it added) and bundle show (all items) |
promoted | promote: items = items in the bundle, copied/existing/replaced as in the promotion response |
Without --format json each command prints a one-line summary instead.
Permissions
None of these commands needs an administrator: a token with read and write on the repositories involved is enough. See CI integration and the bundle permissions.
Importing from Nexus or Artifactory
oa import nexus|artifactory --source-url URL --repo SRC[:DST]... copies hosted repositories from a Nexus 3 or Artifactory server: Maven (also Gradle), Debian, RPM, raw, npm, PyPI, Docker/OCI, Helm, NuGet, RubyGems, Cargo, Go, Conda, Conan, Ivy and sbt, with created-at, properties, checksum verification and resume. The source password or token is read from a file, stdin or OA_IMPORT_SOURCE_TOKEN, never from the command line. Flags, supported formats, resume and checksum behavior are in Migrating from Nexus or Artifactory.