From 0251b47a6d191ae31b8d2c435f1d9d0209454092 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 14 May 2026 13:36:30 +0200 Subject: [PATCH 1/6] feat: support object versioning (list-versions, version-id, all-versions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CLI surface for the new versioning primitives in @tigrisdata/storage@3.6.0 (listVersions, plus versionId on head/get/remove). New command: - `tigris objects list-versions ` (alias `lv`) — lists versions and delete markers, two-table output in table mode, AWS-shaped JSON (`{ versions, deleteMarkers, commonPrefixes, nextKeyMarker, nextVersionIdMarker, hasMore }`) so downstream `jq` users get the same ergonomics as `aws s3api list-object-versions`. Supports `--prefix`, `--delimiter`, `--limit`, `--key-marker`, `--version-id-marker`. New flag `--version-id ` on: - `tigris objects info` — head a specific version. - `tigris objects get` — download a specific version (both stream and string modes). - `tigris objects delete` — hard-delete a single version. Mutually exclusive with --all-versions; rejected when more than one key is targeted. - `tigris stat ` — head a specific version for the object branch. New flag `--all-versions` on `tigris objects delete`: - Walks `listVersions` for each key and hard-deletes every version and every delete marker. Confirmation prompt is rewritten to match the destructive scope. Out of scope (matches `aws s3`'s posture, not `aws s3api`): - `tigris cp` and `tigris rm` stay version-unaware. Default behavior preserved (matches S3): - `objects get/info/stat` without `--version-id` → current version (server returns 404 if the current version is a delete marker). - `objects delete` without `--version-id` on a versioned bucket → creates a delete marker, the existing versions are preserved. `snapshotVersion` (bucket-level point-in-time snapshot read) and `versionId` (per-object historical version) are distinct mechanisms and remain as separate flags. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 8 +- package.json | 2 +- src/lib/objects/delete.ts | 90 +++++++++++++++---- src/lib/objects/get.ts | 3 + src/lib/objects/info.ts | 2 + src/lib/objects/list-versions.ts | 144 +++++++++++++++++++++++++++++++ src/lib/stat.ts | 2 + src/specs.yaml | 54 +++++++++++- 8 files changed, 282 insertions(+), 23 deletions(-) create mode 100644 src/lib/objects/list-versions.ts diff --git a/package-lock.json b/package-lock.json index fa41267..1f95271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.5.2", + "@tigrisdata/storage": "^3.6.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", @@ -4032,9 +4032,9 @@ } }, "node_modules/@tigrisdata/storage": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.5.2.tgz", - "integrity": "sha512-VbjLO7W627Xy3iWPbbbvLMwIziZxLxfFRZyp2qGNjOE7qjy/+ADQzN0VjjiFZdVc7uL7RjHh78CGhJOjAI204A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.6.0.tgz", + "integrity": "sha512-l+FHRAA903MOZ/PFBlz6S73YwlJ1Fs+D5/gTPiI2/NtiyIsqj3eNRGDYB+DszT75NIxKWCRoyNornUM+7qXkVw==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", diff --git a/package.json b/package.json index b126495..ebd193d 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.5.2", + "@tigrisdata/storage": "^3.6.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index d4adf19..210c8de 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -1,5 +1,5 @@ import { getStorageConfig } from '@auth/provider.js'; -import { remove } from '@tigrisdata/storage'; +import { listVersions, remove } from '@tigrisdata/storage'; import { exitWithError, failWithError, @@ -18,6 +18,8 @@ import { resolveObjectArgs } from '@utils/path.js'; const context = msg('objects', 'delete'); +type Target = { key: string; versionId?: string }; + export default async function deleteObject(options: Record) { printStart(context); @@ -26,11 +28,20 @@ export default async function deleteObject(options: Record) { const bucketArg = getOption(options, ['bucket']); const keysArg = getOption(options, ['key']); const force = getOption(options, ['yes', 'y', 'force']); + const versionId = getOption(options, ['version-id', 'versionId']); + const allVersions = !!getOption(options, [ + 'all-versions', + 'allVersions', + ]); if (!bucketArg) { failWithError(context, 'Bucket name or path is required'); } + if (versionId && allVersions) { + failWithError(context, 'Cannot use --version-id with --all-versions'); + } + const resolved = resolveObjectArgs(bucketArg); const bucket = resolved.bucket; const keys = keysArg || resolved.key || undefined; @@ -40,35 +51,80 @@ export default async function deleteObject(options: Record) { } const config = await getStorageConfig(); + const bucketConfig = { ...config, bucket }; const keyList = Array.isArray(keys) ? keys : [keys]; + if (versionId && keyList.length > 1) { + failWithError( + context, + '--version-id targets a single object; pass exactly one key' + ); + } + + // Resolve the list of (key, versionId?) targets to delete. By + // default we issue an unversioned DELETE per key (server creates a + // delete marker on versioned buckets). --version-id hard-deletes + // one specific version. --all-versions enumerates every version + // and every delete marker for each key and hard-deletes them all. + const targets: Target[] = []; + if (allVersions) { + for (const key of keyList) { + const { data, error } = await listVersions({ + prefix: key, + config: bucketConfig, + }); + if (error) { + failWithError(context, error); + } + const matchingVersions = data.versions.filter((v) => v.name === key); + const matchingMarkers = data.deleteMarkers.filter((m) => m.name === key); + for (const v of matchingVersions) { + targets.push({ key, versionId: v.versionId }); + } + for (const m of matchingMarkers) { + targets.push({ key, versionId: m.versionId }); + } + if (matchingVersions.length === 0 && matchingMarkers.length === 0) { + failWithError( + context, + `No versions or delete markers found for key '${key}'` + ); + } + } + } else if (versionId) { + targets.push({ key: keyList[0], versionId }); + } else { + for (const key of keyList) targets.push({ key }); + } + if (!force) { requireInteractive('Use --yes to skip confirmation'); - const confirmed = await confirm( - `Delete ${keyList.length} object(s) from '${bucket}'?` - ); + const label = allVersions + ? `Hard-delete ${targets.length} version(s) and delete marker(s) for ${keyList.length} object(s) from '${bucket}'?` + : versionId + ? `Hard-delete version '${versionId}' of '${keyList[0]}' from '${bucket}'?` + : `Delete ${keyList.length} object(s) from '${bucket}'?`; + const confirmed = await confirm(label); if (!confirmed) { console.log('Aborted'); return; } } - const deleted: string[] = []; - const errors: { key: string; error: string }[] = []; - for (const key of keyList) { - const { error } = await remove(key, { - config: { - ...config, - bucket, - }, + const deleted: Target[] = []; + const errors: { key: string; versionId?: string; error: string }[] = []; + for (const target of targets) { + const { error } = await remove(target.key, { + ...(target.versionId ? { versionId: target.versionId } : {}), + config: bucketConfig, }); if (error) { - printFailure(context, error.message, { key }); - errors.push({ key, error: error.message }); + printFailure(context, error.message, target); + errors.push({ ...target, error: error.message }); } else { - deleted.push(key); - printSuccess(context, { key }); + deleted.push(target); + printSuccess(context, target); } } @@ -77,7 +133,7 @@ export default async function deleteObject(options: Record) { const jsonOutput: Record = { action: 'deleted', bucket, - keys: deleted, + deleted, errors, }; if (nextActions.length > 0) jsonOutput.nextActions = nextActions; diff --git a/src/lib/objects/get.ts b/src/lib/objects/get.ts index 2cfb982..aac7977 100644 --- a/src/lib/objects/get.ts +++ b/src/lib/objects/get.ts @@ -116,6 +116,7 @@ export default async function getObject(options: Record) { 'snapshotVersion', 'snapshot', ]); + const versionId = getOption(options, ['version-id', 'versionId']); if (!bucketArg) { failWithError(context, 'Bucket name or path is required'); @@ -135,6 +136,7 @@ export default async function getObject(options: Record) { if (mode === 'stream') { const { data, error } = await get(key, 'stream', { ...(snapshotVersion ? { snapshotVersion } : {}), + ...(versionId ? { versionId } : {}), config: { ...config, bucket, @@ -162,6 +164,7 @@ export default async function getObject(options: Record) { } else { const { data, error } = await get(key, 'string', { ...(snapshotVersion ? { snapshotVersion } : {}), + ...(versionId ? { versionId } : {}), config: { ...config, bucket, diff --git a/src/lib/objects/info.ts b/src/lib/objects/info.ts index f14a3a1..334e714 100644 --- a/src/lib/objects/info.ts +++ b/src/lib/objects/info.ts @@ -19,6 +19,7 @@ export default async function objectInfo(options: Record) { 'snapshotVersion', 'snapshot', ]); + const versionId = getOption(options, ['version-id', 'versionId']); if (!bucketArg) { failWithError(context, 'Bucket name or path is required'); @@ -34,6 +35,7 @@ export default async function objectInfo(options: Record) { const { data, error } = await head(key, { ...(snapshotVersion ? { snapshotVersion } : {}), + ...(versionId ? { versionId } : {}), config: { ...config, bucket, diff --git a/src/lib/objects/list-versions.ts b/src/lib/objects/list-versions.ts new file mode 100644 index 0000000..bf4d058 --- /dev/null +++ b/src/lib/objects/list-versions.ts @@ -0,0 +1,144 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { listVersions } from '@tigrisdata/storage'; +import { failWithError } from '@utils/exit.js'; +import { + formatJson, + formatSize, + formatTable, + formatXml, + type TableColumn, +} from '@utils/format.js'; +import { + msg, + printEmpty, + printPaginationHint, + printStart, + printSuccess, +} from '@utils/messages.js'; +import { getFormat, getOption, getPaginationOptions } from '@utils/options.js'; +import { parseAnyPath } from '@utils/path.js'; + +const context = msg('objects', 'list-versions'); + +export default async function listObjectVersions( + options: Record +) { + printStart(context); + + const bucketArg = getOption(options, ['bucket']); + const prefixFlag = getOption(options, ['prefix', 'p', 'P']); + const delimiter = getOption(options, ['delimiter', 'd']); + const keyMarker = getOption(options, ['key-marker', 'keyMarker']); + const versionIdMarker = getOption(options, [ + 'version-id-marker', + 'versionIdMarker', + ]); + const format = getFormat(options); + const { limit } = getPaginationOptions(options); + + if (!bucketArg) { + failWithError(context, 'Bucket name is required'); + } + + const parsed = parseAnyPath(bucketArg); + const bucket = parsed.bucket; + const prefix = prefixFlag || parsed.path || undefined; + + const config = await getStorageConfig(); + + const { data, error } = await listVersions({ + prefix, + ...(delimiter ? { delimiter } : {}), + ...(limit !== undefined ? { limit } : {}), + ...(keyMarker ? { keyMarker } : {}), + ...(versionIdMarker ? { versionIdMarker } : {}), + config: { + ...config, + bucket, + }, + }); + + if (error) { + failWithError(context, error); + } + + const versionRows = data.versions.map((v) => ({ + key: v.name, + versionId: v.versionId, + latest: v.isLatest ? 'yes' : '', + size: formatSize(v.size), + modified: v.lastModified, + })); + + const deleteMarkerRows = data.deleteMarkers.map((m) => ({ + key: m.name, + versionId: m.versionId, + latest: m.isLatest ? 'yes' : '', + modified: m.lastModified, + })); + + if (versionRows.length === 0 && deleteMarkerRows.length === 0) { + printEmpty(context); + return; + } + + const versionColumns: TableColumn[] = [ + { key: 'key', header: 'Key' }, + { key: 'versionId', header: 'Version ID' }, + { key: 'latest', header: 'Latest' }, + { key: 'size', header: 'Size' }, + { key: 'modified', header: 'Modified' }, + ]; + + const deleteMarkerColumns: TableColumn[] = [ + { key: 'key', header: 'Key' }, + { key: 'versionId', header: 'Version ID' }, + { key: 'latest', header: 'Latest' }, + { key: 'modified', header: 'Modified' }, + ]; + + if (format === 'json') { + // Mirror the S3 ListObjectVersions response shape so downstream + // `jq` users get the same ergonomics as `aws s3api`. + console.log( + formatJson({ + versions: data.versions, + deleteMarkers: data.deleteMarkers, + commonPrefixes: data.commonPrefixes, + nextKeyMarker: data.nextKeyMarker, + nextVersionIdMarker: data.nextVersionIdMarker, + hasMore: data.hasMore, + }) + ); + } else if (format === 'xml') { + const lines = ['']; + lines.push( + ' ' + + formatXml(versionRows, 'versions', 'version').replace(/\n/g, '\n ') + ); + lines.push( + ' ' + + formatXml(deleteMarkerRows, 'deleteMarkers', 'deleteMarker').replace( + /\n/g, + '\n ' + ) + ); + lines.push(''); + console.log(lines.join('\n')); + } else { + if (versionRows.length > 0) { + console.log('\nVersions'); + console.log(formatTable(versionRows, versionColumns)); + } + if (deleteMarkerRows.length > 0) { + console.log('Delete Markers'); + console.log(formatTable(deleteMarkerRows, deleteMarkerColumns)); + } + printPaginationHint(data.nextKeyMarker); + } + + printSuccess(context, { + versions: versionRows.length, + deleteMarkers: deleteMarkerRows.length, + }); +} diff --git a/src/lib/stat.ts b/src/lib/stat.ts index 4483a72..f22a4d5 100644 --- a/src/lib/stat.ts +++ b/src/lib/stat.ts @@ -19,6 +19,7 @@ export default async function stat(options: Record) { 'snapshotVersion', 'snapshot', ]); + const versionId = getOption(options, ['version-id', 'versionId']); const config = await getStorageConfig(); // No path: show overall stats @@ -84,6 +85,7 @@ export default async function stat(options: Record) { // Object path: show object metadata const { data, error } = await head(path, { ...(snapshotVersion ? { snapshotVersion } : {}), + ...(versionId ? { versionId } : {}), config: { ...config, bucket, diff --git a/src/specs.yaml b/src/specs.yaml index 88e7ccd..02c5b78 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -396,6 +396,8 @@ commands: - name: snapshot-version description: Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) alias: snapshot + - name: version-id + description: Object version id to stat (requires bucket versioning). Omit to stat the latest version # presign - name: presign @@ -1248,6 +1250,44 @@ commands: - name: source description: List objects from a specific storage source on buckets with shadow migration enabled options: [tigris, shadow] + # list-versions + - name: list-versions + description: List object versions and delete markers in a bucket (requires bucket versioning). Returns both arrays separately to match the S3 ListObjectVersions response + alias: lv + examples: + - "tigris objects list-versions my-bucket" + - "tigris objects list-versions t3://my-bucket/logs/" + - "tigris objects list-versions my-bucket --prefix images/" + - "tigris objects list-versions my-bucket --format json" + messages: + onStart: 'Listing object versions...' + onSuccess: 'Found {{versions}} version(s) and {{deleteMarkers}} delete marker(s)' + onFailure: 'Failed to list object versions' + onEmpty: 'No versions or delete markers found' + arguments: + - name: bucket + description: Name of the bucket, or a path with optional prefix (t3://bucket/prefix/) + type: positional + required: true + examples: + - my-bucket + - t3://my-bucket/images/ + - name: prefix + description: Filter by key prefix + alias: p + - name: delimiter + description: Group keys sharing a common prefix up to the delimiter (e.g. "/" for folder-style grouping) + alias: d + - name: format + description: Output format + options: [json, table, xml] + default: table + - name: limit + description: Maximum number of items to return per page + - name: key-marker + description: Pagination marker — the key to start listing from (from a prior nextKeyMarker) + - name: version-id-marker + description: Pagination marker — the version id to start listing from (from a prior nextVersionIdMarker) # get - name: get description: Download an object by key. Prints to stdout by default, or saves to a file with --output @@ -1283,6 +1323,8 @@ commands: - name: snapshot-version description: Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) alias: snapshot + - name: version-id + description: Object version id to download (requires bucket versioning). Omit to download the latest version # put - name: put description: Upload a local file as an object. Content-type is auto-detected from extension unless overridden @@ -1330,12 +1372,14 @@ commands: default: table # delete - name: delete - description: Delete one or more objects by key from the given bucket + description: Delete one or more objects by key from the given bucket. On a versioned bucket, the default creates a delete marker; use --version-id or --all-versions to hard-delete versions alias: d examples: - "tigris objects delete my-bucket old-file.txt --yes" - "tigris objects delete t3://my-bucket/old-file.txt --yes" - "tigris objects delete my-bucket file-a.txt,file-b.txt --yes" + - "tigris objects delete my-bucket old-file.txt --version-id abc123 --yes" + - "tigris objects delete my-bucket old-file.txt --all-versions --yes" messages: onStart: 'Deleting object...' onSuccess: "Object '{{key}}' deleted successfully" @@ -1357,6 +1401,11 @@ commands: multiple: true examples: - my-file.txt + - name: version-id + description: Hard-delete a specific object version (requires bucket versioning). Targets a single key + - name: all-versions + description: Hard-delete every version and delete marker for the given key(s). Mutually exclusive with --version-id + type: flag - name: force type: flag description: Skip confirmation prompts (alias for --yes) @@ -1425,6 +1474,7 @@ commands: - "tigris objects info my-bucket report.pdf" - "tigris objects info t3://my-bucket/report.pdf" - "tigris objects info my-bucket report.pdf --format json" + - "tigris objects info my-bucket report.pdf --version-id abc123" messages: onStart: '' onSuccess: '' @@ -1444,6 +1494,8 @@ commands: - name: snapshot-version description: Read from a specific bucket snapshot alias: snapshot + - name: version-id + description: Object version id (requires bucket versioning). Omit to read the latest version ######################### # Manage access keys From 820e49eee9883ad95ffe573a1435117b5ed74dfe Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 14 May 2026 14:14:48 +0200 Subject: [PATCH 2/6] fix: paginate listVersions for --all-versions and fix list-versions hint `delete --all-versions` called `listVersions` only once per key. The API paginates (typical max 1000 entries per page), so any object with more versions than fit on a single page kept its older history after the operation. Walk every page until `hasMore` is false. `objects list-versions` reused the generic `printPaginationHint` helper, which suggests `--page-token`. The command actually paginates on a `(--key-marker, --version-id-marker)` pair. Replace with a custom hint that shows both markers and the correct flag names, only when `hasMore` is true. Addresses cursor bugbot review on #102. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/objects/delete.ts | 50 ++++++++++++++++++++++---------- src/lib/objects/list-versions.ts | 18 +++++++----- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index 210c8de..91ea5fb 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -69,22 +69,40 @@ export default async function deleteObject(options: Record) { const targets: Target[] = []; if (allVersions) { for (const key of keyList) { - const { data, error } = await listVersions({ - prefix: key, - config: bucketConfig, - }); - if (error) { - failWithError(context, error); - } - const matchingVersions = data.versions.filter((v) => v.name === key); - const matchingMarkers = data.deleteMarkers.filter((m) => m.name === key); - for (const v of matchingVersions) { - targets.push({ key, versionId: v.versionId }); - } - for (const m of matchingMarkers) { - targets.push({ key, versionId: m.versionId }); - } - if (matchingVersions.length === 0 && matchingMarkers.length === 0) { + let matched = 0; + let keyMarker: string | undefined; + let versionIdMarker: string | undefined; + // listVersions is paginated; walk every page so we don't + // leave older history behind on heavily-versioned keys. + do { + const { data, error } = await listVersions({ + prefix: key, + ...(keyMarker ? { keyMarker } : {}), + ...(versionIdMarker ? { versionIdMarker } : {}), + config: bucketConfig, + }); + if (error) { + failWithError(context, error); + } + // `prefix` is a loose filter — listVersions returns any key + // that starts with `key`. Exact-match before queueing for + // deletion so we don't nuke a sibling like `foo.txt.bak` + // when the user asked for `foo.txt`. + for (const v of data.versions) { + if (v.name !== key) continue; + targets.push({ key, versionId: v.versionId }); + matched++; + } + for (const m of data.deleteMarkers) { + if (m.name !== key) continue; + targets.push({ key, versionId: m.versionId }); + matched++; + } + keyMarker = data.hasMore ? data.nextKeyMarker : undefined; + versionIdMarker = data.hasMore ? data.nextVersionIdMarker : undefined; + } while (keyMarker); + + if (matched === 0) { failWithError( context, `No versions or delete markers found for key '${key}'` diff --git a/src/lib/objects/list-versions.ts b/src/lib/objects/list-versions.ts index bf4d058..47cfd25 100644 --- a/src/lib/objects/list-versions.ts +++ b/src/lib/objects/list-versions.ts @@ -8,13 +8,7 @@ import { formatXml, type TableColumn, } from '@utils/format.js'; -import { - msg, - printEmpty, - printPaginationHint, - printStart, - printSuccess, -} from '@utils/messages.js'; +import { msg, printEmpty, printStart, printSuccess } from '@utils/messages.js'; import { getFormat, getOption, getPaginationOptions } from '@utils/options.js'; import { parseAnyPath } from '@utils/path.js'; @@ -134,7 +128,15 @@ export default async function listObjectVersions( console.log('Delete Markers'); console.log(formatTable(deleteMarkerRows, deleteMarkerColumns)); } - printPaginationHint(data.nextKeyMarker); + if (data.hasMore && data.nextKeyMarker) { + // `list-versions` paginates on a (keyMarker, versionIdMarker) + // pair, not the single page-token the generic helper assumes. + let hint = `\nNext page: --key-marker "${data.nextKeyMarker}"`; + if (data.nextVersionIdMarker) { + hint += ` --version-id-marker "${data.nextVersionIdMarker}"`; + } + console.error(hint); + } } printSuccess(context, { From bc6501e5a3283c32f225d7f3585888349abcffb7 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 14 May 2026 14:32:06 +0200 Subject: [PATCH 3/6] fix: emit JSON on empty list-versions and early-exit --all-versions pagination - list-versions: --format json/xml now emits a valid empty response shape instead of falling through to the human-readable "empty" message, so `jq` consumers don't have to special-case no results. - objects delete --all-versions: stop paginating as soon as a returned key sorts past the target. Avoided issuing thousands of requests to walk every `a*` entry when the user asked to delete `a`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/objects/delete.ts | 25 +++++++++++++++++++------ src/lib/objects/list-versions.ts | 13 ++++++++----- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index 91ea5fb..56a82d3 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -74,6 +74,12 @@ export default async function deleteObject(options: Record) { let versionIdMarker: string | undefined; // listVersions is paginated; walk every page so we don't // leave older history behind on heavily-versioned keys. + // ListObjectVersions returns entries in (key asc, version-id + // desc) order. Once we see a key that sorts after the target, + // no later page can contain another match — bail out so we + // don't issue thousands of requests just to walk past every + // `a*` key when the user asked for `a`. + let pastTarget = false; do { const { data, error } = await listVersions({ prefix: key, @@ -89,15 +95,22 @@ export default async function deleteObject(options: Record) { // deletion so we don't nuke a sibling like `foo.txt.bak` // when the user asked for `foo.txt`. for (const v of data.versions) { - if (v.name !== key) continue; - targets.push({ key, versionId: v.versionId }); - matched++; + if (v.name === key) { + targets.push({ key, versionId: v.versionId }); + matched++; + } else if (v.name > key) { + pastTarget = true; + } } for (const m of data.deleteMarkers) { - if (m.name !== key) continue; - targets.push({ key, versionId: m.versionId }); - matched++; + if (m.name === key) { + targets.push({ key, versionId: m.versionId }); + matched++; + } else if (m.name > key) { + pastTarget = true; + } } + if (pastTarget) break; keyMarker = data.hasMore ? data.nextKeyMarker : undefined; versionIdMarker = data.hasMore ? data.nextVersionIdMarker : undefined; } while (keyMarker); diff --git a/src/lib/objects/list-versions.ts b/src/lib/objects/list-versions.ts index 47cfd25..a75b0a9 100644 --- a/src/lib/objects/list-versions.ts +++ b/src/lib/objects/list-versions.ts @@ -71,11 +71,6 @@ export default async function listObjectVersions( modified: m.lastModified, })); - if (versionRows.length === 0 && deleteMarkerRows.length === 0) { - printEmpty(context); - return; - } - const versionColumns: TableColumn[] = [ { key: 'key', header: 'Key' }, { key: 'versionId', header: 'Version ID' }, @@ -91,6 +86,10 @@ export default async function listObjectVersions( { key: 'modified', header: 'Modified' }, ]; + // JSON / XML always emit a valid response object — even when both + // arrays are empty — so downstream `jq` / parser consumers don't + // have to special-case the no-results path. The human-readable + // "empty" message only fires in table mode. if (format === 'json') { // Mirror the S3 ListObjectVersions response shape so downstream // `jq` users get the same ergonomics as `aws s3api`. @@ -120,6 +119,10 @@ export default async function listObjectVersions( lines.push(''); console.log(lines.join('\n')); } else { + if (versionRows.length === 0 && deleteMarkerRows.length === 0) { + printEmpty(context); + return; + } if (versionRows.length > 0) { console.log('\nVersions'); console.log(formatTable(versionRows, versionColumns)); From 8b0298a358add69b06c44df675cc929490be365b Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 14 May 2026 14:50:35 +0200 Subject: [PATCH 4/6] docs: simplify README to mirror help output Drops the manual Core / Auth / Other / Resources groupings in favour of a single top-level command table in specs order, matching `tigris help`. The detail sections recurse through resources and sub-resources (`buckets lifecycle`, `iam policies`, `iam users`), which also fixes the prior regression where introducing a nested sub-resource dropped the flag tables on its sibling leaf commands. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1142 ++++++++++++++++++++++------------------ scripts/update-docs.ts | 395 +++----------- 2 files changed, 710 insertions(+), 827 deletions(-) diff --git a/README.md b/README.md index 4b98b6a..e78cd60 100644 --- a/README.md +++ b/README.md @@ -22,587 +22,440 @@ tigris [flags] Run `tigris help` to see all available commands, or `tigris help` for details on a specific command. -### Core Commands +### Commands -- `tigris ls [path]` - List all buckets (no arguments) or objects under a bucket/prefix path. Accepts bare names or t3:// URIs -- `tigris mk ` - Create a bucket (bare name) or a folder inside a bucket (bucket/folder/ with trailing slash) -- `tigris touch ` - Create an empty (zero-byte) object at the given bucket/key path -- `tigris cp ` - Copy files between local filesystem and Tigris, or between paths within Tigris. At least one side must be a remote t3:// path -- `tigris mv ` - Move (rename) objects within Tigris. Both source and destination must be remote t3:// paths -- `tigris rm ` - Remove a bucket, folder, or object from Tigris. A bare bucket name deletes the bucket itself -- `tigris stat [path]` - Show storage stats (no args), bucket info, or object metadata -- `tigris presign ` - Generate a presigned URL for temporary access to an object without credentials -- `tigris bundle ` - Download multiple objects as a streaming tar archive in a single request. Designed for batch workloads that need many objects without per-object HTTP overhead - -### Authentication - -- `tigris login` - Start a session via OAuth (default) or temporary credentials. Session state is cleared on logout -- `tigris logout` - End the current session and clear login state. Credentials saved via 'configure' are kept -- `tigris whoami` - Print the currently authenticated user, organization, and auth method -- `tigris configure` - Save access-key credentials to ~/.tigris/config.json for persistent use across all commands - -### Other - -- `tigris update` - Update the CLI to the latest version - -### Resources - -- `tigris organizations` - List, create, and switch between organizations. An organization is a workspace that contains your resources like buckets and access keys -- `tigris access-keys` - Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access -- `tigris credentials` - Test whether your current credentials can reach Tigris and optionally verify access to a specific bucket -- `tigris buckets` - Create, inspect, update, and delete buckets. Buckets are top-level containers that hold objects -- `tigris forks` - (Deprecated, use "buckets create --fork-of" and "buckets list --forks-of") List and create forks -- `tigris snapshots` - List and take snapshots. A snapshot is a point-in-time, read-only copy of a bucket's state -- `tigris objects` - Low-level object operations for listing, downloading, uploading, and deleting individual objects in a bucket -- `tigris iam` - Identity and Access Management - manage policies, users, and permissions +| Command | Description | +|---------|-------------| +| `tigris configure` (c) | Save access-key credentials to ~/.tigris/config.json for persistent use across all commands | +| `tigris login` (l) | Start a session via OAuth (default) or temporary credentials. Session state is cleared on logout | +| `tigris whoami` (w) | Print the currently authenticated user, organization, and auth method | +| `tigris update` | Update the CLI to the latest version | +| `tigris logout` | End the current session and clear login state. Credentials saved via 'configure' are kept | +| `tigris credentials` (creds) | Test whether your current credentials can reach Tigris and optionally verify access to a specific bucket | +| `tigris ls` (list) | List all buckets (no arguments) or objects under a bucket/prefix path. Accepts bare names or t3:// URIs | +| `tigris mk` (create) | Create a bucket (bare name) or a folder inside a bucket (bucket/folder/ with trailing slash) | +| `tigris touch` | Create an empty (zero-byte) object at the given bucket/key path | +| `tigris stat` | Show storage stats (no args), bucket info, or object metadata | +| `tigris presign` | Generate a presigned URL for temporary access to an object without credentials | +| `tigris cp` (copy) | Copy files between local filesystem and Tigris, or between paths within Tigris. At least one side must be a remote t3:// path | +| `tigris mv` (move) | Move (rename) objects within Tigris. Both source and destination must be remote t3:// paths | +| `tigris rm` (remove) | Remove a bucket, folder, or object from Tigris. A bare bucket name deletes the bucket itself | +| `tigris bundle` | Download multiple objects as a streaming tar archive in a single request. Designed for batch workloads that need many objects without per-object HTTP overhead | +| `tigris organizations` (orgs) | List, create, and switch between organizations. An organization is a workspace that contains your resources like buckets and access keys | +| `tigris buckets` (b) | Create, inspect, update, and delete buckets. Buckets are top-level containers that hold objects | +| `tigris snapshots` (s) | List and take snapshots. A snapshot is a point-in-time, read-only copy of a bucket's state | +| `tigris objects` (o) | Low-level object operations for listing, downloading, uploading, and deleting individual objects in a bucket | +| `tigris access-keys` (keys) | Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access | +| `tigris iam` | Identity and Access Management - manage policies, users, and permissions | --- -## Core Commands - -### `ls` | `list` - -List all buckets (no arguments) or objects under a bucket/prefix path. Accepts bare names or t3:// URIs +### `tigris configure` (c) -``` -tigris ls [path] [flags] -``` - -| Flag | Description | -|------|-------------| -| `-snapshot, --snapshot-version` | Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) | -| `--format` | Output format | -| `--limit` | Maximum number of items to return per page | -| `-pt, --page-token` | Pagination token from a previous request to fetch the next page | -| `--source` | List objects from a specific storage source on buckets with shadow migration enabled | - -**Examples:** -```bash -tigris ls -tigris ls my-bucket -tigris ls my-bucket/images/ -tigris ls t3://my-bucket/prefix/ -``` - -### `mk` | `create` - -Create a bucket (bare name) or a folder inside a bucket (bucket/folder/ with trailing slash) +Save access-key credentials to ~/.tigris/config.json for persistent use across all commands ``` -tigris mk [flags] +tigris configure [flags] ``` | Flag | Description | |------|-------------| -| `-a, --access` | Access level (only applies when creating a bucket) | -| `--public` | Shorthand for --access public (only applies when creating a bucket) | -| `-s, --enable-snapshots` | Enable snapshots for the bucket (only applies when creating a bucket) | -| `-t, --default-tier` | Default storage tier (only applies when creating a bucket) | -| `-c, --consistency` | (Deprecated, use --locations) Consistency level (only applies when creating a bucket) | -| `-r, --region` | (Deprecated, use --locations) Region (only applies when creating a bucket) | -| `-l, --locations` | Location for the bucket (only applies when creating a bucket) | -| `-fork, --fork-of` | Create this bucket as a fork (copy-on-write clone) of the named source bucket | -| `-source-snap, --source-snapshot` | Fork from a specific snapshot of the source bucket. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464). Requires --fork-of | +| `-key, --access-key` | Your Tigris access key ID | +| `-secret, --access-secret` | Your Tigris secret access key | +| `-e, --endpoint` | Tigris API endpoint (default: https://t3.storage.dev) | **Examples:** ```bash -tigris mk my-bucket -tigris mk my-bucket --access public --region iad -tigris mk my-bucket/images/ -tigris mk t3://my-bucket -tigris mk my-fork --fork-of my-bucket -tigris mk my-fork --fork-of my-bucket --source-snapshot 1765889000501544464 +tigris configure --access-key tid_AaBb --access-secret tsec_XxYy +tigris configure --endpoint https://custom.endpoint.dev ``` -### `touch` - -Create an empty (zero-byte) object at the given bucket/key path +### `tigris login` (l) -``` -tigris touch -``` +Start a session via OAuth (default) or temporary credentials. Session state is cleared on logout -**Examples:** -```bash -tigris touch my-bucket/placeholder.txt -tigris touch t3://my-bucket/logs/ -``` +| Command | Description | +|---------|-------------| +| `tigris login select` | Choose how to login - OAuth (browser) or credentials (access key) | +| `tigris login oauth` (o) | Login via browser using OAuth2 device flow. Best for interactive use | +| `tigris login credentials` (c) | Login with an access key and secret. Creates a temporary session that is cleared on logout | -### `cp` | `copy` +#### `tigris login select` -Copy files between local filesystem and Tigris, or between paths within Tigris. At least one side must be a remote t3:// path +Choose how to login - OAuth (browser) or credentials (access key) ``` -tigris cp [flags] -``` - -| Flag | Description | -|------|-------------| -| `-r, --recursive` | Copy directories recursively | - -**Examples:** -```bash -tigris cp ./file.txt t3://my-bucket/file.txt -tigris cp t3://my-bucket/file.txt ./local-copy.txt -tigris cp t3://my-bucket/src/ t3://my-bucket/dest/ -r -tigris cp ./images/ t3://my-bucket/images/ -r +tigris login select ``` -### `mv` | `move` +#### `tigris login oauth` (o) -Move (rename) objects within Tigris. Both source and destination must be remote t3:// paths +Login via browser using OAuth2 device flow. Best for interactive use ``` -tigris mv [flags] +tigris login oauth ``` -| Flag | Description | -|------|-------------| -| `-r, --recursive` | Move directories recursively | -| `-f, --force` | Skip confirmation prompts (alias for --yes) | - **Examples:** ```bash -tigris mv t3://my-bucket/old.txt t3://my-bucket/new.txt -f -tigris mv t3://my-bucket/old-dir/ t3://my-bucket/new-dir/ -rf -tigris mv my-bucket/a.txt my-bucket/b.txt -f +tigris login oauth ``` -### `rm` | `remove` +#### `tigris login credentials` (c) -Remove a bucket, folder, or object from Tigris. A bare bucket name deletes the bucket itself +Login with an access key and secret. Creates a temporary session that is cleared on logout ``` -tigris rm [flags] +tigris login credentials [flags] ``` | Flag | Description | |------|-------------| -| `-r, --recursive` | Remove directories recursively | -| `-f, --force` | Skip confirmation prompts (alias for --yes) | +| `-key, --access-key` | Your access key ID (will prompt if not provided) | +| `-secret, --access-secret` | Your secret access key (will prompt if not provided) | **Examples:** ```bash -tigris rm t3://my-bucket/file.txt -f -tigris rm t3://my-bucket/folder/ -rf -tigris rm t3://my-bucket -f -tigris rm "t3://my-bucket/logs/*.tmp" -f +tigris login credentials --access-key tid_AaBb --access-secret tsec_XxYy +tigris login credentials ``` -### `stat` +### `tigris whoami` (w) -Show storage stats (no args), bucket info, or object metadata +Print the currently authenticated user, organization, and auth method ``` -tigris stat [path] [flags] +tigris whoami ``` -| Flag | Description | -|------|-------------| -| `--format` | Output format | -| `-snapshot, --snapshot-version` | Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) | - **Examples:** ```bash -tigris stat -tigris stat t3://my-bucket -tigris stat t3://my-bucket/my-object.json +tigris whoami ``` -### `presign` +### `tigris update` -Generate a presigned URL for temporary access to an object without credentials +Update the CLI to the latest version ``` -tigris presign [flags] +tigris update ``` -| Flag | Description | -|------|-------------| -| `-m, --method` | HTTP method for the presigned URL | -| `-e, --expires-in` | URL expiry time in seconds | -| `--access-key` | Access key ID to use for signing. If not provided, resolved from credentials or auto-selected | -| `--select` | Interactively select an access key (OAuth only) | -| `--format` | Output format | - **Examples:** ```bash -tigris presign my-bucket/file.txt -tigris presign t3://my-bucket/report.pdf --method put --expires-in 7200 -tigris presign my-bucket/image.png --format json -tigris presign my-bucket/data.csv --access-key tid_AaBb +tigris update ``` -### `bundle` +### `tigris logout` -Download multiple objects as a streaming tar archive in a single request. Designed for batch workloads that need many objects without per-object HTTP overhead +End the current session and clear login state. Credentials saved via 'configure' are kept ``` -tigris bundle [flags] +tigris logout ``` -| Flag | Description | -|------|-------------| -| `-k, --keys` | Comma-separated object keys, or path to a file with one key per line. If a local file matching the value exists, it is read as a keys file. If omitted, reads keys from stdin | -| `-o, --output` | Output file path. Defaults to stdout (for piping) | -| `--compression` | Compression algorithm for the archive. Auto-detected from output file extension when not specified | -| `--on-error` | How to handle missing objects. 'skip' omits them, 'fail' aborts the request | - **Examples:** ```bash -tigris bundle my-bucket --keys key1.jpg,key2.jpg --output archive.tar -tigris bundle my-bucket --keys keys.txt --output archive.tar -tigris bundle t3://my-bucket --keys keys.txt --compression gzip -o archive.tar.gz -cat keys.txt | tigris bundle my-bucket > archive.tar +tigris logout ``` -## Authentication +### `tigris credentials` (creds) -### `login` | `l` - -Start a session via OAuth (default) or temporary credentials. Session state is cleared on logout +Test whether your current credentials can reach Tigris and optionally verify access to a specific bucket | Command | Description | |---------|-------------| -| `login select` | Choose how to login - OAuth (browser) or credentials (access key) | -| `login oauth` (o) | Login via browser using OAuth2 device flow. Best for interactive use | -| `login credentials` (c) | Login with an access key and secret. Creates a temporary session that is cleared on logout | +| `tigris credentials test` (t) | Verify that current credentials are valid. Optionally checks access to a specific bucket | -#### `login select` +#### `tigris credentials test` (t) -``` -tigris login select -``` - -#### `login oauth` +Verify that current credentials are valid. Optionally checks access to a specific bucket ``` -tigris login oauth +tigris credentials test [flags] ``` +| Flag | Description | +|------|-------------| +| `-b, --bucket` | Bucket name to test access against (optional) | + **Examples:** ```bash -tigris login oauth +tigris credentials test +tigris credentials test --bucket my-bucket ``` -#### `login credentials` +### `tigris ls` (list) + +List all buckets (no arguments) or objects under a bucket/prefix path. Accepts bare names or t3:// URIs ``` -tigris login credentials [flags] +tigris ls [path] [flags] ``` | Flag | Description | |------|-------------| -| `-key, --access-key` | Your access key ID (will prompt if not provided) | -| `-secret, --access-secret` | Your secret access key (will prompt if not provided) | +| `-snapshot, --snapshot-version` | Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) | +| `--format` | Output format (default: table) | +| `--limit` | Maximum number of items to return per page | +| `-pt, --page-token` | Pagination token from a previous request to fetch the next page | +| `--source` | List objects from a specific storage source on buckets with shadow migration enabled | **Examples:** ```bash -tigris login credentials --access-key tid_AaBb --access-secret tsec_XxYy -tigris login credentials +tigris ls +tigris ls my-bucket +tigris ls my-bucket/images/ +tigris ls t3://my-bucket/prefix/ ``` -### `logout` +### `tigris mk` (create) -End the current session and clear login state. Credentials saved via 'configure' are kept +Create a bucket (bare name) or a folder inside a bucket (bucket/folder/ with trailing slash) ``` -tigris logout +tigris mk [flags] ``` +| Flag | Description | +|------|-------------| +| `-a, --access` | Access level (only applies when creating a bucket) (default: private) | +| `--public` | Shorthand for --access public (only applies when creating a bucket) | +| `-s, --enable-snapshots` | Enable snapshots for the bucket (only applies when creating a bucket) (default: false) | +| `-t, --default-tier` | Default storage tier (only applies when creating a bucket) (default: STANDARD) | +| `-l, --locations` | Location for the bucket (only applies when creating a bucket) (default: global) | +| `-fork, --fork-of` | Create this bucket as a fork (copy-on-write clone) of the named source bucket | +| `-source-snap, --source-snapshot` | Fork from a specific snapshot of the source bucket. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464). Requires --fork-of | + **Examples:** ```bash -tigris logout +tigris mk my-bucket +tigris mk my-bucket --access public --region iad +tigris mk my-bucket/images/ +tigris mk t3://my-bucket +tigris mk my-fork --fork-of my-bucket +tigris mk my-fork --fork-of my-bucket --source-snapshot 1765889000501544464 ``` -### `whoami` | `w` +### `tigris touch` -Print the currently authenticated user, organization, and auth method +Create an empty (zero-byte) object at the given bucket/key path ``` -tigris whoami +tigris touch ``` **Examples:** ```bash -tigris whoami +tigris touch my-bucket/placeholder.txt +tigris touch t3://my-bucket/logs/ ``` -### `configure` | `c` +### `tigris stat` -Save access-key credentials to ~/.tigris/config.json for persistent use across all commands +Show storage stats (no args), bucket info, or object metadata ``` -tigris configure [flags] +tigris stat [path] [flags] ``` | Flag | Description | |------|-------------| -| `-key, --access-key` | Your Tigris access key ID | -| `-secret, --access-secret` | Your Tigris secret access key | -| `-e, --endpoint` | Tigris API endpoint (default: https://t3.storage.dev) | +| `--format` | Output format (default: table) | +| `-snapshot, --snapshot-version` | Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) | +| `--version-id` | Object version id to stat (requires bucket versioning). Omit to stat the latest version | **Examples:** ```bash -tigris configure --access-key tid_AaBb --access-secret tsec_XxYy -tigris configure --endpoint https://custom.endpoint.dev +tigris stat +tigris stat t3://my-bucket +tigris stat t3://my-bucket/my-object.json ``` -## Resources - -### `organizations` | `orgs` - -List, create, and switch between organizations. An organization is a workspace that contains your resources like buckets and access keys - -| Command | Description | -|---------|-------------| -| `organizations list` (l) | List all organizations you belong to and interactively select one as active | -| `organizations create` (c) | Create a new organization with the given name | -| `organizations select` (s) | Set the named organization as your active org for all subsequent commands | +### `tigris presign` -#### `organizations list` +Generate a presigned URL for temporary access to an object without credentials ``` -tigris organizations list [flags] +tigris presign [flags] ``` | Flag | Description | |------|-------------| -| `--format` | Output format (default: select) | -| `-i, --select` | Interactive selection mode | - -**Examples:** -```bash -tigris orgs list -tigris orgs list --format json -``` - -#### `organizations create` - -``` -tigris organizations create -``` - -**Examples:** -```bash -tigris orgs create my-org -``` - -#### `organizations select` - -``` -tigris organizations select -``` +| `-m, --method` | HTTP method for the presigned URL (default: get) | +| `-e, --expires-in` | URL expiry time in seconds (default: 3600) | +| `--access-key` | Access key ID to use for signing. If not provided, resolved from credentials or auto-selected | +| `--select` | Interactively select an access key (OAuth only) | +| `--format` | Output format (default: url) | **Examples:** ```bash -tigris orgs select my-org +tigris presign my-bucket/file.txt +tigris presign t3://my-bucket/report.pdf --method put --expires-in 7200 +tigris presign my-bucket/image.png --format json +tigris presign my-bucket/data.csv --access-key tid_AaBb ``` -### `access-keys` | `keys` - -Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access +### `tigris cp` (copy) -| Command | Description | -|---------|-------------| -| `access-keys list` (l) | List all access keys in the current organization | -| `access-keys create` (c) | Create a new access key with the given name. Returns the key ID and secret (shown only once) | -| `access-keys delete` (d) | Permanently delete an access key by its ID. This revokes all access immediately | -| `access-keys get` (g) | Show details for an access key including its name, creation date, and assigned bucket roles | -| `access-keys assign` (a) | Assign per-bucket roles to an access key. Pair each --bucket with a --role (Editor or ReadOnly), or use --admin for org-wide access | -| `access-keys rotate` (r) | Rotate an access key's secret. The current secret is immediately invalidated and a new one is returned (shown only once) | -| `access-keys attach-policy` (ap) | Attach an IAM policy to an access key. If no policy ARN is provided, shows interactive selection of available policies | -| `access-keys detach-policy` (dp) | Detach an IAM policy from an access key. If no policy ARN is provided, shows interactive selection of attached policies | -| `access-keys list-policies` (lp) | List all IAM policies attached to an access key | - -#### `access-keys list` +Copy files between local filesystem and Tigris, or between paths within Tigris. At least one side must be a remote t3:// path ``` -tigris access-keys list [flags] +tigris cp [flags] ``` | Flag | Description | |------|-------------| -| `--format` | Output format (default: table) | -| `--limit` | Maximum number of items to return per page | -| `-pt, --page-token` | Pagination token from a previous request to fetch the next page | +| `-r, --recursive` | Copy directories recursively | **Examples:** ```bash -tigris access-keys list -``` - -#### `access-keys create` - -``` -tigris access-keys create +tigris cp ./file.txt t3://my-bucket/file.txt +tigris cp t3://my-bucket/file.txt ./local-copy.txt +tigris cp t3://my-bucket/src/ t3://my-bucket/dest/ -r +tigris cp ./images/ t3://my-bucket/images/ -r ``` -**Examples:** -```bash -tigris access-keys create my-ci-key -``` +### `tigris mv` (move) -#### `access-keys delete` +Move (rename) objects within Tigris. Both source and destination must be remote t3:// paths ``` -tigris access-keys delete [flags] +tigris mv [flags] ``` | Flag | Description | |------|-------------| -| `--force` | Skip confirmation prompts (alias for --yes) | +| `-r, --recursive` | Move directories recursively | +| `-f, --force` | Skip confirmation prompts (alias for --yes) | **Examples:** ```bash -tigris access-keys delete tid_AaBbCcDdEeFf --yes -``` - -#### `access-keys get` - -``` -tigris access-keys get +tigris mv t3://my-bucket/old.txt t3://my-bucket/new.txt -f +tigris mv t3://my-bucket/old-dir/ t3://my-bucket/new-dir/ -rf +tigris mv my-bucket/a.txt my-bucket/b.txt -f ``` -**Examples:** -```bash -tigris access-keys get tid_AaBbCcDdEeFf -``` +### `tigris rm` (remove) -#### `access-keys assign` +Remove a bucket, folder, or object from Tigris. A bare bucket name deletes the bucket itself ``` -tigris access-keys assign [flags] +tigris rm [flags] ``` | Flag | Description | |------|-------------| -| `-b, --bucket` | Bucket name (can specify multiple, comma-separated). Each bucket is paired positionally with a --role value | -| `-r, --role` | Role to assign (can specify multiple, comma-separated). Each role pairs with the corresponding --bucket value | -| `--admin` | Grant admin access to all buckets in the organization | -| `--revoke-roles` | Revoke all bucket roles from the access key | +| `-r, --recursive` | Remove directories recursively | +| `-f, --force` | Skip confirmation prompts (alias for --yes) | **Examples:** ```bash -tigris access-keys assign tid_AaBb --bucket my-bucket --role Editor -tigris access-keys assign tid_AaBb --bucket a,b --role Editor,ReadOnly -tigris access-keys assign tid_AaBb --admin -tigris access-keys assign tid_AaBb --revoke-roles +tigris rm t3://my-bucket/file.txt -f +tigris rm t3://my-bucket/folder/ -rf +tigris rm t3://my-bucket -f +tigris rm "t3://my-bucket/logs/*.tmp" -f ``` -#### `access-keys rotate` +### `tigris bundle` + +Download multiple objects as a streaming tar archive in a single request. Designed for batch workloads that need many objects without per-object HTTP overhead ``` -tigris access-keys rotate [flags] +tigris bundle [flags] ``` | Flag | Description | |------|-------------| -| `--force` | Skip confirmation prompts (alias for --yes) | +| `-k, --keys` | Comma-separated object keys, or path to a file with one key per line. If a local file matching the value exists, it is read as a keys file. If omitted, reads keys from stdin | +| `-o, --output` | Output file path. Defaults to stdout (for piping) | +| `--compression` | Compression algorithm for the archive. Auto-detected from output file extension when not specified | +| `--on-error` | How to handle missing objects. 'skip' omits them, 'fail' aborts the request (default: skip) | **Examples:** ```bash -tigris access-keys rotate tid_AaBbCcDdEeFf --yes +tigris bundle my-bucket --keys key1.jpg,key2.jpg --output archive.tar +tigris bundle my-bucket --keys keys.txt --output archive.tar +tigris bundle t3://my-bucket --keys keys.txt --compression gzip -o archive.tar.gz +cat keys.txt | tigris bundle my-bucket > archive.tar ``` -#### `access-keys attach-policy` +### `tigris organizations` (orgs) -``` -tigris access-keys attach-policy [flags] -``` +List, create, and switch between organizations. An organization is a workspace that contains your resources like buckets and access keys -| Flag | Description | -|------|-------------| -| `--policy-arn` | ARN of the policy to attach | +| Command | Description | +|---------|-------------| +| `tigris organizations list` (l) | List all organizations you belong to and interactively select one as active | +| `tigris organizations create` (c) | Create a new organization with the given name | +| `tigris organizations select` (s) | Set the named organization as your active org for all subsequent commands | -**Examples:** -```bash -tigris access-keys attach-policy tid_AaBb --policy-arn arn:aws:iam::org_id:policy/my-policy -tigris access-keys attach-policy tid_AaBb -``` +#### `tigris organizations list` (l) -#### `access-keys detach-policy` +List all organizations you belong to and interactively select one as active ``` -tigris access-keys detach-policy [flags] +tigris organizations list [flags] ``` | Flag | Description | |------|-------------| -| `--policy-arn` | ARN of the policy to detach | -| `--force` | Skip confirmation prompts (alias for --yes) | +| `--format` | Output format (default: select) | +| `-i, --select` | Interactive selection mode | **Examples:** ```bash -tigris access-keys detach-policy tid_AaBb --policy-arn arn:aws:iam::org_id:policy/my-policy --yes -tigris access-keys detach-policy tid_AaBb +tigris orgs list +tigris orgs list --format json ``` -#### `access-keys list-policies` +#### `tigris organizations create` (c) + +Create a new organization with the given name ``` -tigris access-keys list-policies [flags] +tigris organizations create ``` -| Flag | Description | -|------|-------------| -| `--format` | Output format (default: table) | -| `--limit` | Maximum number of items to return per page | -| `-pt, --page-token` | Pagination token from a previous request to fetch the next page | - **Examples:** ```bash -tigris access-keys list-policies tid_AaBbCcDdEeFf +tigris orgs create my-org ``` -### `credentials` | `creds` +#### `tigris organizations select` (s) -Test whether your current credentials can reach Tigris and optionally verify access to a specific bucket - -| Command | Description | -|---------|-------------| -| `credentials test` (t) | Verify that current credentials are valid. Optionally checks access to a specific bucket | - -#### `credentials test` +Set the named organization as your active org for all subsequent commands ``` -tigris credentials test [flags] +tigris organizations select ``` -| Flag | Description | -|------|-------------| -| `-b, --bucket` | Bucket name to test access against (optional) | - **Examples:** ```bash -tigris credentials test -tigris credentials test --bucket my-bucket +tigris orgs select my-org ``` -### Buckets - -Buckets are containers for objects. You can also create forks and snapshots of buckets. - -#### `buckets` | `b` +### `tigris buckets` (b) Create, inspect, update, and delete buckets. Buckets are top-level containers that hold objects | Command | Description | |---------|-------------| -| `buckets list` (l) | List all buckets in the current organization | -| `buckets create` (c) | Create a new bucket with optional access, tier, and location settings | -| `buckets get` (g) | Show details for a bucket including access level, region, tier, and custom domain | -| `buckets delete` (d) | Delete one or more buckets by name. The bucket must be empty or delete-protection must be off | -| `buckets set` (s) | Update settings on an existing bucket such as access level, location, caching, or custom domain | -| `buckets set-ttl` | Configure object expiration (TTL) on a bucket. Objects expire after a number of days or on a specific date | -| `buckets set-locations` | Set the data locations for a bucket | -| `buckets set-migration` | Configure data migration from an external S3-compatible source bucket. Tigris will pull objects on demand from the source | -| `buckets migrate` | Actively migrate all objects from a shadow bucket to Tigris by scheduling server-side migration for unmigrated objects | -| `buckets set-transition` | Configure a lifecycle transition rule on a bucket. Automatically move objects to a different storage class after a number of days or on a specific date | -| `buckets set-notifications` | Configure object event notifications on a bucket. Sends webhook requests to a URL when objects are created, updated, or deleted | -| `buckets set-cors` | Configure CORS rules on a bucket. Each invocation adds a rule unless --override or --reset is used | - -##### `buckets list` +| `tigris buckets list` (l) | List all buckets in the current organization | +| `tigris buckets create` (c) | Create a new bucket with optional access, tier, and location settings | +| `tigris buckets get` (g) | Show details for a bucket including access level, region, tier, and custom domain | +| `tigris buckets delete` (d) | Delete one or more buckets by name. The bucket must be empty or delete-protection must be off | +| `tigris buckets set` (s) | Update settings on an existing bucket such as access level, location, caching, or custom domain | +| `tigris buckets set-locations` | Set the data locations for a bucket | +| `tigris buckets set-migration` | Configure data migration from an external S3-compatible source bucket. Tigris will pull objects on demand from the source | +| `tigris buckets migrate` | Actively migrate all objects from a shadow bucket to Tigris by scheduling server-side migration for unmigrated objects | +| `tigris buckets lifecycle` (lc) | Manage bucket lifecycle rules. Each rule combines an optional storage-class transition and/or expiration (TTL), scoped to an optional key prefix | +| `tigris buckets set-notifications` | Configure object event notifications on a bucket. Sends webhook requests to a URL when objects are created, updated, or deleted | +| `tigris buckets set-cors` | Configure CORS rules on a bucket. Each invocation adds a rule unless --override or --reset is used | + +#### `tigris buckets list` (l) + +List all buckets in the current organization ``` tigris buckets list [flags] @@ -622,7 +475,9 @@ tigris buckets list --format json tigris buckets list --forks-of my-bucket ``` -##### `buckets create` +#### `tigris buckets create` (c) + +Create a new bucket with optional access, tier, and location settings ``` tigris buckets create [name] [flags] @@ -634,8 +489,6 @@ tigris buckets create [name] [flags] | `--public` | Shorthand for --access public | | `-s, --enable-snapshots` | Enable snapshots for the bucket (default: false) | | `-t, --default-tier` | Choose the default tier for the bucket (default: STANDARD) | -| `-c, --consistency` | (Deprecated, use --locations) Choose the consistency level for the bucket | -| `-r, --region` | (Deprecated, use --locations) Region | | `-l, --locations` | Location for the bucket (default: global) | | `-fork, --fork-of` | Create this bucket as a fork (copy-on-write clone) of the named source bucket | | `-source-snap, --source-snapshot` | Fork from a specific snapshot of the source bucket. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464). Requires --fork-of | @@ -649,7 +502,9 @@ tigris buckets create my-fork --fork-of my-bucket tigris buckets create my-fork --fork-of my-bucket --source-snapshot 1765889000501544464 ``` -##### `buckets get` +#### `tigris buckets get` (g) + +Show details for a bucket including access level, region, tier, and custom domain ``` tigris buckets get [flags] @@ -664,7 +519,9 @@ tigris buckets get [flags] tigris buckets get my-bucket ``` -##### `buckets delete` +#### `tigris buckets delete` (d) + +Delete one or more buckets by name. The bucket must be empty or delete-protection must be off ``` tigris buckets delete [flags] @@ -680,7 +537,9 @@ tigris buckets delete my-bucket --yes tigris buckets delete bucket-a,bucket-b --yes ``` -##### `buckets set` +#### `tigris buckets set` (s) + +Update settings on an existing bucket such as access level, location, caching, or custom domain ``` tigris buckets set [flags] @@ -689,7 +548,6 @@ tigris buckets set [flags] | Flag | Description | |------|-------------| | `--access` | Bucket access level | -| `--region` | (Deprecated, use --locations) Allowed regions (can specify multiple) | | `--locations` | Bucket location (see https://www.tigrisdata.com/docs/buckets/locations/ for more details) | | `--allow-object-acl` | Enable object-level ACL | | `--disable-directory-listing` | Disable directory listing | @@ -705,27 +563,9 @@ tigris buckets set my-bucket --locations iad,fra --cache-control 'max-age=3600' tigris buckets set my-bucket --custom-domain assets.example.com ``` -##### `buckets set-ttl` +#### `tigris buckets set-locations` -``` -tigris buckets set-ttl [flags] -``` - -| Flag | Description | -|------|-------------| -| `-d, --days` | Expire objects after this many days | -| `--date` | Expire objects on this date (ISO-8601, e.g. 2026-06-01) | -| `--enable` | Enable TTL on the bucket (uses existing lifecycle rules) | -| `--disable` | Disable TTL on the bucket | - -**Examples:** -```bash -tigris buckets set-ttl my-bucket --days 30 -tigris buckets set-ttl my-bucket --date 2026-06-01 -tigris buckets set-ttl my-bucket --disable -``` - -##### `buckets set-locations` +Set the data locations for a bucket ``` tigris buckets set-locations [flags] @@ -742,7 +582,9 @@ tigris buckets set-locations my-bucket --locations iad,fra tigris buckets set-locations my-bucket --locations global ``` -##### `buckets set-migration` +#### `tigris buckets set-migration` + +Configure data migration from an external S3-compatible source bucket. Tigris will pull objects on demand from the source ``` tigris buckets set-migration [flags] @@ -760,47 +602,109 @@ tigris buckets set-migration [flags] **Examples:** ```bash -tigris buckets set-migration my-bucket --bucket source-bucket --endpoint https://s3.amazonaws.com --region us-east-1 --access-key AKIA... --secret-key wJal... -tigris buckets set-migration my-bucket --bucket source-bucket --endpoint https://s3.amazonaws.com --region us-east-1 --access-key AKIA... --secret-key wJal... --write-through -tigris buckets set-migration my-bucket --disable +tigris buckets set-migration my-bucket --bucket source-bucket --endpoint https://s3.amazonaws.com --region us-east-1 --access-key AKIA... --secret-key wJal... +tigris buckets set-migration my-bucket --bucket source-bucket --endpoint https://s3.amazonaws.com --region us-east-1 --access-key AKIA... --secret-key wJal... --write-through +tigris buckets set-migration my-bucket --disable +``` + +#### `tigris buckets migrate` + +Actively migrate all objects from a shadow bucket to Tigris by scheduling server-side migration for unmigrated objects + +``` +tigris buckets migrate +``` + +**Examples:** +```bash +tigris buckets migrate my-bucket +tigris buckets migrate my-bucket/images/ +tigris buckets migrate t3://my-bucket/prefix/ +``` + +#### `tigris buckets lifecycle` (lc) + +Manage bucket lifecycle rules. Each rule combines an optional storage-class transition and/or expiration (TTL), scoped to an optional key prefix + +| Command | Description | +|---------|-------------| +| `tigris buckets lifecycle list` (l) | List lifecycle rules on a bucket | +| `tigris buckets lifecycle create` (c) | Create a new lifecycle rule. A rule must include a transition (--storage-class with --days or --date) and/or an expiration (--expire-days or --expire-date), and may optionally be scoped via --prefix | +| `tigris buckets lifecycle edit` (e) | Edit an existing lifecycle rule by its id. Only specified fields are changed | + +##### `tigris buckets lifecycle list` (l) + +List lifecycle rules on a bucket + +``` +tigris buckets lifecycle list [flags] +``` + +| Flag | Description | +|------|-------------| +| `--format` | Output format (default: table) | + +**Examples:** +```bash +tigris buckets lifecycle list my-bucket +tigris buckets lifecycle list my-bucket --json ``` -##### `buckets migrate` +##### `tigris buckets lifecycle create` (c) + +Create a new lifecycle rule. A rule must include a transition (--storage-class with --days or --date) and/or an expiration (--expire-days or --expire-date), and may optionally be scoped via --prefix ``` -tigris buckets migrate +tigris buckets lifecycle create [flags] ``` +| Flag | Description | +|------|-------------| +| `-p, --prefix` | Key prefix to scope the rule to. Omit for a bucket-wide rule | +| `-s, --storage-class` | Target storage class for the transition | +| `-d, --days` | Transition objects after this many days (used with --storage-class) | +| `--date` | Transition objects on this date (ISO-8601, e.g. 2026-06-01) (used with --storage-class) | +| `--expire-days` | Expire (delete) objects after this many days | +| `--expire-date` | Expire (delete) objects on this date (ISO-8601, e.g. 2026-06-01) | +| `--disable` | Create the rule in a disabled state | + **Examples:** ```bash -tigris buckets migrate my-bucket -tigris buckets migrate my-bucket/images/ -tigris buckets migrate t3://my-bucket/prefix/ +tigris buckets lifecycle create my-bucket --storage-class STANDARD_IA --days 30 +tigris buckets lifecycle create my-bucket --prefix logs/ --storage-class GLACIER --days 90 +tigris buckets lifecycle create my-bucket --prefix tmp/ --expire-days 7 +tigris buckets lifecycle create my-bucket --prefix archive/ --storage-class GLACIER --days 30 --expire-days 365 ``` -##### `buckets set-transition` +##### `tigris buckets lifecycle edit` (e) + +Edit an existing lifecycle rule by its id. Only specified fields are changed ``` -tigris buckets set-transition [flags] +tigris buckets lifecycle edit [flags] ``` | Flag | Description | |------|-------------| -| `-s, --storage-class` | Target storage class to transition objects to | -| `-d, --days` | Transition objects after this many days | -| `--date` | Transition objects on this date (ISO-8601, e.g. 2026-06-01) | -| `--enable` | Enable lifecycle transition rules on the bucket | -| `--disable` | Disable lifecycle transition rules on the bucket | +| `-p, --prefix` | Replace the rule's key prefix | +| `-s, --storage-class` | Replace the rule's transition target | +| `-d, --days` | Replace the rule's transition days | +| `--date` | Replace the rule's transition date (ISO-8601) | +| `--expire-days` | Replace the rule's expiration days | +| `--expire-date` | Replace the rule's expiration date (ISO-8601) | +| `--enable` | Enable the rule | +| `--disable` | Disable the rule (does not remove it) | **Examples:** ```bash -tigris buckets set-transition my-bucket --storage-class STANDARD_IA --days 30 -tigris buckets set-transition my-bucket --storage-class GLACIER --date 2026-06-01 -tigris buckets set-transition my-bucket --enable -tigris buckets set-transition my-bucket --disable +tigris buckets lifecycle edit my-bucket abc123 --days 60 +tigris buckets lifecycle edit my-bucket abc123 --expire-days 90 +tigris buckets lifecycle edit my-bucket abc123 --enable ``` -##### `buckets set-notifications` +#### `tigris buckets set-notifications` + +Configure object event notifications on a bucket. Sends webhook requests to a URL when objects are created, updated, or deleted ``` tigris buckets set-notifications [flags] @@ -828,7 +732,9 @@ tigris buckets set-notifications my-bucket --disable tigris buckets set-notifications my-bucket --reset ``` -##### `buckets set-cors` +#### `tigris buckets set-cors` + +Configure CORS rules on a bucket. Each invocation adds a rule unless --override or --reset is used ``` tigris buckets set-cors [flags] @@ -852,57 +758,18 @@ tigris buckets set-cors my-bucket --origins https://example.com --override tigris buckets set-cors my-bucket --reset ``` -#### `forks` | `f` - -(Deprecated, use "buckets create --fork-of" and "buckets list --forks-of") List and create forks - -| Command | Description | -|---------|-------------| -| `forks list` (l) | (Deprecated, use "buckets list --forks-of") List all forks created from the given source bucket | -| `forks create` (c) | (Deprecated, use "buckets create --fork-of") Create a new fork (copy-on-write clone) of the source bucket | - -##### `forks list` - -``` -tigris forks list [flags] -``` - -| Flag | Description | -|------|-------------| -| `--format` | Output format (default: table) | - -**Examples:** -```bash -tigris forks list my-bucket -tigris forks list my-bucket --format json -``` - -##### `forks create` - -``` -tigris forks create [flags] -``` - -| Flag | Description | -|------|-------------| -| `-s, --snapshot` | Create fork from a specific snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) | - -**Examples:** -```bash -tigris forks create my-bucket my-fork -tigris forks create my-bucket my-fork --snapshot 1765889000501544464 -``` - -#### `snapshots` | `s` +### `tigris snapshots` (s) List and take snapshots. A snapshot is a point-in-time, read-only copy of a bucket's state | Command | Description | |---------|-------------| -| `snapshots list` (l) | List all snapshots for the given bucket, ordered by creation time | -| `snapshots take` (t) | Take a new snapshot of the bucket's current state. Optionally provide a name for the snapshot | +| `tigris snapshots list` (l) | List all snapshots for the given bucket, ordered by creation time | +| `tigris snapshots take` (t) | Take a new snapshot of the bucket's current state. Optionally provide a name for the snapshot | + +#### `tigris snapshots list` (l) -##### `snapshots list` +List all snapshots for the given bucket, ordered by creation time ``` tigris snapshots list [flags] @@ -920,7 +787,9 @@ tigris snapshots list my-bucket tigris snapshots list my-bucket --format json ``` -##### `snapshots take` +#### `tigris snapshots take` (t) + +Take a new snapshot of the bucket's current state. Optionally provide a name for the snapshot ``` tigris snapshots take [snapshot-name] @@ -932,20 +801,24 @@ tigris snapshots take my-bucket tigris snapshots take my-bucket my-snapshot ``` -### `objects` | `o` +### `tigris objects` (o) Low-level object operations for listing, downloading, uploading, and deleting individual objects in a bucket | Command | Description | |---------|-------------| -| `objects list` (l) | List objects in a bucket, optionally filtered by a key prefix | -| `objects get` (g) | Download an object by key. Prints to stdout by default, or saves to a file with --output | -| `objects put` (p) | Upload a local file as an object. Content-type is auto-detected from extension unless overridden | -| `objects delete` (d) | Delete one or more objects by key from the given bucket | -| `objects set` (s) | Update settings on an existing object such as access level | -| `objects info` (i) | Show metadata for an object (content type, size, modified date) | +| `tigris objects list` (l) | List objects in a bucket, optionally filtered by a key prefix | +| `tigris objects list-versions` (lv) | List object versions and delete markers in a bucket (requires bucket versioning). Returns both arrays separately to match the S3 ListObjectVersions response | +| `tigris objects get` (g) | Download an object by key. Prints to stdout by default, or saves to a file with --output | +| `tigris objects put` (p) | Upload a local file as an object. Content-type is auto-detected from extension unless overridden | +| `tigris objects delete` (d) | Delete one or more objects by key from the given bucket. On a versioned bucket, the default creates a delete marker; use --version-id or --all-versions to hard-delete versions | +| `tigris objects set` (s) | (Deprecated) Update settings on an existing object such as access level. Use `tigris objects set-access` for ACL changes and `tigris mv` to rename | +| `tigris objects set-access` (sa) | Set the access level (public or private) on an existing object | +| `tigris objects info` (i) | Show metadata for an object (content type, size, modified date) | + +#### `tigris objects list` (l) -#### `objects list` +List objects in a bucket, optionally filtered by a key prefix ``` tigris objects list [flags] @@ -969,7 +842,34 @@ tigris objects list my-bucket --prefix images/ tigris objects list my-bucket --format json ``` -#### `objects get` +#### `tigris objects list-versions` (lv) + +List object versions and delete markers in a bucket (requires bucket versioning). Returns both arrays separately to match the S3 ListObjectVersions response + +``` +tigris objects list-versions [flags] +``` + +| Flag | Description | +|------|-------------| +| `-p, --prefix` | Filter by key prefix | +| `-d, --delimiter` | Group keys sharing a common prefix up to the delimiter (e.g. "/" for folder-style grouping) | +| `--format` | Output format (default: table) | +| `--limit` | Maximum number of items to return per page | +| `--key-marker` | Pagination marker — the key to start listing from (from a prior nextKeyMarker) | +| `--version-id-marker` | Pagination marker — the version id to start listing from (from a prior nextVersionIdMarker) | + +**Examples:** +```bash +tigris objects list-versions my-bucket +tigris objects list-versions t3://my-bucket/logs/ +tigris objects list-versions my-bucket --prefix images/ +tigris objects list-versions my-bucket --format json +``` + +#### `tigris objects get` (g) + +Download an object by key. Prints to stdout by default, or saves to a file with --output ``` tigris objects get [key] [flags] @@ -980,6 +880,7 @@ tigris objects get [key] [flags] | `-o, --output` | Output file path (if not specified, prints to stdout) | | `-m, --mode` | Response mode: "string" loads into memory, "stream" writes in chunks (auto-detected from extension if not specified) | | `-snapshot, --snapshot-version` | Read from a specific bucket snapshot. Accepts a snapshot version string or any UNIX nanosecond-precision timestamp (e.g. 1765889000501544464) | +| `--version-id` | Object version id to download (requires bucket versioning). Omit to download the latest version | **Examples:** ```bash @@ -988,7 +889,9 @@ tigris objects get t3://my-bucket/config.json tigris objects get my-bucket archive.zip --output ./archive.zip --mode stream ``` -#### `objects put` +#### `tigris objects put` (p) + +Upload a local file as an object. Content-type is auto-detected from extension unless overridden ``` tigris objects put [key] [file] [flags] @@ -1007,7 +910,9 @@ tigris objects put t3://my-bucket/report.pdf ./report.pdf tigris objects put my-bucket logo.png ./logo.png --access public --content-type image/png ``` -#### `objects delete` +#### `tigris objects delete` (d) + +Delete one or more objects by key from the given bucket. On a versioned bucket, the default creates a delete marker; use --version-id or --all-versions to hard-delete versions ``` tigris objects delete [key] [flags] @@ -1015,6 +920,8 @@ tigris objects delete [key] [flags] | Flag | Description | |------|-------------| +| `--version-id` | Hard-delete a specific object version (requires bucket versioning). Targets a single key | +| `--all-versions` | Hard-delete every version and delete marker for the given key(s). Mutually exclusive with --version-id | | `--force` | Skip confirmation prompts (alias for --yes) | **Examples:** @@ -1022,9 +929,13 @@ tigris objects delete [key] [flags] tigris objects delete my-bucket old-file.txt --yes tigris objects delete t3://my-bucket/old-file.txt --yes tigris objects delete my-bucket file-a.txt,file-b.txt --yes +tigris objects delete my-bucket old-file.txt --version-id abc123 --yes +tigris objects delete my-bucket old-file.txt --all-versions --yes ``` -#### `objects set` +#### `tigris objects set` (s) + +(Deprecated) Update settings on an existing object such as access level. Use `tigris objects set-access` for ACL changes and `tigris mv` to rename ``` tigris objects set [key] [flags] @@ -1042,7 +953,27 @@ tigris objects set t3://my-bucket/my-file.txt --access public tigris objects set my-bucket my-file.txt --access private ``` -#### `objects info` +#### `tigris objects set-access` (sa) + +Set the access level (public or private) on an existing object + +``` +tigris objects set-access [key] [access] [flags] +``` + +| Flag | Description | +|------|-------------| +| `--format` | Output format (default: table) | + +**Examples:** +```bash +tigris objects set-access my-bucket my-file.txt public +tigris objects set-access t3://my-bucket/my-file.txt private +``` + +#### `tigris objects info` (i) + +Show metadata for an object (content type, size, modified date) ``` tigris objects info [key] [flags] @@ -1052,39 +983,217 @@ tigris objects info [key] [flags] |------|-------------| | `--format` | Output format (default: table) | | `-snapshot, --snapshot-version` | Read from a specific bucket snapshot | +| `--version-id` | Object version id (requires bucket versioning). Omit to read the latest version | **Examples:** ```bash tigris objects info my-bucket report.pdf tigris objects info t3://my-bucket/report.pdf tigris objects info my-bucket report.pdf --format json +tigris objects info my-bucket report.pdf --version-id abc123 +``` + +### `tigris access-keys` (keys) + +Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access + +| Command | Description | +|---------|-------------| +| `tigris access-keys list` (l) | List all access keys in the current organization | +| `tigris access-keys create` (c) | Create a new access key with the given name. Returns the key ID and secret (shown only once) | +| `tigris access-keys delete` (d) | Permanently delete an access key by its ID. This revokes all access immediately | +| `tigris access-keys get` (g) | Show details for an access key including its name, creation date, and assigned bucket roles | +| `tigris access-keys assign` (a) | Assign per-bucket roles to an access key. Pair each --bucket with a --role (Editor or ReadOnly), or use --admin for org-wide access | +| `tigris access-keys rotate` (r) | Rotate an access key's secret. The current secret is immediately invalidated and a new one is returned (shown only once) | +| `tigris access-keys attach-policy` (ap) | Attach an IAM policy to an access key. If no policy ARN is provided, shows interactive selection of available policies | +| `tigris access-keys detach-policy` (dp) | Detach an IAM policy from an access key. If no policy ARN is provided, shows interactive selection of attached policies | +| `tigris access-keys list-policies` (lp) | List all IAM policies attached to an access key | + +#### `tigris access-keys list` (l) + +List all access keys in the current organization + +``` +tigris access-keys list [flags] +``` + +| Flag | Description | +|------|-------------| +| `--format` | Output format (default: table) | +| `--limit` | Maximum number of items to return per page | +| `-pt, --page-token` | Pagination token from a previous request to fetch the next page | + +**Examples:** +```bash +tigris access-keys list +``` + +#### `tigris access-keys create` (c) + +Create a new access key with the given name. Returns the key ID and secret (shown only once) + +``` +tigris access-keys create +``` + +**Examples:** +```bash +tigris access-keys create my-ci-key +``` + +#### `tigris access-keys delete` (d) + +Permanently delete an access key by its ID. This revokes all access immediately + +``` +tigris access-keys delete [flags] +``` + +| Flag | Description | +|------|-------------| +| `--force` | Skip confirmation prompts (alias for --yes) | + +**Examples:** +```bash +tigris access-keys delete tid_AaBbCcDdEeFf --yes +``` + +#### `tigris access-keys get` (g) + +Show details for an access key including its name, creation date, and assigned bucket roles + +``` +tigris access-keys get +``` + +**Examples:** +```bash +tigris access-keys get tid_AaBbCcDdEeFf +``` + +#### `tigris access-keys assign` (a) + +Assign per-bucket roles to an access key. Pair each --bucket with a --role (Editor or ReadOnly), or use --admin for org-wide access + +``` +tigris access-keys assign [flags] +``` + +| Flag | Description | +|------|-------------| +| `-b, --bucket` | Bucket name (can specify multiple, comma-separated). Each bucket is paired positionally with a --role value | +| `-r, --role` | Role to assign (can specify multiple, comma-separated). Each role pairs with the corresponding --bucket value | +| `--admin` | Grant admin access to all buckets in the organization | +| `--revoke-roles` | Revoke all bucket roles from the access key | + +**Examples:** +```bash +tigris access-keys assign tid_AaBb --bucket my-bucket --role Editor +tigris access-keys assign tid_AaBb --bucket a,b --role Editor,ReadOnly +tigris access-keys assign tid_AaBb --admin +tigris access-keys assign tid_AaBb --revoke-roles +``` + +#### `tigris access-keys rotate` (r) + +Rotate an access key's secret. The current secret is immediately invalidated and a new one is returned (shown only once) + +``` +tigris access-keys rotate [flags] +``` + +| Flag | Description | +|------|-------------| +| `--force` | Skip confirmation prompts (alias for --yes) | + +**Examples:** +```bash +tigris access-keys rotate tid_AaBbCcDdEeFf --yes +``` + +#### `tigris access-keys attach-policy` (ap) + +Attach an IAM policy to an access key. If no policy ARN is provided, shows interactive selection of available policies + +``` +tigris access-keys attach-policy [flags] +``` + +| Flag | Description | +|------|-------------| +| `--policy-arn` | ARN of the policy to attach | + +**Examples:** +```bash +tigris access-keys attach-policy tid_AaBb --policy-arn arn:aws:iam::org_id:policy/my-policy +tigris access-keys attach-policy tid_AaBb +``` + +#### `tigris access-keys detach-policy` (dp) + +Detach an IAM policy from an access key. If no policy ARN is provided, shows interactive selection of attached policies + +``` +tigris access-keys detach-policy [flags] +``` + +| Flag | Description | +|------|-------------| +| `--policy-arn` | ARN of the policy to detach | +| `--force` | Skip confirmation prompts (alias for --yes) | + +**Examples:** +```bash +tigris access-keys detach-policy tid_AaBb --policy-arn arn:aws:iam::org_id:policy/my-policy --yes +tigris access-keys detach-policy tid_AaBb +``` + +#### `tigris access-keys list-policies` (lp) + +List all IAM policies attached to an access key + +``` +tigris access-keys list-policies [flags] +``` + +| Flag | Description | +|------|-------------| +| `--format` | Output format (default: table) | +| `--limit` | Maximum number of items to return per page | +| `-pt, --page-token` | Pagination token from a previous request to fetch the next page | + +**Examples:** +```bash +tigris access-keys list-policies tid_AaBbCcDdEeFf ``` -### `iam` +### `tigris iam` Identity and Access Management - manage policies, users, and permissions | Command | Description | |---------|-------------| -| `iam policies` (p) | Manage IAM policies. Policies define permissions for access keys | -| `iam users` (u) | Manage organization users and invitations | +| `tigris iam policies` (p) | Manage IAM policies. Policies define permissions for access keys | +| `tigris iam users` (u) | Manage organization users and invitations | -#### `iam policies` | `p` +#### `tigris iam policies` (p) Manage IAM policies. Policies define permissions for access keys | Command | Description | |---------|-------------| -| `iam policies list` (l) | List all policies in the current organization | -| `iam policies get` (g) | Show details for a policy including its document and attached users. If no ARN provided, shows interactive selection | -| `iam policies create` (c) | Create a new policy with the given name and policy document. Document can be provided via file, inline JSON, or stdin | -| `iam policies edit` (e) | Update an existing policy's document. Document can be provided via file, inline JSON, or stdin. If no ARN provided, shows interactive selection | -| `iam policies delete` (d) | Delete a policy. If no ARN provided, shows interactive selection | -| `iam policies link-key` (lnk) | Link an access key to a policy. If no policy ARN is provided, shows interactive selection. If no access key ID is provided, shows interactive selection of unlinked keys | -| `iam policies unlink-key` (ulnk) | Unlink an access key from a policy. If no policy ARN is provided, shows interactive selection. If no access key ID is provided, shows interactive selection of linked keys | -| `iam policies list-keys` (lk) | List all access keys attached to a policy. If no policy ARN is provided, shows interactive selection | +| `tigris iam policies list` (l) | List all policies in the current organization | +| `tigris iam policies get` (g) | Show details for a policy including its document and attached users. If no ARN provided, shows interactive selection | +| `tigris iam policies create` (c) | Create a new policy with the given name and policy document. Document can be provided via file, inline JSON, or stdin | +| `tigris iam policies edit` (e) | Update an existing policy's document. Document can be provided via file, inline JSON, or stdin. If no ARN provided, shows interactive selection | +| `tigris iam policies delete` (d) | Delete a policy. If no ARN provided, shows interactive selection | +| `tigris iam policies link-key` (lnk) | Link an access key to a policy. If no policy ARN is provided, shows interactive selection. If no access key ID is provided, shows interactive selection of unlinked keys | +| `tigris iam policies unlink-key` (ulnk) | Unlink an access key from a policy. If no policy ARN is provided, shows interactive selection. If no access key ID is provided, shows interactive selection of linked keys | +| `tigris iam policies list-keys` (lk) | List all access keys attached to a policy. If no policy ARN is provided, shows interactive selection | -##### `iam policies list` +##### `tigris iam policies list` (l) + +List all policies in the current organization ``` tigris iam policies list [flags] @@ -1101,7 +1210,9 @@ tigris iam policies list [flags] tigris iam policies list ``` -##### `iam policies get` +##### `tigris iam policies get` (g) + +Show details for a policy including its document and attached users. If no ARN provided, shows interactive selection ``` tigris iam policies get [resource] [flags] @@ -1117,7 +1228,9 @@ tigris iam policies get tigris iam policies get arn:aws:iam::org_id:policy/my-policy ``` -##### `iam policies create` +##### `tigris iam policies create` (c) + +Create a new policy with the given name and policy document. Document can be provided via file, inline JSON, or stdin ``` tigris iam policies create [flags] @@ -1135,7 +1248,9 @@ tigris iam policies create my-policy --document '{"Version":"2012-10-17","Statem cat policy.json | tigris iam policies create my-policy ``` -##### `iam policies edit` +##### `tigris iam policies edit` (e) + +Update an existing policy's document. Document can be provided via file, inline JSON, or stdin. If no ARN provided, shows interactive selection ``` tigris iam policies edit [resource] [flags] @@ -1153,7 +1268,9 @@ tigris iam policies edit arn:aws:iam::org_id:policy/my-policy --document policy. cat policy.json | tigris iam policies edit arn:aws:iam::org_id:policy/my-policy ``` -##### `iam policies delete` +##### `tigris iam policies delete` (d) + +Delete a policy. If no ARN provided, shows interactive selection ``` tigris iam policies delete [resource] [flags] @@ -1169,7 +1286,9 @@ tigris iam policies delete tigris iam policies delete arn:aws:iam::org_id:policy/my-policy --yes ``` -##### `iam policies link-key` +##### `tigris iam policies link-key` (lnk) + +Link an access key to a policy. If no policy ARN is provided, shows interactive selection. If no access key ID is provided, shows interactive selection of unlinked keys ``` tigris iam policies link-key [resource] [flags] @@ -1185,7 +1304,9 @@ tigris iam policies link-key arn:aws:iam::org_id:policy/my-policy --id tid_AaBb tigris iam policies link-key ``` -##### `iam policies unlink-key` +##### `tigris iam policies unlink-key` (ulnk) + +Unlink an access key from a policy. If no policy ARN is provided, shows interactive selection. If no access key ID is provided, shows interactive selection of linked keys ``` tigris iam policies unlink-key [resource] [flags] @@ -1202,7 +1323,9 @@ tigris iam policies unlink-key arn:aws:iam::org_id:policy/my-policy --id tid_AaB tigris iam policies unlink-key ``` -##### `iam policies list-keys` +##### `tigris iam policies list-keys` (lk) + +List all access keys attached to a policy. If no policy ARN is provided, shows interactive selection ``` tigris iam policies list-keys [resource] [flags] @@ -1218,19 +1341,21 @@ tigris iam policies list-keys arn:aws:iam::org_id:policy/my-policy tigris iam policies list-keys ``` -#### `iam users` | `u` +#### `tigris iam users` (u) Manage organization users and invitations | Command | Description | |---------|-------------| -| `iam users list` (l) | List all users and pending invitations in the organization | -| `iam users invite` (i) | Invite users to the organization by email | -| `iam users revoke-invitation` (ri) | Revoke pending invitations. If no invitation ID provided, shows interactive selection | -| `iam users update-role` (ur) | Update user roles in the organization. If no user ID provided, shows interactive selection | -| `iam users remove` (rm) | Remove users from the organization. If no user ID provided, shows interactive selection | +| `tigris iam users list` (l) | List all users and pending invitations in the organization | +| `tigris iam users invite` (i) | Invite users to the organization by email | +| `tigris iam users revoke-invitation` (ri) | Revoke pending invitations. If no invitation ID provided, shows interactive selection | +| `tigris iam users update-role` (ur) | Update user roles in the organization. If no user ID provided, shows interactive selection | +| `tigris iam users remove` (rm) | Remove users from the organization. If no user ID provided, shows interactive selection | -##### `iam users list` +##### `tigris iam users list` (l) + +List all users and pending invitations in the organization ``` tigris iam users list [flags] @@ -1246,7 +1371,9 @@ tigris iam users list tigris iam users list --format json ``` -##### `iam users invite` +##### `tigris iam users invite` (i) + +Invite users to the organization by email ``` tigris iam users invite [flags] @@ -1263,7 +1390,9 @@ tigris iam users invite user@example.com --role admin tigris iam users invite user1@example.com,user2@example.com ``` -##### `iam users revoke-invitation` +##### `tigris iam users revoke-invitation` (ri) + +Revoke pending invitations. If no invitation ID provided, shows interactive selection ``` tigris iam users revoke-invitation [resource] [flags] @@ -1280,7 +1409,9 @@ tigris iam users revoke-invitation invitation_id --yes tigris iam users revoke-invitation id1,id2,id3 --yes ``` -##### `iam users update-role` +##### `tigris iam users update-role` (ur) + +Update user roles in the organization. If no user ID provided, shows interactive selection ``` tigris iam users update-role [resource] [flags] @@ -1298,7 +1429,9 @@ tigris iam users update-role id1,id2 --role admin tigris iam users update-role id1,id2 --role admin,member ``` -##### `iam users remove` +##### `tigris iam users remove` (rm) + +Remove users from the organization. If no user ID provided, shows interactive selection ``` tigris iam users remove [resource] [flags] @@ -1315,21 +1448,6 @@ tigris iam users remove user@example.com --yes tigris iam users remove user@example.com,user@example.net --yes ``` -## Other - -### `update` - -Update the CLI to the latest version - -``` -tigris update -``` - -**Examples:** -```bash -tigris update -``` - ## License MIT diff --git a/scripts/update-docs.ts b/scripts/update-docs.ts index 776c9b3..8b3f23d 100644 --- a/scripts/update-docs.ts +++ b/scripts/update-docs.ts @@ -16,17 +16,16 @@ interface Specs { commands: CommandSpec[]; } -// Check if a command is implemented (has a corresponding .ts file without underscore prefix) function isImplemented(...parts: string[]): boolean { const base = join(libDir, ...parts); const paths = [base + '.ts', join(base, 'index.ts')]; return paths.some((p) => existsSync(p) && !p.includes('/_')); } -// Check if a command or any of its nested subcommands are implemented. -// Removed commands are tombstones and never count as "implemented" for -// docs purposes — they shouldn't appear in the rendered README. -function hasImplementation(cmd: CommandSpec, ...parentParts: string[]): boolean { +function hasImplementation( + cmd: CommandSpec, + ...parentParts: string[] +): boolean { if (cmd.removed) return false; const parts = [...parentParts, cmd.name]; if (isImplemented(...parts)) return true; @@ -36,39 +35,65 @@ function hasImplementation(cmd: CommandSpec, ...parentParts: string[]): boolean return false; } -function getCommandUsage(cmd: CommandSpec): string { - if (!cmd.arguments) return `tigris ${cmd.name}`; +function aliasList(cmd: CommandSpec): string[] { + if (!cmd.alias) return []; + return Array.isArray(cmd.alias) ? cmd.alias : [cmd.alias]; +} + +function aliasSuffix(cmd: CommandSpec): string { + const aliases = aliasList(cmd); + return aliases.length ? ` (${aliases.join(', ')})` : ''; +} - const positionals = cmd.arguments - .filter((a) => a.type === 'positional') +function getPositionalSuffix(cmd: CommandSpec): string { + const positionals = (cmd.arguments ?? []) + .filter((a) => a.type === 'positional' && !a.removed) .map((a) => (a.required ? `<${a.name}>` : `[${a.name}]`)); + return positionals.length ? ' ' + positionals.join(' ') : ''; +} - return `tigris ${cmd.name}${positionals.length ? ' ' + positionals.join(' ') : ''}`; +function renderCommandTable( + commands: CommandSpec[], + parentPath: string[] +): string[] { + const lines: string[] = []; + lines.push('| Command | Description |'); + lines.push('|---------|-------------|'); + for (const cmd of commands) { + const fullName = [...parentPath, cmd.name].join(' '); + lines.push( + `| \`tigris ${fullName}\`${aliasSuffix(cmd)} | ${cmd.description ?? ''} |` + ); + } + lines.push(''); + return lines; } -function generateCommandSection(cmd: CommandSpec): string { +function renderLeafDetail(cmd: CommandSpec, parentPath: string[]): string[] { const lines: string[] = []; - const aliasStr = cmd.alias ? ` | \`${cmd.alias}\`` : ''; + const fullName = [...parentPath, cmd.name].join(' '); + const positionals = getPositionalSuffix(cmd); + const flags = (cmd.arguments ?? []).filter( + (a) => a.type !== 'positional' && !a.removed + ); - lines.push(`### \`${cmd.name}\`${aliasStr}`); - lines.push(''); - lines.push(cmd.description ?? ''); - lines.push(''); lines.push('```'); - const usage = getCommandUsage(cmd); - const hasFlags = cmd.arguments?.some((a) => a.type !== 'positional'); - lines.push(`${usage}${hasFlags ? ' [flags]' : ''}`); + lines.push( + `tigris ${fullName}${positionals}${flags.length ? ' [flags]' : ''}` + ); lines.push('```'); lines.push(''); - const flags = - cmd.arguments?.filter((a) => a.type !== 'positional' && !a.removed) || []; if (flags.length > 0) { lines.push('| Flag | Description |'); lines.push('|------|-------------|'); for (const arg of flags) { - const flagName = arg.alias ? `-${arg.alias}, --${arg.name}` : `--${arg.name}`; - lines.push(`| \`${flagName}\` | ${arg.description} |`); + const flagName = arg.alias + ? `-${arg.alias}, --${arg.name}` + : `--${arg.name}`; + const defaultStr = + arg.default !== undefined ? ` (default: ${arg.default})` : ''; + lines.push(`| \`${flagName}\` | ${arg.description ?? ''}${defaultStr} |`); } lines.push(''); } @@ -76,145 +101,44 @@ function generateCommandSection(cmd: CommandSpec): string { if (cmd.examples && cmd.examples.length > 0) { lines.push('**Examples:**'); lines.push('```bash'); - for (const ex of cmd.examples) { - lines.push(ex); - } + for (const ex of cmd.examples) lines.push(ex); lines.push('```'); lines.push(''); - } else { - const positionals = cmd.arguments?.filter((a) => a.type === 'positional') || []; - if (positionals.length > 0 && positionals.some((p) => p.examples?.length)) { - lines.push('**Examples:**'); - lines.push('```bash'); - if (positionals.length === 1 && positionals[0].examples) { - for (const ex of positionals[0].examples.slice(0, 3)) { - lines.push(`tigris ${cmd.name} ${ex}`); - } - } else if (positionals.length >= 2) { - if (cmd.name === 'cp' || cmd.name === 'mv') { - lines.push(`tigris ${cmd.name} bucket/file.txt bucket/copy.txt`); - lines.push(`tigris ${cmd.name} bucket/folder/ other-bucket/folder/`); - } - } - lines.push('```'); - lines.push(''); - } } - return lines.join('\n'); + return lines; } -function generateResourceSection( +function renderCommand( cmd: CommandSpec, - parentPath: string[] = [], - headerLevel: string = '###' -): string { - const lines: string[] = []; - const aliasStr = cmd.alias ? ` | \`${cmd.alias}\`` : ''; - const commandPath = [...parentPath, cmd.name]; - const fullName = commandPath.join(' '); - const subHeaderLevel = headerLevel === '###' ? '####' : '#####'; - - lines.push(`${headerLevel} \`${fullName}\`${aliasStr}`); - lines.push(''); - lines.push(cmd.description ?? ''); - lines.push(''); - - const subcommands = cmd.commands || []; - - // Check if subcommands have their own nested subcommands (e.g. iam -> policies -> list) - const hasNestedSubcommands = subcommands.some((sub) => sub.commands && sub.commands.length > 0); - - if (hasNestedSubcommands) { - // Parent resource (like iam) - recurse into sub-resources - const implementedSubs = subcommands.filter((sub) => hasImplementation(sub, ...commandPath)); - - if (implementedSubs.length > 0) { - lines.push('| Command | Description |'); - lines.push('|---------|-------------|'); - for (const sub of implementedSubs) { - const subAlias = sub.alias - ? ` (${Array.isArray(sub.alias) ? sub.alias[0] : sub.alias})` - : ''; - lines.push(`| \`${fullName} ${sub.name}\`${subAlias} | ${sub.description} |`); - } - lines.push(''); - - for (const sub of implementedSubs) { - lines.push(generateResourceSection(sub, commandPath, subHeaderLevel)); - } - } - } else { - // Leaf resource - show implemented operations - const implementedOps = subcommands.filter((op) => isImplemented(...commandPath, op.name)); - - if (implementedOps.length > 0) { - lines.push('| Command | Description |'); - lines.push('|---------|-------------|'); - for (const op of implementedOps) { - const opAlias = op.alias - ? ` (${Array.isArray(op.alias) ? op.alias[0] : op.alias})` - : ''; - lines.push(`| \`${fullName} ${op.name}\`${opAlias} | ${op.description} |`); - } - lines.push(''); - - for (const op of implementedOps) { - lines.push(generateOperationSection(commandPath, op, subHeaderLevel)); - } - } - } - - return lines.join('\n'); -} - -function generateOperationSection( parentPath: string[], - op: CommandSpec, - headerLevel: string = '####' + level: number ): string { const lines: string[] = []; - const fullName = [...parentPath, op.name].join(' '); + const fullName = [...parentPath, cmd.name].join(' '); + const hash = '#'.repeat(Math.min(level, 6)); - lines.push(`${headerLevel} \`${fullName}\``); + lines.push(`${hash} \`tigris ${fullName}\`${aliasSuffix(cmd)}`); lines.push(''); + if (cmd.description) { + lines.push(cmd.description); + lines.push(''); + } - const positionals = - op.arguments - ?.filter((a) => a.type === 'positional') - .map((a) => (a.required ? `<${a.name}>` : `[${a.name}]`)) || []; - - const hasFlags = op.arguments?.some((a) => a.type !== 'positional'); - - lines.push('```'); - lines.push( - `tigris ${fullName}${positionals.length ? ' ' + positionals.join(' ') : ''}${hasFlags ? ' [flags]' : ''}` + const childPath = [...parentPath, cmd.name]; + const subcommands = (cmd.commands ?? []).filter((sub) => + hasImplementation(sub, ...childPath) ); - lines.push('```'); - lines.push(''); - const flags = op.arguments?.filter((a) => a.type !== 'positional') || []; - if (flags.length > 0) { - lines.push('| Flag | Description |'); - lines.push('|------|-------------|'); - for (const arg of flags) { - const flagName = arg.alias ? `-${arg.alias}, --${arg.name}` : `--${arg.name}`; - const defaultStr = arg.default !== undefined ? ` (default: ${arg.default})` : ''; - lines.push(`| \`${flagName}\` | ${arg.description}${defaultStr} |`); - } - lines.push(''); + if (subcommands.length === 0) { + lines.push(...renderLeafDetail(cmd, parentPath)); + return lines.join('\n'); } - if (op.examples && op.examples.length > 0) { - lines.push('**Examples:**'); - lines.push('```bash'); - for (const ex of op.examples) { - lines.push(ex); - } - lines.push('```'); - lines.push(''); + lines.push(...renderCommandTable(subcommands, childPath)); + for (const sub of subcommands) { + lines.push(renderCommand(sub, childPath, level + 1)); } - return lines.join('\n'); } @@ -227,182 +151,21 @@ function generateDocs(specs: Specs): string { lines.push('tigris [flags]'); lines.push('```'); lines.push(''); - lines.push('Run `tigris help` to see all available commands, or `tigris help` for details on a specific command.'); - lines.push(''); - - // Core commands (Unix-style) - only implemented ones - const coreCommands = ['ls', 'mk', 'touch', 'cp', 'mv', 'rm', 'stat', 'presign', 'bundle'].filter((c) => isImplemented(c)); - lines.push('### Core Commands'); - lines.push(''); - for (const cmdName of coreCommands) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(`- \`${getCommandUsage(cmd)}\` - ${cmd.description}`); - } - } - lines.push(''); - - // Auth commands - check both direct implementation and subcommands - const authCommandNames = ['login', 'logout', 'whoami', 'configure']; - const authCommands = authCommandNames.filter((c) => { - if (isImplemented(c)) return true; - const cmd = specs.commands.find((s) => s.name === c); - return cmd?.commands?.some((op) => isImplemented(c, op.name)); - }); - lines.push('### Authentication'); - lines.push(''); - for (const cmdName of authCommands) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(`- \`tigris ${cmd.name}\` - ${cmd.description}`); - } - } + lines.push( + 'Run `tigris help` to see all available commands, or `tigris help` for details on a specific command.' + ); lines.push(''); - // Other commands (CLI management) - const otherCommands = ['update'].filter((c) => isImplemented(c)); - if (otherCommands.length > 0) { - lines.push('### Other'); - lines.push(''); - for (const cmdName of otherCommands) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(`- \`tigris ${cmd.name}\` - ${cmd.description}`); - } - } - lines.push(''); - } - - // Resource management - const resourceCommands = ['organizations', 'access-keys', 'credentials', 'buckets', 'snapshots', 'objects', 'iam']; - const implementedResources = resourceCommands.filter((c) => { - const cmd = specs.commands.find((s) => s.name === c); - if (!cmd) return false; - return hasImplementation(cmd); - }); + const topLevel = specs.commands.filter((c) => hasImplementation(c)); - lines.push('### Resources'); + lines.push('### Commands'); lines.push(''); - for (const cmdName of implementedResources) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(`- \`tigris ${cmd.name}\` - ${cmd.description}`); - } - } - lines.push(''); - + lines.push(...renderCommandTable(topLevel, [])); lines.push('---'); lines.push(''); - // Detailed sections - lines.push('## Core Commands'); - lines.push(''); - for (const cmdName of coreCommands) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(generateCommandSection(cmd)); - } - } - - lines.push('## Authentication'); - lines.push(''); - for (const cmdName of authCommands) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - // Commands with subcommands use resource-style docs - if (cmd.commands?.some((op) => isImplemented(cmdName, op.name))) { - lines.push(generateResourceSection(cmd)); - } else { - lines.push(generateCommandSection(cmd)); - } - } - } - - lines.push('## Resources'); - lines.push(''); - - // Organizations first - if (implementedResources.includes('organizations')) { - const orgsCmd = specs.commands.find((c) => c.name === 'organizations'); - if (orgsCmd) { - lines.push(generateResourceSection(orgsCmd)); - } - } - - // Access Keys - if (implementedResources.includes('access-keys')) { - const accessKeysCmd = specs.commands.find((c) => c.name === 'access-keys'); - if (accessKeysCmd) { - lines.push(generateResourceSection(accessKeysCmd)); - } - } - - // Credentials - if (implementedResources.includes('credentials')) { - const credentialsCmd = specs.commands.find((c) => c.name === 'credentials'); - if (credentialsCmd) { - lines.push(generateResourceSection(credentialsCmd)); - } - } - - // Buckets section (buckets, snapshots) - const bucketRelated = ['buckets', 'snapshots'].filter((c) => implementedResources.includes(c)); - if (bucketRelated.length > 0) { - lines.push('### Buckets'); - lines.push(''); - lines.push('Buckets are containers for objects. You can also create snapshots of buckets.'); - lines.push(''); - - for (const cmdName of bucketRelated) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(generateResourceSection(cmd, [], '####')); - } - } - } - - // Objects - if (implementedResources.includes('objects')) { - const objectsCmd = specs.commands.find((c) => c.name === 'objects'); - if (objectsCmd) { - lines.push(generateResourceSection(objectsCmd)); - } - } - - // IAM - if (implementedResources.includes('iam')) { - const iamCmd = specs.commands.find((c) => c.name === 'iam'); - if (iamCmd) { - lines.push(generateResourceSection(iamCmd)); - } - } - - // Other commands - if (otherCommands.length > 0) { - lines.push('## Other'); - lines.push(''); - for (const cmdName of otherCommands) { - const cmd = specs.commands.find((c) => c.name === cmdName); - if (cmd) { - lines.push(generateCommandSection(cmd)); - } - } - } - - // Warn about any specs commands that aren't in any category - const allCategorized = new Set([ - ...coreCommands, - ...authCommandNames, - ...resourceCommands, - ...otherCommands, - ]); - const unhandled = specs.commands - .filter((c) => !allCategorized.has(c.name) && hasImplementation(c)) - .map((c) => c.name); - if (unhandled.length > 0) { - console.warn( - `Warning: the following implemented commands are not in any docs category: ${unhandled.join(', ')}` - ); + for (const cmd of topLevel) { + lines.push(renderCommand(cmd, [], 3)); } return lines.join('\n'); @@ -421,13 +184,15 @@ function updateReadme(docsContent: string): void { } const newReadme = - readmeContent.slice(0, usageStart) + docsContent + '\n' + readmeContent.slice(licenseStart); + readmeContent.slice(0, usageStart) + + docsContent + + '\n' + + readmeContent.slice(licenseStart); writeFileSync(readmePath, newReadme); console.log('README.md updated successfully!'); } -// Main const specsPath = join(__dirname, '..', 'src', 'specs.yaml'); const specsContent = readFileSync(specsPath, 'utf-8'); const specs = yaml.parse(specsContent) as Specs; From cebf15fa0b4fe4dc657d176c7724b494ac6eb812 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 14 May 2026 15:29:02 +0200 Subject: [PATCH 5/6] fix: keep `keys` in `objects delete --json` for backward compatibility The earlier versioning rewrite renamed the JSON output property from `keys` (string[]) to `deleted` (Target[]), which silently broke any script reading `.keys` on the unversioned path. Restore `keys` as a flat string[] alongside the richer `deleted` array so existing consumers keep working while new ones can still read versionId info. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/objects/delete.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index 56a82d3..9719ec1 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -164,6 +164,10 @@ export default async function deleteObject(options: Record) { const jsonOutput: Record = { action: 'deleted', bucket, + // `keys` is kept as a flat string[] for backward compatibility + // with consumers that predate versioning support. `deleted` + // carries the richer (key, versionId?) shape for new callers. + keys: deleted.map((d) => d.key), deleted, errors, }; From 0949135ca7c4e54203c95ca8878af8d94b54fbb9 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 14 May 2026 19:06:22 +0200 Subject: [PATCH 6/6] fix: drive --all-versions pagination off explicit hasMore flag The previous loop used `keyMarker` truthiness as the continuation signal, so a server response with `hasMore: true` but a missing `nextKeyMarker` would silently stop pagination mid-bulk-delete and leave older versions intact. Switch to an explicit `data.hasMore` check, and fail loudly if the server claims more pages without a continuation marker rather than dropping work on the floor. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/objects/delete.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index 9719ec1..b197aef 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -80,7 +80,12 @@ export default async function deleteObject(options: Record) { // don't issue thousands of requests just to walk past every // `a*` key when the user asked for `a`. let pastTarget = false; - do { + // Drive loop continuation off the explicit `hasMore` flag, + // not marker truthiness. A destructive bulk-delete must never + // silently stop because the server reported more pages but + // omitted a continuation token — bail loudly instead so the + // user doesn't end up with half-deleted history. + for (;;) { const { data, error } = await listVersions({ prefix: key, ...(keyMarker ? { keyMarker } : {}), @@ -110,10 +115,16 @@ export default async function deleteObject(options: Record) { pastTarget = true; } } - if (pastTarget) break; - keyMarker = data.hasMore ? data.nextKeyMarker : undefined; - versionIdMarker = data.hasMore ? data.nextVersionIdMarker : undefined; - } while (keyMarker); + if (pastTarget || !data.hasMore) break; + if (!data.nextKeyMarker) { + failWithError( + context, + `listVersions reported more pages but no nextKeyMarker for key '${key}'` + ); + } + keyMarker = data.nextKeyMarker; + versionIdMarker = data.nextVersionIdMarker; + } if (matched === 0) { failWithError(