From 4c39d70438c0816f3f1ba8cef9f0abed2f060fb8 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 25 Jun 2026 18:42:26 +0200 Subject: [PATCH 1/6] chore(deps): upgrade @tigrisdata/storage to ^3.16.0 and @tigrisdata/iam to ^2.2.0 Brings in object ACL/directory-listing options on createBucket, restoreObject/getRestoreInfo, and team management APIs, which the following commits surface in the CLI. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index c82cc48..3c86c76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "dependencies": { "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", - "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.15.0", + "@tigrisdata/iam": "^2.2.0", + "@tigrisdata/storage": "^3.16.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", @@ -3696,18 +3696,18 @@ "license": "MIT" }, "node_modules/@tigrisdata/iam": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@tigrisdata/iam/-/iam-2.1.1.tgz", - "integrity": "sha512-l9mjTnFpWGi+Nzved836qM7R/2Qm47kDmfsiHFTO7++y9I12XokpbXhFACsShhlrrWY2seSKn1wkrM8dpRXCDg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/iam/-/iam-2.2.0.tgz", + "integrity": "sha512-tfHmBV4BjHOln/MxGUIIC3zCbUyyuW6eYDxzKpJwcKrvTPfrAqstfhJXD4ubBxk+QK3TlKixXcZMfLGYbE/MQw==", "license": "MIT", "dependencies": { "dotenv": "^17.4.2" } }, "node_modules/@tigrisdata/storage": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.15.0.tgz", - "integrity": "sha512-B1Og+EKdFiz6yH+jCM1JYYFjTuVL1bYh8AgrlaNN+2C5lyxllMMVdEFmonJWcfeLw7KwWxNl8IJVl5YLSemjPA==", + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.16.0.tgz", + "integrity": "sha512-Tdqh94A8gEj9GUF6fWGibO3Dz3l3BBieNMFh26PXUuBOTOpHEbPitg/zELopboVTRtRzWNsWnUFGymjxHYlpZA==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", diff --git a/package.json b/package.json index 21e5716..2b57464 100644 --- a/package.json +++ b/package.json @@ -102,8 +102,8 @@ "dependencies": { "@aws-sdk/credential-providers": "^3.1038.0", "@smithy/shared-ini-file-loader": "^4.4.9", - "@tigrisdata/iam": "^2.1.1", - "@tigrisdata/storage": "^3.15.0", + "@tigrisdata/iam": "^2.2.0", + "@tigrisdata/storage": "^3.16.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.3", From cd553b5b85e755f2b13f7defb69e567008f70251 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 25 Jun 2026 19:06:02 +0200 Subject: [PATCH 2/6] feat: object ACL controls on bucket creation and uploads - mk / buckets create: add --allow-object-acl and --enable-directory-listing, wired into createBucket - cp: add --access (public|private) for local-to-remote uploads; rejected for other directions where it cannot apply, with a pointer to objects set-access Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 ++++++++ src/lib/buckets/create.ts | 10 ++++++++ src/lib/cp.ts | 49 ++++++++++++++++++++++++++++++++++----- src/lib/mk.ts | 10 ++++++++ src/specs.yaml | 25 ++++++++++++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1c018cb..d01e11b 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ tigris mk [flags] | `-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) | +| `--allow-object-acl` | Allow per-object ACLs on the bucket (only applies when creating a bucket) (default: false) | +| `--enable-directory-listing` | Enable directory listing, relevant for public buckets (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 | @@ -231,6 +233,8 @@ tigris mk [flags] ```bash tigris mk my-bucket tigris mk my-bucket --access public --region iad +tigris mk my-bucket --allow-object-acl +tigris mk my-bucket --public --enable-directory-listing tigris mk my-bucket/images/ tigris mk t3://my-bucket tigris mk my-fork --fork-of my-bucket @@ -308,10 +312,12 @@ tigris cp [flags] | Flag | Description | |------|-------------| | `-r, --recursive` | Copy directories recursively | +| `-a, --access` | Access level for uploaded objects (only applies to local-to-remote uploads) | **Examples:** ```bash tigris cp ./file.txt t3://my-bucket/file.txt +tigris cp ./logo.png t3://my-bucket/logo.png --access public 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 @@ -494,6 +500,8 @@ tigris buckets create [name] [flags] | `-a, --access` | Access level (default: private) | | `--public` | Shorthand for --access public | | `-s, --enable-snapshots` | Enable snapshots for the bucket (default: false) | +| `--allow-object-acl` | Allow per-object ACLs on the bucket (default: false) | +| `--enable-directory-listing` | Enable directory listing, relevant for public buckets (default: false) | | `-t, --default-tier` | Choose the default tier for the bucket (default: STANDARD) | | `-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 | @@ -504,6 +512,8 @@ tigris buckets create [name] [flags] tigris buckets create my-bucket tigris buckets create my-bucket --access public --locations iad tigris buckets create my-bucket --enable-snapshots --default-tier STANDARD_IA +tigris buckets create my-bucket --allow-object-acl +tigris buckets create my-bucket --public --enable-directory-listing tigris buckets create my-fork --fork-of my-bucket tigris buckets create my-fork --fork-of my-bucket --source-snapshot 1765889000501544464 ``` diff --git a/src/lib/buckets/create.ts b/src/lib/buckets/create.ts index e53b7c7..931f857 100644 --- a/src/lib/buckets/create.ts +++ b/src/lib/buckets/create.ts @@ -32,6 +32,14 @@ export default async function create(options: Record) { 's', 'S', ]); + const allowObjectAcl = getOption(options, [ + 'allow-object-acl', + 'allowObjectAcl', + ]); + const enableDirectoryListing = getOption(options, [ + 'enable-directory-listing', + 'enableDirectoryListing', + ]); let defaultTier = getOption(options, ['default-tier', 't', 'T']); const locations = getOption(options, ['locations', 'l', 'L']); const forkOf = getOption(options, ['fork-of', 'forkOf', 'fork']); @@ -120,6 +128,8 @@ export default async function create(options: Record) { const { error } = await createBucket(name, { defaultTier: (defaultTier ?? 'STANDARD') as StorageClass, enableSnapshot: enableSnapshots === true, + allowObjectAcl: allowObjectAcl === true, + enableDirectoryListing: enableDirectoryListing === true, access: (access ?? 'private') as 'public' | 'private', locations: parsedLocations ?? parseLocations(locations ?? 'global'), ...(forkOf ? { sourceBucketName: forkOf } : {}), diff --git a/src/lib/cp.ts b/src/lib/cp.ts index 8a30511..7e847de 100644 --- a/src/lib/cp.ts +++ b/src/lib/cp.ts @@ -101,7 +101,8 @@ async function uploadFile( bucket: string, key: string, config: Awaited>, - showProgress = false + showProgress = false, + access?: 'public' | 'private' ): Promise<{ error?: string }> { let fileSize: number | undefined; try { @@ -119,6 +120,7 @@ async function uploadFile( const { error: putError } = await put(key, body, { ...calculateUploadParams(fileSize), ...(contentType ? { contentType } : {}), + ...(access ? { access } : {}), onUploadProgress: showProgress ? ({ loaded }) => { if (fileSize !== undefined && fileSize > 0) { @@ -253,7 +255,8 @@ async function copyLocalToRemote( src: string, destParsed: ParsedPath, config: Awaited>, - recursive: boolean + recursive: boolean, + access?: 'public' | 'private' ) { const localPath = resolveLocalPath(src); const isWildcard = src.includes('*'); @@ -282,7 +285,14 @@ async function copyLocalToRemote( ? `${destParsed.path.replace(/\/$/, '')}/${relPath}` : relPath; - const result = await uploadFile(file, destParsed.bucket, destKey, config); + const result = await uploadFile( + file, + destParsed.bucket, + destKey, + config, + false, + access + ); if (result.error) { console.error(`Failed to upload ${file}: ${result.error}`); return false; @@ -352,7 +362,14 @@ async function copyLocalToRemote( ].filter(Boolean); const destKey = parts.join('/'); - const result = await uploadFile(file, destParsed.bucket, destKey, config); + const result = await uploadFile( + file, + destParsed.bucket, + destKey, + config, + false, + access + ); if (result.error) { console.error(`Failed to upload ${file}: ${result.error}`); return false; @@ -405,7 +422,8 @@ async function copyLocalToRemote( destParsed.bucket, destKey, config, - !_jsonMode + !_jsonMode, + access ); if (result.error) { exitWithError(result.error); @@ -824,10 +842,29 @@ export default async function cp(options: Record) { } const recursive = !!getOption(options, ['recursive', 'r']); + const accessArg = getOption(options, ['access', 'a', 'A']); const format = getFormat(options); _jsonMode = format === 'json'; + let access: 'public' | 'private' | undefined; + if (accessArg === undefined) { + access = undefined; + } else if (accessArg === 'public' || accessArg === 'private') { + access = accessArg; + } else { + exitWithError('Access level must be either "public" or "private"'); + } + const direction = detectDirection(src, dest); + + // --access only affects the object being written, which only happens on + // upload. copy() can't carry access and a downloaded file has none. + if (access !== undefined && direction !== 'local-to-remote') { + exitWithError( + '--access only applies to local-to-remote uploads. Use "tigris objects set-access" to change access on existing objects.' + ); + } + const config = await getStorageConfig({ withCredentialProvider: true }); switch (direction) { @@ -836,7 +873,7 @@ export default async function cp(options: Record) { if (!destParsed.bucket) { exitWithError('Invalid destination path'); } - await copyLocalToRemote(src, destParsed, config, recursive); + await copyLocalToRemote(src, destParsed, config, recursive, access); break; } case 'remote-to-local': { diff --git a/src/lib/mk.ts b/src/lib/mk.ts index d698b25..7153b2a 100644 --- a/src/lib/mk.ts +++ b/src/lib/mk.ts @@ -33,6 +33,14 @@ export default async function mk(options: Record) { 's', 'S', ]); + const allowObjectAcl = getOption(options, [ + 'allow-object-acl', + 'allowObjectAcl', + ]); + const enableDirectoryListing = getOption(options, [ + 'enable-directory-listing', + 'enableDirectoryListing', + ]); const defaultTier = getOption(options, [ 'defaultTier', 'default-tier', @@ -54,6 +62,8 @@ export default async function mk(options: Record) { const { error } = await createBucket(bucket, { defaultTier: (defaultTier ?? 'STANDARD') as StorageClass, enableSnapshot: enableSnapshots === true, + allowObjectAcl: allowObjectAcl === true, + enableDirectoryListing: enableDirectoryListing === true, access: (access ?? 'private') as 'public' | 'private', locations: parseLocations(locations ?? 'global'), ...(forkOf ? { sourceBucketName: forkOf } : {}), diff --git a/src/specs.yaml b/src/specs.yaml index c5d0470..0a457a5 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -298,6 +298,8 @@ commands: examples: - "tigris mk my-bucket" - "tigris mk my-bucket --access public --region iad" + - "tigris mk my-bucket --allow-object-acl" + - "tigris mk my-bucket --public --enable-directory-listing" - "tigris mk my-bucket/images/" - "tigris mk t3://my-bucket" - "tigris mk my-fork --fork-of my-bucket" @@ -327,6 +329,14 @@ commands: alias: s type: flag default: false + - name: allow-object-acl + description: Allow per-object ACLs on the bucket (only applies when creating a bucket) + type: flag + default: false + - name: enable-directory-listing + description: Enable directory listing, relevant for public buckets (only applies when creating a bucket) + type: flag + default: false - name: default-tier description: Default storage tier (only applies when creating a bucket) alias: t @@ -447,6 +457,7 @@ commands: alias: copy examples: - "tigris cp ./file.txt t3://my-bucket/file.txt" + - "tigris cp ./logo.png t3://my-bucket/logo.png --access public" - "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" @@ -476,6 +487,10 @@ commands: type: flag alias: r description: Copy directories recursively + - name: access + description: Access level for uploaded objects (only applies to local-to-remote uploads) + alias: a + options: *access_options # mv - name: mv @@ -699,6 +714,8 @@ commands: - "tigris buckets create my-bucket" - "tigris buckets create my-bucket --access public --locations iad" - "tigris buckets create my-bucket --enable-snapshots --default-tier STANDARD_IA" + - "tigris buckets create my-bucket --allow-object-acl" + - "tigris buckets create my-bucket --public --enable-directory-listing" - "tigris buckets create my-fork --fork-of my-bucket" - "tigris buckets create my-fork --fork-of my-bucket --source-snapshot 1765889000501544464" messages: @@ -730,6 +747,14 @@ commands: alias: s type: flag default: false + - name: allow-object-acl + description: Allow per-object ACLs on the bucket + type: flag + default: false + - name: enable-directory-listing + description: Enable directory listing, relevant for public buckets + type: flag + default: false - name: default-tier description: Choose the default tier for the bucket alias: t From e5abbf066a83fd86d1a966500456c9d603aa2a22 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 25 Jun 2026 19:13:54 +0200 Subject: [PATCH 3/6] feat: restore archived objects and report restore state - objects restore: request restoration of an archived object (e.g. GLACIER) for N days via restoreObject - objects restore-info: report an object's restore state (archived, in-progress, restored) via getRestoreInfo Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 42 ++++++++++++++++++++ src/lib/objects/restore-info.ts | 69 +++++++++++++++++++++++++++++++++ src/lib/objects/restore.ts | 64 ++++++++++++++++++++++++++++++ src/specs.yaml | 56 ++++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 src/lib/objects/restore-info.ts create mode 100644 src/lib/objects/restore.ts diff --git a/README.md b/README.md index d01e11b..2f998b9 100644 --- a/README.md +++ b/README.md @@ -874,6 +874,8 @@ Low-level object operations for listing, downloading, uploading, and deleting in | `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 restore` (rs) | Restore an archived object (e.g. one in the GLACIER tier) into an actively-readable copy for a number of days | +| `tigris objects restore-info` (ri) | Show the restore state of an archived object (archived, in-progress, or restored) | #### `tigris objects list` (l) @@ -1052,6 +1054,46 @@ tigris objects info my-bucket report.pdf --format json tigris objects info my-bucket report.pdf --version-id abc123 ``` +#### `tigris objects restore` (rs) + +Restore an archived object (e.g. one in the GLACIER tier) into an actively-readable copy for a number of days + +``` +tigris objects restore [key] [flags] +``` + +| Flag | Description | +|------|-------------| +| `-d, --days` | How many days the restored copy stays available before reverting to its archived tier (default: 1) | +| `--version-id` | Restore a specific object version (requires bucket versioning). Omit to restore the current version | +| `--format` | Output format (default: table) | + +**Examples:** +```bash +tigris objects restore my-bucket archived.bin +tigris objects restore my-bucket archived.bin --days 3 +tigris objects restore t3://my-bucket/archived.bin --days 7 +``` + +#### `tigris objects restore-info` (ri) + +Show the restore state of an archived object (archived, in-progress, or restored) + +``` +tigris objects restore-info [key] [flags] +``` + +| Flag | Description | +|------|-------------| +| `--version-id` | Inspect a specific object version (requires bucket versioning). Omit to read the current version | +| `--format` | Output format (default: table) | + +**Examples:** +```bash +tigris objects restore-info my-bucket archived.bin +tigris objects restore-info t3://my-bucket/archived.bin --format json +``` + ### `tigris access-keys` (keys) Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access diff --git a/src/lib/objects/restore-info.ts b/src/lib/objects/restore-info.ts new file mode 100644 index 0000000..561b816 --- /dev/null +++ b/src/lib/objects/restore-info.ts @@ -0,0 +1,69 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { getRestoreInfo } from '@tigrisdata/storage'; +import { failWithError } from '@utils/exit.js'; +import { formatOutput } from '@utils/format.js'; +import { msg, printEmpty, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; +import { resolveObjectArgs } from '@utils/path.js'; + +const context = msg('objects', 'restore-info'); + +export default async function restoreInfo(options: Record) { + printStart(context); + + const format = getFormat(options); + const bucketArg = getOption(options, ['bucket']); + const keyArg = getOption(options, ['key']); + const versionId = getOption(options, ['version-id', 'versionId']); + + if (!bucketArg) { + failWithError(context, 'Bucket name or path is required'); + } + + const { bucket, key } = resolveObjectArgs(bucketArg, keyArg); + + if (!key) { + failWithError(context, 'Object key is required'); + } + + const config = await getStorageConfig(); + + const { data, error } = await getRestoreInfo(key, { + ...(versionId ? { versionId } : {}), + config: { + ...config, + bucket, + }, + }); + + if (error) { + failWithError(context, error); + } + + // undefined means there is nothing to restore — a non-archived object or + // one that does not exist. Not an error. + if (!data) { + if (format === 'json') { + console.log(JSON.stringify({ status: null })); + } else { + printEmpty(context); + } + return; + } + + const info = [ + { metric: 'Path', value: key }, + { metric: 'Status', value: data.status }, + ...(data.expiresAt + ? [{ metric: 'Expires', value: data.expiresAt.toISOString() }] + : []), + ]; + + const output = formatOutput(info, format, 'restore-info', 'info', [ + { key: 'metric', header: 'Metric' }, + { key: 'value', header: 'Value' }, + ]); + + console.log(output); + printSuccess(context, { bucket, key }); +} diff --git a/src/lib/objects/restore.ts b/src/lib/objects/restore.ts new file mode 100644 index 0000000..a291501 --- /dev/null +++ b/src/lib/objects/restore.ts @@ -0,0 +1,64 @@ +import { getStorageConfig } from '@auth/provider.js'; +import { restoreObject } from '@tigrisdata/storage'; +import { failWithError } from '@utils/exit.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; +import { resolveObjectArgs } from '@utils/path.js'; + +const context = msg('objects', 'restore'); + +export default async function restore(options: Record) { + printStart(context); + + const format = getFormat(options); + const bucketArg = getOption(options, ['bucket']); + const keyArg = getOption(options, ['key']); + const versionId = getOption(options, ['version-id', 'versionId']); + const daysArg = getOption(options, ['days', 'd']); + + if (!bucketArg) { + failWithError(context, 'Bucket name or path is required'); + } + + const { bucket, key } = resolveObjectArgs(bucketArg, keyArg); + + if (!key) { + failWithError(context, 'Object key is required'); + } + + let days: number | undefined; + if (daysArg !== undefined) { + days = Number(daysArg); + if (!Number.isInteger(days) || days < 1) { + failWithError(context, '--days must be a positive integer'); + } + } + + const config = await getStorageConfig(); + + const { error } = await restoreObject(key, { + ...(days !== undefined ? { days } : {}), + ...(versionId ? { versionId } : {}), + config: { + ...config, + bucket, + }, + }); + + if (error) { + failWithError(context, error); + } + + if (format === 'json') { + console.log( + JSON.stringify({ + action: 'restore-requested', + bucket, + key, + days: days ?? 1, + }) + ); + } + + printSuccess(context, { key, bucket }); +} diff --git a/src/specs.yaml b/src/specs.yaml index 0a457a5..7a23ce4 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -1588,6 +1588,62 @@ commands: alias: snapshot - name: version-id description: Object version id (requires bucket versioning). Omit to read the latest version + # restore + - name: restore + description: Restore an archived object (e.g. one in the GLACIER tier) into an actively-readable copy for a number of days + alias: rs + examples: + - "tigris objects restore my-bucket archived.bin" + - "tigris objects restore my-bucket archived.bin --days 3" + - "tigris objects restore t3://my-bucket/archived.bin --days 7" + messages: + onStart: 'Requesting restore...' + onSuccess: "Restore requested for '{{key}}'" + onFailure: 'Failed to restore object' + arguments: + - name: bucket + type: positional + required: true + description: Name of the bucket, or a full path (t3://bucket/key) + - name: key + type: positional + description: Key of the object (omit if bucket contains the full path) + - name: days + description: How many days the restored copy stays available before reverting to its archived tier + alias: d + default: 1 + - name: version-id + description: Restore a specific object version (requires bucket versioning). Omit to restore the current version + - name: format + description: Output format + options: [json, table, xml] + default: table + # restore-info + - name: restore-info + description: Show the restore state of an archived object (archived, in-progress, or restored) + alias: ri + examples: + - "tigris objects restore-info my-bucket archived.bin" + - "tigris objects restore-info t3://my-bucket/archived.bin --format json" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to get restore info' + onEmpty: 'No restore information for this object (it is not archived)' + arguments: + - name: bucket + type: positional + required: true + description: Name of the bucket, or a full path (t3://bucket/key) + - name: key + type: positional + description: Key of the object (omit if bucket contains the full path) + - name: version-id + description: Inspect a specific object version (requires bucket versioning). Omit to read the current version + - name: format + description: Output format + options: [json, table, xml] + default: table ######################### # Manage access keys From 2d32db9dda49ad73818f3c1689ee9fbb90379f94 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 25 Jun 2026 19:24:05 +0200 Subject: [PATCH 4/6] feat: manage organization teams Add `iam teams list/create/edit`, mirroring `iam users`: - list: show teams (member count in table, full members in JSON) - create: create a team with optional description and member emails - edit: update name/description/members (members replaced with the list) Gated to OAuth + non-Fly orgs like the rest of iam user management. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 70 ++++++++++++++++++++++++++++++++++ src/lib/iam/teams/create.ts | 53 ++++++++++++++++++++++++++ src/lib/iam/teams/edit.ts | 63 +++++++++++++++++++++++++++++++ src/lib/iam/teams/list.ts | 63 +++++++++++++++++++++++++++++++ src/specs.yaml | 75 +++++++++++++++++++++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/lib/iam/teams/create.ts create mode 100644 src/lib/iam/teams/edit.ts create mode 100644 src/lib/iam/teams/list.ts diff --git a/README.md b/README.md index 2f998b9..9933dc5 100644 --- a/README.md +++ b/README.md @@ -1276,6 +1276,7 @@ Identity and Access Management - manage policies, users, and permissions |---------|-------------| | `tigris iam policies` (p) | Manage IAM policies. Policies define permissions for access keys | | `tigris iam users` (u) | Manage organization users and invitations | +| `tigris iam teams` (t) | Manage organization teams | #### `tigris iam policies` (p) @@ -1549,6 +1550,75 @@ tigris iam users remove user@example.com --yes tigris iam users remove user@example.com,user@example.net --yes ``` +#### `tigris iam teams` (t) + +Manage organization teams + +| Command | Description | +|---------|-------------| +| `tigris iam teams list` (l) | List all teams in the organization | +| `tigris iam teams create` (c) | Create a new team in the organization | +| `tigris iam teams edit` (e) | Update a team's name, description, or members. Members are replaced with the provided list | + +##### `tigris iam teams list` (l) + +List all teams in the organization + +``` +tigris iam teams list [flags] +``` + +| Flag | Description | +|------|-------------| +| `--format` | Output format (default: table) | + +**Examples:** +```bash +tigris iam teams list +tigris iam teams list --format json +``` + +##### `tigris iam teams create` (c) + +Create a new team in the organization + +``` +tigris iam teams create [flags] +``` + +| Flag | Description | +|------|-------------| +| `-d, --description` | Description for the team | +| `-m, --members` | Member email address(es) to add (comma-separated for multiple) | + +**Examples:** +```bash +tigris iam teams create engineering +tigris iam teams create engineering --description 'Engineering team' +tigris iam teams create engineering --members a@example.com,b@example.com +``` + +##### `tigris iam teams edit` (e) + +Update a team's name, description, or members. Members are replaced with the provided list + +``` +tigris iam teams edit [flags] +``` + +| Flag | Description | +|------|-------------| +| `-n, --name` | New name for the team | +| `-d, --description` | New description for the team | +| `-m, --members` | Replace the team's members with these email address(es) (comma-separated for multiple) | + +**Examples:** +```bash +tigris iam teams edit team_id --name platform +tigris iam teams edit team_id --description 'Platform team' +tigris iam teams edit team_id --members a@example.com,b@example.com +``` + ## License MIT diff --git a/src/lib/iam/teams/create.ts b/src/lib/iam/teams/create.ts new file mode 100644 index 0000000..0be6fe8 --- /dev/null +++ b/src/lib/iam/teams/create.ts @@ -0,0 +1,53 @@ +import { getOAuthIAMConfig, isFlyOrganization } from '@auth/iam.js'; +import { createTeam } from '@tigrisdata/iam'; +import { failWithError } from '@utils/exit.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +const context = msg('iam teams', 'create'); + +export default async function create(options: Record) { + printStart(context); + + const format = getFormat(options); + + if (isFlyOrganization('Team management')) return; + + const name = getOption(options, ['name']); + const description = getOption(options, ['description', 'd']); + const membersOption = getOption(options, ['members', 'm']); + + if (!name) { + failWithError(context, 'Team name is required'); + } + + const members = Array.isArray(membersOption) + ? membersOption + : membersOption + ? [membersOption] + : undefined; + + const iamConfig = await getOAuthIAMConfig(context); + + const { data, error } = await createTeam( + { + name, + ...(description !== undefined ? { description } : {}), + ...(members ? { members } : {}), + }, + { config: iamConfig } + ); + + if (error) { + failWithError(context, error); + } + + if (format === 'json') { + console.log( + JSON.stringify({ action: 'created', teamId: data.teamId, name }) + ); + } + + printSuccess(context, { name }); + console.log(`Team ID: ${data.teamId}`); +} diff --git a/src/lib/iam/teams/edit.ts b/src/lib/iam/teams/edit.ts new file mode 100644 index 0000000..d4738a3 --- /dev/null +++ b/src/lib/iam/teams/edit.ts @@ -0,0 +1,63 @@ +import { getOAuthIAMConfig, isFlyOrganization } from '@auth/iam.js'; +import { editTeam } from '@tigrisdata/iam'; +import { failWithError } from '@utils/exit.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; + +const context = msg('iam teams', 'edit'); + +export default async function edit(options: Record) { + printStart(context); + + const format = getFormat(options); + + if (isFlyOrganization('Team management')) return; + + const id = getOption(options, ['id']); + const name = getOption(options, ['name', 'n']); + const description = getOption(options, ['description', 'd']); + const membersOption = getOption(options, ['members', 'm']); + + if (!id) { + failWithError(context, 'Team ID is required'); + } + + const members = Array.isArray(membersOption) + ? membersOption + : membersOption + ? [membersOption] + : undefined; + + if ( + name === undefined && + description === undefined && + members === undefined + ) { + failWithError( + context, + 'Provide at least one of --name, --description, or --members to update' + ); + } + + const iamConfig = await getOAuthIAMConfig(context); + + const { error } = await editTeam( + id, + { + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(members ? { members } : {}), + }, + { config: iamConfig } + ); + + if (error) { + failWithError(context, error); + } + + if (format === 'json') { + console.log(JSON.stringify({ action: 'updated', teamId: id })); + } + + printSuccess(context, { id }); +} diff --git a/src/lib/iam/teams/list.ts b/src/lib/iam/teams/list.ts new file mode 100644 index 0000000..1e3ab36 --- /dev/null +++ b/src/lib/iam/teams/list.ts @@ -0,0 +1,63 @@ +import { getOAuthIAMConfig, isFlyOrganization } from '@auth/iam.js'; +import { listTeams } from '@tigrisdata/iam'; +import { failWithError } from '@utils/exit.js'; +import { + formatJson, + formatTable, + formatXml, + type TableColumn, +} from '@utils/format.js'; +import { msg, printEmpty, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat } from '@utils/options.js'; + +const context = msg('iam teams', 'list'); + +export default async function list(options: Record) { + printStart(context); + + const format = getFormat(options); + + if (isFlyOrganization('Team management')) return; + + const iamConfig = await getOAuthIAMConfig(context); + + const { data, error } = await listTeams({ + config: iamConfig, + }); + + if (error) { + failWithError(context, error); + } + + if (data.teams.length === 0) { + printEmpty(context); + return; + } + + const rows = data.teams.map((team) => ({ + id: team.id, + name: team.name, + description: team.description || '-', + members: team.members.length, + created: team.createdAt, + })); + + const columns: TableColumn[] = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'description', header: 'Description' }, + { key: 'members', header: 'Members' }, + { key: 'created', header: 'Created' }, + ]; + + if (format === 'json') { + // Keep the full member list (rows collapse it to a count for tables). + console.log(formatJson({ teams: data.teams })); + } else if (format === 'xml') { + console.log(formatXml(rows, 'teams', 'team')); + } else { + console.log(formatTable(rows, columns)); + } + + printSuccess(context, { count: data.teams.length }); +} diff --git a/src/specs.yaml b/src/specs.yaml index 7a23ce4..b71d15a 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -2180,3 +2180,78 @@ commands: - name: force type: flag description: Skip confirmation prompts (alias for --yes) + - name: teams + description: Manage organization teams + alias: t + examples: + - "tigris iam teams list" + - "tigris iam teams create engineering --members user@example.com" + - "tigris iam teams edit team_id --name platform" + commands: + - name: list + description: List all teams in the organization + alias: l + examples: + - "tigris iam teams list" + - "tigris iam teams list --format json" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to list teams' + onEmpty: 'No teams found in this organization' + arguments: + - name: format + description: Output format + options: [json, table, xml] + default: table + - name: create + description: Create a new team in the organization + alias: c + examples: + - "tigris iam teams create engineering" + - "tigris iam teams create engineering --description 'Engineering team'" + - "tigris iam teams create engineering --members a@example.com,b@example.com" + messages: + onStart: 'Creating team...' + onSuccess: "Team '{{name}}' created" + onFailure: 'Failed to create team' + arguments: + - name: name + description: Name of the team + type: positional + required: true + examples: + - engineering + - name: description + description: Description for the team + alias: d + - name: members + description: Member email address(es) to add (comma-separated for multiple) + alias: m + multiple: true + - name: edit + description: Update a team's name, description, or members. Members are replaced with the provided list + alias: e + examples: + - "tigris iam teams edit team_id --name platform" + - "tigris iam teams edit team_id --description 'Platform team'" + - "tigris iam teams edit team_id --members a@example.com,b@example.com" + messages: + onStart: 'Updating team...' + onSuccess: "Team updated" + onFailure: 'Failed to update team' + arguments: + - name: id + description: ID of the team to edit + type: positional + required: true + - name: name + description: New name for the team + alias: n + - name: description + description: New description for the team + alias: d + - name: members + description: Replace the team's members with these email address(es) (comma-separated for multiple) + alias: m + multiple: true From 0386fe6866e9c4d0b99c80ee5f792671c6ddca3b Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 25 Jun 2026 19:34:21 +0200 Subject: [PATCH 5/6] test: integration tests for object ACL, restore, and teams - bucket ACL/listing: mk + buckets create with --allow-object-acl (verified via buckets get) and --enable-directory-listing - cp --access: upload sets access; rejected for non-upload directions and invalid values - objects restore/restore-info: restore-info on a non-archived object, --days validation, missing-key error - iam teams: create/list/edit (OAuth-gated) plus an always-on edit validation check Also stop `iam teams create --format json` from printing a trailing Team ID line that polluted the JSON output. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/iam/teams/create.ts | 1 + test/cli.test.ts | 171 ++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/src/lib/iam/teams/create.ts b/src/lib/iam/teams/create.ts index 0be6fe8..b8bc891 100644 --- a/src/lib/iam/teams/create.ts +++ b/src/lib/iam/teams/create.ts @@ -46,6 +46,7 @@ export default async function create(options: Record) { console.log( JSON.stringify({ action: 'created', teamId: data.teamId, name }) ); + return; } printSuccess(context, { name }); diff --git a/test/cli.test.ts b/test/cli.test.ts index a9848ed..d99d882 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1894,6 +1894,146 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); }); + describe('bucket ACL and directory listing', () => { + const aclBuckets: string[] = []; + + afterAll(() => { + for (const b of aclBuckets) { + runCli(`rm ${t3(b)} -f`); + } + }); + + it('mk should create a bucket with --allow-object-acl', () => { + const name = `${testPrefix}-acl-mk`; + aclBuckets.push(name); + const result = runCli(`mk ${name} --allow-object-acl`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + + // buckets get surfaces the setting as "Allow Object ACL: Yes" + const info = runCli(`buckets get ${name}`); + expect(info.exitCode).toBe(0); + expect(info.stdout).toContain('Allow Object ACL'); + expect(info.stdout).toContain('Yes'); + }); + + it('buckets create should create a bucket with --allow-object-acl', () => { + const name = `${testPrefix}-acl-bc`; + aclBuckets.push(name); + const result = runCli(`buckets create ${name} --allow-object-acl`); + expect(result.exitCode).toBe(0); + + const info = runCli(`buckets get ${name}`); + expect(info.stdout).toContain('Allow Object ACL'); + expect(info.stdout).toContain('Yes'); + }); + + it('mk should create a public bucket with --enable-directory-listing', () => { + // Directory listing is not surfaced by buckets get, so we only assert + // that creation with the flag succeeds. + const name = `${testPrefix}-dirlist`; + aclBuckets.push(name); + const result = runCli(`mk ${name} --public --enable-directory-listing`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + }); + }); + + describe('cp command - access', () => { + const tmpBase = join(tmpdir(), `cli-test-cpaccess-${testPrefix}`); + + beforeAll(() => { + mkdirSync(tmpBase, { recursive: true }); + }); + + afterAll(() => { + rmSync(tmpBase, { recursive: true, force: true }); + runCli(`rm ${t3(testBucket)}/cp-access-pub.txt -f`); + }); + + it('should upload local->remote with --access public', () => { + const tmpFile = join(tmpBase, 'pub.txt'); + writeFileSync(tmpFile, 'public via cp'); + const result = runCli( + `cp ${tmpFile} ${t3(testBucket)}/cp-access-pub.txt --access public` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Uploaded'); + }); + + it('should reject --access on remote-to-remote copies', () => { + const result = runCli( + `cp ${t3(testBucket)}/cp-access-pub.txt ${t3(testBucket)}/cp-access-copy.txt --access public` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'only applies to local-to-remote uploads' + ); + }); + + it('should reject an invalid --access value', () => { + const tmpFile = join(tmpBase, 'inv.txt'); + writeFileSync(tmpFile, 'x'); + const result = runCli( + `cp ${tmpFile} ${t3(testBucket)}/cp-access-inv.txt --access maybe` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Access level must be either'); + }); + }); + + describe('objects restore / restore-info', () => { + const restoreFile = 'restore-test.txt'; + + beforeAll(() => { + runCli(`touch ${testBucket}/${restoreFile}`); + }); + + afterAll(() => { + runCli(`rm ${t3(testBucket)}/${restoreFile} -f`); + }); + + it('restore-info should report no restore info for a non-archived object', () => { + const result = runCli( + `objects restore-info ${testBucket} ${restoreFile} --format json` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('"status":null'); + }); + + it('restore should reject a non-positive --days', () => { + const result = runCli( + `objects restore ${testBucket} ${restoreFile} --days 0` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--days must be a positive integer'); + }); + + it('restore should reject a non-numeric --days', () => { + const result = runCli( + `objects restore ${testBucket} ${restoreFile} --days abc` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--days must be a positive integer'); + }); + + it('restore-info should error when the object key is missing', () => { + const result = runCli(`objects restore-info ${testBucket}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Object key is required'); + }); + }); + + describe('iam teams - validation', () => { + // Pure CLI validation — fails before any OAuth/network call, so it runs + // without OAuth credentials. + it('edit should error when no fields are provided', () => { + const result = runCli('iam teams edit some-team-id'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Provide at least one of'); + }); + }); + describe('objects commands with t3:// paths', () => { const tmpBase = join(tmpdir(), `cli-test-t3path-${testPrefix}`); @@ -2341,6 +2481,37 @@ describe.skipIf(skipTests || skipOAuth)('OAuth Integration Tests', () => { }); }); + describe('iam teams', () => { + let createdTeamId: string | undefined; + const teamName = `${getTestPrefix()}-team`; + + it('should create a team', () => { + const result = runCli(`iam teams create ${teamName} --format json`); + // May fail for Fly orgs — that's expected + if (result.exitCode === 0 && result.stdout.trim()) { + const parsed = JSON.parse(result.stdout.trim()) as { teamId?: string }; + expect(parsed.teamId).toBeTruthy(); + createdTeamId = parsed.teamId; + } + }); + + it('should list teams', () => { + const result = runCli('iam teams list --format json'); + if (result.exitCode === 0 && result.stdout.trim()) { + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + } + }); + + it('should edit the created team', () => { + // Skip if creation didn't yield an id (e.g. Fly org). + if (!createdTeamId) return; + const result = runCli( + `iam teams edit ${createdTeamId} --name ${teamName}-renamed` + ); + expect(result.exitCode).toBe(0); + }); + }); + describe('organizations', () => { it('should list organizations with --format table', () => { const result = runCli('organizations list --format table'); From 71437f853510f089b2f18825116bae1f7afa1bce Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Fri, 26 Jun 2026 15:34:23 +0200 Subject: [PATCH 6/6] fix(iam teams): normalize the --members flag A valueless `--members` flag arrives as boolean `true` and an empty `--members ""` as "", so the previous wrapping could send `[true]` to the API or silently drop the flag (and misfire the "at least one field" check). Add parseMembers to normalize the flag into a clean email list, erroring when it is present without a usable value. Also coerce a bare --name / --description so they can't forward a non-string. Covered by new validation tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/iam/teams/create.ts | 20 ++++++++++++-------- src/lib/iam/teams/edit.ts | 22 ++++++++++++++-------- src/lib/iam/teams/shared.ts | 29 +++++++++++++++++++++++++++++ test/cli.test.ts | 12 ++++++++++++ 4 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 src/lib/iam/teams/shared.ts diff --git a/src/lib/iam/teams/create.ts b/src/lib/iam/teams/create.ts index b8bc891..c4d7da1 100644 --- a/src/lib/iam/teams/create.ts +++ b/src/lib/iam/teams/create.ts @@ -4,6 +4,8 @@ import { failWithError } from '@utils/exit.js'; import { msg, printStart, printSuccess } from '@utils/messages.js'; import { getFormat, getOption } from '@utils/options.js'; +import { parseMembers } from './shared.js'; + const context = msg('iam teams', 'create'); export default async function create(options: Record) { @@ -14,19 +16,21 @@ export default async function create(options: Record) { if (isFlyOrganization('Team management')) return; const name = getOption(options, ['name']); - const description = getOption(options, ['description', 'd']); - const membersOption = getOption(options, ['members', 'm']); + const descriptionRaw = getOption(options, [ + 'description', + 'd', + ]); + const description = + typeof descriptionRaw === 'string' ? descriptionRaw : undefined; + const members = parseMembers( + context, + getOption(options, ['members', 'm']) + ); if (!name) { failWithError(context, 'Team name is required'); } - const members = Array.isArray(membersOption) - ? membersOption - : membersOption - ? [membersOption] - : undefined; - const iamConfig = await getOAuthIAMConfig(context); const { data, error } = await createTeam( diff --git a/src/lib/iam/teams/edit.ts b/src/lib/iam/teams/edit.ts index d4738a3..1fb312b 100644 --- a/src/lib/iam/teams/edit.ts +++ b/src/lib/iam/teams/edit.ts @@ -4,6 +4,8 @@ import { failWithError } from '@utils/exit.js'; import { msg, printStart, printSuccess } from '@utils/messages.js'; import { getFormat, getOption } from '@utils/options.js'; +import { parseMembers } from './shared.js'; + const context = msg('iam teams', 'edit'); export default async function edit(options: Record) { @@ -14,19 +16,23 @@ export default async function edit(options: Record) { if (isFlyOrganization('Team management')) return; const id = getOption(options, ['id']); - const name = getOption(options, ['name', 'n']); - const description = getOption(options, ['description', 'd']); - const membersOption = getOption(options, ['members', 'm']); if (!id) { failWithError(context, 'Team ID is required'); } - const members = Array.isArray(membersOption) - ? membersOption - : membersOption - ? [membersOption] - : undefined; + const nameRaw = getOption(options, ['name', 'n']); + const name = typeof nameRaw === 'string' ? nameRaw : undefined; + const descriptionRaw = getOption(options, [ + 'description', + 'd', + ]); + const description = + typeof descriptionRaw === 'string' ? descriptionRaw : undefined; + const members = parseMembers( + context, + getOption(options, ['members', 'm']) + ); if ( name === undefined && diff --git a/src/lib/iam/teams/shared.ts b/src/lib/iam/teams/shared.ts new file mode 100644 index 0000000..b415cf0 --- /dev/null +++ b/src/lib/iam/teams/shared.ts @@ -0,0 +1,29 @@ +import { failWithError } from '@utils/exit.js'; +import type { MessageContext } from '@utils/messages.js'; + +/** + * Normalize the repeatable `--members` flag into a clean list of emails. + * + * cli-core delivers it as `undefined` (flag omitted), a `string[]` (one or + * more comma-separated values), or boolean `true` (the flag passed with no + * value). Returns `undefined` when omitted so callers can distinguish "not + * provided" from "provided"; errors when the flag is present but carries no + * usable value, rather than silently dropping it or forwarding a non-email + * (e.g. `true`) to the API. + */ +export function parseMembers( + context: MessageContext, + raw: string | string[] | boolean | undefined +): string[] | undefined { + if (raw === undefined) return undefined; + + const members = (Array.isArray(raw) ? raw : [raw]) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0); + + if (members.length === 0) { + failWithError(context, '--members requires at least one email address'); + } + + return members; +} diff --git a/test/cli.test.ts b/test/cli.test.ts index d99d882..b428dee 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2032,6 +2032,18 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Provide at least one of'); }); + + it('create should reject a valueless --members flag', () => { + const result = runCli(`iam teams create ${testPrefix}-noval --members`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires at least one email address'); + }); + + it('edit should reject an empty --members value', () => { + const result = runCli('iam teams edit some-team-id --members ""'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires at least one email address'); + }); }); describe('objects commands with t3:// paths', () => {