diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..7bcfae5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + branches: + - main + - next + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - run: npm run test + - run: npm run build + + - uses: oven-sh/setup-bun@v2 + - run: bun run build:binary + + integration: + runs-on: ubuntu-latest + if: github.event_name == 'push' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - run: npm run build + - run: npm run test:integration + env: + TIGRIS_STORAGE_ACCESS_KEY_ID: ${{ secrets.TIGRIS_STORAGE_ACCESS_KEY_ID }} + TIGRIS_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.TIGRIS_STORAGE_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml deleted file mode 100644 index f4be17e..0000000 --- a/.github/workflows/pr.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: Pull Request - -on: - pull_request: - branches: - - main - - next - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: '22' - - run: npm ci - - run: npm run test - - - run: npm run build - - - uses: oven-sh/setup-bun@v2 - - run: bun run build:binary diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 9aff9f5..29a2208 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -34,7 +34,10 @@ jobs: - run: npm ci - run: npm run lint - run: npm run build - - run: npm run test + - run: npm run test:all + env: + TIGRIS_STORAGE_ACCESS_KEY_ID: ${{ secrets.TIGRIS_STORAGE_ACCESS_KEY_ID }} + TIGRIS_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.TIGRIS_STORAGE_SECRET_ACCESS_KEY }} - run: npm run publint - run: npm audit signatures diff --git a/package.json b/package.json index 173ef89..31860c8 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,9 @@ "lint:fix": "eslint src --fix", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", - "test": "vitest run", + "test": "vitest run test/utils test/cli-core.test.ts test/specs-completeness.test.ts", "test:watch": "vitest", - "test:unit": "vitest run test/utils", + "test:all": "vitest run", "test:integration": "vitest run test/cli.test.ts", "publint": "publint", "updatedocs": "tsx scripts/update-docs.ts", diff --git a/src/cli-core.ts b/src/cli-core.ts index fff3d63..a19d932 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -515,6 +515,7 @@ export function createProgram(config: CLIConfig): CommanderCommand { const program = new CommanderCommand(); program.name(specs.name).description(specs.description).version(version); + program.option('-y, --yes', 'Skip all confirmation prompts'); registerCommands(config, program, specs.commands); diff --git a/src/lib/access-keys/assign.ts b/src/lib/access-keys/assign.ts index a7cf714..5b388e3 100644 --- a/src/lib/access-keys/assign.ts +++ b/src/lib/access-keys/assign.ts @@ -24,6 +24,11 @@ function normalizeToArray(value: T | T[] | undefined): T[] { export default async function assign(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const id = getOption(options, ['id']); const admin = getOption(options, ['admin']); const revokeRoles = getOption(options, [ @@ -83,6 +88,10 @@ export default async function assign(options: Record) { process.exit(1); } + if (format === 'json') { + console.log(JSON.stringify({ action: 'revoked', id })); + } + printSuccess(context); return; } @@ -149,5 +158,9 @@ export default async function assign(options: Record) { process.exit(1); } + if (format === 'json') { + console.log(JSON.stringify({ action: 'assigned', id, assignments })); + } + printSuccess(context); } diff --git a/src/lib/access-keys/create.ts b/src/lib/access-keys/create.ts index df86eaf..2dbe5fe 100644 --- a/src/lib/access-keys/create.ts +++ b/src/lib/access-keys/create.ts @@ -16,6 +16,11 @@ const context = msg('access-keys', 'create'); export default async function create(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const name = getOption(options, ['name']); if (!name) { @@ -58,13 +63,24 @@ export default async function create(options: Record) { process.exit(1); } - console.log(` Name: ${data.name}`); - console.log(` Access Key ID: ${data.id}`); - console.log(` Secret Access Key: ${data.secret}`); - console.log(''); - console.log( - ' Save these credentials securely. The secret will not be shown again.' - ); + if (format === 'json') { + console.log( + JSON.stringify({ + action: 'created', + name: data.name, + id: data.id, + secret: data.secret, + }) + ); + } else { + console.log(` Name: ${data.name}`); + console.log(` Access Key ID: ${data.id}`); + console.log(` Secret Access Key: ${data.secret}`); + console.log(''); + console.log( + ' Save these credentials securely. The secret will not be shown again.' + ); + } printSuccess(context); } diff --git a/src/lib/access-keys/delete.ts b/src/lib/access-keys/delete.ts index 5898aca..8143ba9 100644 --- a/src/lib/access-keys/delete.ts +++ b/src/lib/access-keys/delete.ts @@ -10,13 +10,20 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { requireInteractive, confirm } from '../../utils/interactive.js'; const context = msg('access-keys', 'delete'); export default async function remove(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const id = getOption(options, ['id']); + const force = getOption(options, ['force', 'yes', 'y']); if (!id) { printFailure(context, 'Access key ID is required'); @@ -41,6 +48,15 @@ export default async function remove(options: Record) { process.exit(1); } + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm(`Delete access key '${id}'?`); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + const accessToken = await authClient.getAccessToken(); const selectedOrg = getSelectedOrganization(); const tigrisConfig = getTigrisConfig(); @@ -58,5 +74,9 @@ export default async function remove(options: Record) { process.exit(1); } + if (format === 'json') { + console.log(JSON.stringify({ action: 'deleted', id })); + } + printSuccess(context); } diff --git a/src/lib/access-keys/get.ts b/src/lib/access-keys/get.ts index ee5e81d..23c9129 100644 --- a/src/lib/access-keys/get.ts +++ b/src/lib/access-keys/get.ts @@ -16,6 +16,11 @@ const context = msg('access-keys', 'get'); export default async function get(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const id = getOption(options, ['id']); if (!id) { @@ -58,19 +63,23 @@ export default async function get(options: Record) { process.exit(1); } - console.log(` Name: ${data.name}`); - console.log(` ID: ${data.id}`); - console.log(` Status: ${data.status}`); - console.log(` Created: ${data.createdAt}`); - console.log(` Organization: ${data.organizationId}`); + if (format === 'json') { + console.log(JSON.stringify(data)); + } else { + console.log(` Name: ${data.name}`); + console.log(` ID: ${data.id}`); + console.log(` Status: ${data.status}`); + console.log(` Created: ${data.createdAt}`); + console.log(` Organization: ${data.organizationId}`); - if (data.roles && data.roles.length > 0) { - console.log(` Roles:`); - for (const role of data.roles) { - console.log(` - ${role.bucket}: ${role.role}`); + if (data.roles && data.roles.length > 0) { + console.log(` Roles:`); + for (const role of data.roles) { + console.log(` - ${role.bucket}: ${role.role}`); + } + } else { + console.log(` Roles: None`); } - } else { - console.log(` Roles: None`); } printSuccess(context); diff --git a/src/lib/access-keys/list.ts b/src/lib/access-keys/list.ts index 6c2e740..ceddbb5 100644 --- a/src/lib/access-keys/list.ts +++ b/src/lib/access-keys/list.ts @@ -1,4 +1,5 @@ import { formatOutput } from '../../utils/format.js'; +import { getOption } from '../../utils/options.js'; import { getLoginMethod } from '../../auth/s3-client.js'; import { getAuthClient } from '../../auth/client.js'; import { getSelectedOrganization } from '../../auth/storage.js'; @@ -14,9 +15,14 @@ import { const context = msg('access-keys', 'list'); -export default async function list() { +export default async function list(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const loginMethod = await getLoginMethod(); if (loginMethod !== 'oauth') { @@ -64,7 +70,7 @@ export default async function list() { created: key.createdAt, })); - const output = formatOutput(keys, 'table', 'keys', 'key', [ + const output = formatOutput(keys, format!, 'keys', 'key', [ { key: 'name', header: 'Name' }, { key: 'id', header: 'ID' }, { key: 'status', header: 'Status' }, diff --git a/src/lib/buckets/create.ts b/src/lib/buckets/create.ts index c63e8f9..f625e6f 100644 --- a/src/lib/buckets/create.ts +++ b/src/lib/buckets/create.ts @@ -1,5 +1,6 @@ import { getOption } from '../../utils/options'; import enquirer from 'enquirer'; +import { requireInteractive } from '../../utils/interactive.js'; import { getArgumentSpec, buildPromptChoices } from '../../utils/specs.js'; import { StorageClass, createBucket } from '@tigrisdata/storage'; import { getStorageConfig } from '../../auth/s3-client'; @@ -19,6 +20,11 @@ const context = msg('buckets', 'create'); export default async function create(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + let name = getOption(options, ['name']); const isPublic = getOption(options, ['public']); let access = isPublic @@ -65,6 +71,8 @@ export default async function create(options: Record) { let parsedLocations: BucketLocations | undefined; if (interactive) { + requireInteractive('Provide --name to skip interactive mode'); + const accessSpec = getArgumentSpec('buckets', 'access', 'create'); const accessChoices = buildPromptChoices(accessSpec!); const accessDefault = accessChoices?.findIndex( @@ -151,5 +159,11 @@ export default async function create(options: Record) { process.exit(1); } + if (format === 'json') { + console.log( + JSON.stringify({ action: 'created', name, ...(forkOf ? { forkOf } : {}) }) + ); + } + printSuccess(context, { name }); } diff --git a/src/lib/buckets/delete.ts b/src/lib/buckets/delete.ts index 94a7af9..7cc6520 100644 --- a/src/lib/buckets/delete.ts +++ b/src/lib/buckets/delete.ts @@ -7,13 +7,20 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { requireInteractive, confirm } from '../../utils/interactive.js'; const context = msg('buckets', 'delete'); export default async function deleteBucket(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const names = getOption(options, ['name']); + const force = getOption(options, ['force', 'yes', 'y']); if (!names) { printFailure(context, 'Bucket name is required'); @@ -23,14 +30,34 @@ export default async function deleteBucket(options: Record) { const bucketNames = Array.isArray(names) ? names : [names]; const config = await getStorageConfig(); + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm(`Delete ${bucketNames.length} bucket(s)?`); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + + const deleted: string[] = []; + const errors: { name: string; error: string }[] = []; for (const name of bucketNames) { const { error } = await removeBucket(name, { config }); if (error) { printFailure(context, error.message, { name }); - process.exit(1); + errors.push({ name, error: error.message }); + } else { + deleted.push(name); + printSuccess(context, { name }); } + } + + if (format === 'json') { + console.log(JSON.stringify({ action: 'deleted', names: deleted, errors })); + } - printSuccess(context, { name }); + if (errors.length > 0) { + process.exit(1); } } diff --git a/src/lib/buckets/get.ts b/src/lib/buckets/get.ts index 9462168..48844e3 100644 --- a/src/lib/buckets/get.ts +++ b/src/lib/buckets/get.ts @@ -16,7 +16,10 @@ export default async function get(options: Record) { printStart(context); const name = getOption(options, ['name']); - const format = getOption(options, ['format']) || 'table'; + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F']) || 'table'; if (!name) { printFailure(context, 'Bucket name is required'); diff --git a/src/lib/buckets/list.ts b/src/lib/buckets/list.ts index 02387b8..8391a6b 100644 --- a/src/lib/buckets/list.ts +++ b/src/lib/buckets/list.ts @@ -16,7 +16,10 @@ export default async function list(options: Record) { printStart(context); try { - const format = getOption(options, ['format', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const forksOf = getOption(options, ['forks-of', 'forksOf']); const config = await getStorageConfig(); diff --git a/src/lib/buckets/set-locations.ts b/src/lib/buckets/set-locations.ts index 4b03ef3..6720032 100644 --- a/src/lib/buckets/set-locations.ts +++ b/src/lib/buckets/set-locations.ts @@ -4,6 +4,7 @@ import { getSelectedOrganization } from '../../auth/storage.js'; import { updateBucket } from '@tigrisdata/storage'; import type { BucketLocations } from '@tigrisdata/storage'; import { parseLocations, promptLocations } from '../../utils/locations.js'; +import { requireInteractive } from '../../utils/interactive.js'; import { printStart, printSuccess, @@ -28,6 +29,7 @@ export default async function setLocations(options: Record) { if (locations !== undefined) { parsedLocations = parseLocations(locations); } else { + requireInteractive('Provide --locations flag'); try { parsedLocations = await promptLocations(); } catch (err) { diff --git a/src/lib/buckets/set.ts b/src/lib/buckets/set.ts index e9279df..2d70f3a 100644 --- a/src/lib/buckets/set.ts +++ b/src/lib/buckets/set.ts @@ -15,6 +15,11 @@ const context = msg('buckets', 'set'); export default async function set(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format'], 'table'); + const name = getOption(options, ['name']); const access = getOption(options, ['access']); let locations = getOption(options, ['locations']); @@ -134,5 +139,9 @@ export default async function set(options: Record) { process.exit(1); } + if (format === 'json') { + console.log(JSON.stringify({ action: 'updated', name })); + } + printSuccess(context, { name }); } diff --git a/src/lib/configure/index.ts b/src/lib/configure/index.ts index e4b65db..0795a6d 100644 --- a/src/lib/configure/index.ts +++ b/src/lib/configure/index.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive } from '../../utils/interactive.js'; import { storeCredentials, storeLoginMethod } from '../../auth/storage.js'; import { DEFAULT_STORAGE_ENDPOINT } from '../../constants.js'; import { @@ -61,6 +62,8 @@ export default async function configure(options: Record) { }); } + requireInteractive('Provide --access-key, --access-secret, and --endpoint'); + const responses = await prompt<{ accessKey?: string; accessSecret?: string; diff --git a/src/lib/cp.ts b/src/lib/cp.ts index eebd236..7937657 100644 --- a/src/lib/cp.ts +++ b/src/lib/cp.ts @@ -26,6 +26,8 @@ import { executeWithConcurrency } from '../utils/concurrency.js'; import { calculateUploadParams } from '../utils/upload.js'; import type { ParsedPath } from '../types.js'; +let _jsonMode = false; + type CopyDirection = 'local-to-remote' | 'remote-to-local' | 'remote-to-remote'; function detectDirection(src: string, dest: string): CopyDirection { @@ -287,7 +289,17 @@ async function copyLocalToRemote( if (isWildcard) { const files = listLocalFilesWithWildcard(localPath, recursive); if (files.length === 0) { - console.log('No files matching pattern'); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'local-to-remote', + count: 0, + }) + ); + } else { + console.log('No files matching pattern'); + } return; } @@ -303,13 +315,26 @@ async function copyLocalToRemote( console.error(`Failed to upload ${file}: ${result.error}`); return false; } else { - console.log(`Uploaded ${file} -> t3://${destParsed.bucket}/${destKey}`); + if (!_jsonMode) + console.log( + `Uploaded ${file} -> t3://${destParsed.bucket}/${destKey}` + ); return true; } }); const results = await executeWithConcurrency(tasks, 8); const copied = results.filter(Boolean).length; - console.log(`Uploaded ${copied} file(s)`); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'local-to-remote', + count: copied, + }) + ); + } else { + console.log(`Uploaded ${copied} file(s)`); + } return; } @@ -331,7 +356,17 @@ async function copyLocalToRemote( const files = listLocalFiles(localPath); if (files.length === 0) { - console.log('No files to upload'); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'local-to-remote', + count: 0, + }) + ); + } else { + console.log('No files to upload'); + } return; } @@ -352,13 +387,26 @@ async function copyLocalToRemote( console.error(`Failed to upload ${file}: ${result.error}`); return false; } else { - console.log(`Uploaded ${file} -> t3://${destParsed.bucket}/${destKey}`); + if (!_jsonMode) + console.log( + `Uploaded ${file} -> t3://${destParsed.bucket}/${destKey}` + ); return true; } }); const dirResults = await executeWithConcurrency(dirTasks, 8); const copied = dirResults.filter(Boolean).length; - console.log(`Uploaded ${copied} file(s)`); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'local-to-remote', + count: copied, + }) + ); + } else { + console.log(`Uploaded ${copied} file(s)`); + } } else { // Single file const fileName = basename(localPath); @@ -387,13 +435,25 @@ async function copyLocalToRemote( destParsed.bucket, destKey, config, - true + !_jsonMode ); if (result.error) { console.error(result.error); process.exit(1); } - console.log(`Uploaded ${src} -> t3://${destParsed.bucket}/${destKey}`); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'local-to-remote', + count: 1, + src, + dest: `t3://${destParsed.bucket}/${destKey}`, + }) + ); + } else { + console.log(`Uploaded ${src} -> t3://${destParsed.bucket}/${destKey}`); + } } } @@ -468,7 +528,17 @@ async function copyRemoteToLocal( } if (filesToDownload.length === 0) { - console.log('No objects to download'); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'remote-to-local', + count: 0, + }) + ); + } else { + console.log('No objects to download'); + } return; } @@ -488,15 +558,26 @@ async function copyRemoteToLocal( console.error(`Failed to download ${item.name}: ${result.error}`); return false; } else { - console.log( - `Downloaded t3://${srcParsed.bucket}/${item.name} -> ${localFilePath}` - ); + if (!_jsonMode) + console.log( + `Downloaded t3://${srcParsed.bucket}/${item.name} -> ${localFilePath}` + ); return true; } }); const downloadResults = await executeWithConcurrency(downloadTasks, 8); const downloaded = downloadResults.filter(Boolean).length; - console.log(`Downloaded ${downloaded} file(s)`); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'remote-to-local', + count: downloaded, + }) + ); + } else { + console.log(`Downloaded ${downloaded} file(s)`); + } } else { // Single object const srcFileName = srcParsed.path.split('/').pop()!; @@ -521,15 +602,27 @@ async function copyRemoteToLocal( srcParsed.path, localFilePath, config, - true + !_jsonMode ); if (result.error) { console.error(result.error); process.exit(1); } - console.log( - `Downloaded t3://${srcParsed.bucket}/${srcParsed.path} -> ${localFilePath}` - ); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'remote-to-local', + count: 1, + src: `t3://${srcParsed.bucket}/${srcParsed.path}`, + dest: localFilePath, + }) + ); + } else { + console.log( + `Downloaded t3://${srcParsed.bucket}/${srcParsed.path} -> ${localFilePath}` + ); + } } } @@ -636,9 +729,10 @@ async function copyRemoteToRemote( console.error(`Failed to copy ${item.name}: ${copyResult.error}`); return false; } else { - console.log( - `Copied t3://${srcParsed.bucket}/${item.name} -> t3://${destParsed.bucket}/${destKey}` - ); + if (!_jsonMode) + console.log( + `Copied t3://${srcParsed.bucket}/${item.name} -> t3://${destParsed.bucket}/${destKey}` + ); return true; } }); @@ -679,11 +773,31 @@ async function copyRemoteToRemote( } if (copied === 0) { - console.log('No objects to copy'); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'remote-to-remote', + count: 0, + }) + ); + } else { + console.log('No objects to copy'); + } return; } - console.log(`Copied ${copied} object(s)`); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'remote-to-remote', + count: copied, + }) + ); + } else { + console.log(`Copied ${copied} object(s)`); + } } else { // Single object const srcFileName = srcParsed.path.split('/').pop()!; @@ -717,7 +831,7 @@ async function copyRemoteToRemote( srcParsed.path, destParsed.bucket, destKey, - true + !_jsonMode ); if (result.error) { @@ -725,9 +839,21 @@ async function copyRemoteToRemote( process.exit(1); } - console.log( - `Copied t3://${srcParsed.bucket}/${srcParsed.path} -> t3://${destParsed.bucket}/${destKey}` - ); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'copied', + direction: 'remote-to-remote', + count: 1, + src: `t3://${srcParsed.bucket}/${srcParsed.path}`, + dest: `t3://${destParsed.bucket}/${destKey}`, + }) + ); + } else { + console.log( + `Copied t3://${srcParsed.bucket}/${srcParsed.path} -> t3://${destParsed.bucket}/${destKey}` + ); + } } } @@ -741,6 +867,12 @@ export default async function cp(options: Record) { } const recursive = !!getOption(options, ['recursive', 'r']); + const jsonFlag = getOption(options, ['json']); + const format = jsonFlag + ? 'json' + : getOption(options, ['format'], 'table'); + _jsonMode = format === 'json'; + const direction = detectDirection(src, dest); const config = await getStorageConfig({ withCredentialProvider: true }); diff --git a/src/lib/credentials/test.ts b/src/lib/credentials/test.ts index 593d07d..99443d2 100644 --- a/src/lib/credentials/test.ts +++ b/src/lib/credentials/test.ts @@ -14,6 +14,11 @@ const context = msg('credentials', 'test'); export default async function test(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const bucket = getOption(options, ['bucket', 'b']); const config = await getStorageConfig(); @@ -49,10 +54,20 @@ export default async function test(options: Record) { process.exit(1); } - console.log(` Bucket: ${bucket}`); - console.log(` Access verified.`); - if (data.sourceBucketName) { - console.log(` Fork of: ${data.sourceBucketName}`); + if (format === 'json') { + console.log( + JSON.stringify({ + valid: true, + bucket, + ...(data.sourceBucketName ? { forkOf: data.sourceBucketName } : {}), + }) + ); + } else { + console.log(` Bucket: ${bucket}`); + console.log(` Access verified.`); + if (data.sourceBucketName) { + console.log(` Fork of: ${data.sourceBucketName}`); + } } } else { // Test general access by listing buckets @@ -63,7 +78,13 @@ export default async function test(options: Record) { process.exit(1); } - console.log(` Access verified. Found ${data.buckets.length} bucket(s).`); + if (format === 'json') { + console.log( + JSON.stringify({ valid: true, bucketCount: data.buckets.length }) + ); + } else { + console.log(` Access verified. Found ${data.buckets.length} bucket(s).`); + } } printSuccess(context); diff --git a/src/lib/forks/list.ts b/src/lib/forks/list.ts index 1348754..9d13c4d 100644 --- a/src/lib/forks/list.ts +++ b/src/lib/forks/list.ts @@ -16,7 +16,10 @@ export default async function list(options: Record) { printStart(context); const name = getOption(options, ['name']); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); if (!name) { printFailure(context, 'Source bucket name is required'); diff --git a/src/lib/iam/policies/delete.ts b/src/lib/iam/policies/delete.ts index f8fc766..acfa464 100644 --- a/src/lib/iam/policies/delete.ts +++ b/src/lib/iam/policies/delete.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive, confirm } from '../../../utils/interactive.js'; import { getOption } from '../../../utils/options.js'; import { getLoginMethod } from '../../../auth/s3-client.js'; import { getAuthClient } from '../../../auth/client.js'; @@ -20,6 +21,7 @@ export default async function del(options: Record) { printStart(context); let resource = getOption(options, ['resource']); + const force = getOption(options, ['force', 'yes', 'y']); const loginMethod = await getLoginMethod(); @@ -65,6 +67,8 @@ export default async function del(options: Record) { return; } + requireInteractive('Provide the policy ARN as a positional argument'); + const { selected } = await prompt<{ selected: string }>({ type: 'select', name: 'selected', @@ -78,6 +82,15 @@ export default async function del(options: Record) { resource = selected; } + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm(`Delete policy '${resource}'?`); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + const { error } = await deletePolicy(resource, { config: iamConfig, }); diff --git a/src/lib/iam/policies/edit.ts b/src/lib/iam/policies/edit.ts index b36d6d5..3cb7cd4 100644 --- a/src/lib/iam/policies/edit.ts +++ b/src/lib/iam/policies/edit.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive } from '../../../utils/interactive.js'; import { getOption } from '../../../utils/options.js'; import { getLoginMethod } from '../../../auth/s3-client.js'; import { getAuthClient } from '../../../auth/client.js'; @@ -83,6 +84,8 @@ export default async function edit(options: Record) { return; } + requireInteractive('Provide the policy ARN as a positional argument'); + const { selected } = await prompt<{ selected: string }>({ type: 'select', name: 'selected', diff --git a/src/lib/iam/policies/get.ts b/src/lib/iam/policies/get.ts index a88a86f..c731ff0 100644 --- a/src/lib/iam/policies/get.ts +++ b/src/lib/iam/policies/get.ts @@ -21,7 +21,10 @@ export default async function get(options: Record) { printStart(context); let resource = getOption(options, ['resource']); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const loginMethod = await getLoginMethod(); diff --git a/src/lib/iam/policies/list.ts b/src/lib/iam/policies/list.ts index da5afed..aa1cf6a 100644 --- a/src/lib/iam/policies/list.ts +++ b/src/lib/iam/policies/list.ts @@ -18,7 +18,10 @@ const context = msg('iam policies', 'list'); export default async function list(options: Record) { printStart(context); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const loginMethod = await getLoginMethod(); diff --git a/src/lib/iam/users/list.ts b/src/lib/iam/users/list.ts index 653116c..81a35cc 100644 --- a/src/lib/iam/users/list.ts +++ b/src/lib/iam/users/list.ts @@ -24,7 +24,10 @@ const context = msg('iam users', 'list'); export default async function list(options: Record) { printStart(context); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const loginMethod = await getLoginMethod(); diff --git a/src/lib/iam/users/remove.ts b/src/lib/iam/users/remove.ts index 5450eff..6e1cac3 100644 --- a/src/lib/iam/users/remove.ts +++ b/src/lib/iam/users/remove.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive, confirm } from '../../../utils/interactive.js'; import { getOption } from '../../../utils/options.js'; import { getLoginMethod } from '../../../auth/s3-client.js'; import { getAuthClient } from '../../../auth/client.js'; @@ -21,6 +22,7 @@ export default async function removeUser(options: Record) { printStart(context); const resourceOption = getOption(options, ['resource']); + const force = getOption(options, ['force', 'yes', 'y']); const loginMethod = await getLoginMethod(); @@ -83,6 +85,8 @@ export default async function removeUser(options: Record) { return; } + requireInteractive('Provide user ID(s) as a positional argument'); + const { selected } = await prompt<{ selected: string[] }>({ type: 'multiselect', name: 'selected', @@ -96,6 +100,15 @@ export default async function removeUser(options: Record) { resources = selected; } + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm(`Remove ${resources.length} user(s)?`); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + const { error } = await removeUserFromOrg(resources, { config: iamConfig, }); diff --git a/src/lib/iam/users/revoke-invitation.ts b/src/lib/iam/users/revoke-invitation.ts index a1c78d8..cabd1ea 100644 --- a/src/lib/iam/users/revoke-invitation.ts +++ b/src/lib/iam/users/revoke-invitation.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive, confirm } from '../../../utils/interactive.js'; import { getOption } from '../../../utils/options.js'; import { getLoginMethod } from '../../../auth/s3-client.js'; import { getAuthClient } from '../../../auth/client.js'; @@ -23,6 +24,7 @@ export default async function revokeInvitation( printStart(context); const resourceOption = getOption(options, ['resource']); + const force = getOption(options, ['force', 'yes', 'y']); const loginMethod = await getLoginMethod(); @@ -85,6 +87,8 @@ export default async function revokeInvitation( return; } + requireInteractive('Provide invitation ID(s) as a positional argument'); + const { selected } = await prompt<{ selected: string[] }>({ type: 'multiselect', name: 'selected', @@ -99,6 +103,17 @@ export default async function revokeInvitation( resources = selected; } + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm( + `Revoke ${resources.length} invitation(s)?` + ); + if (!confirmed) { + console.log('Aborted'); + return; + } + } + const { error } = await revokeInv(resources, { config: iamConfig, }); diff --git a/src/lib/iam/users/update-role.ts b/src/lib/iam/users/update-role.ts index 30b1ea4..52e6ef6 100644 --- a/src/lib/iam/users/update-role.ts +++ b/src/lib/iam/users/update-role.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive } from '../../../utils/interactive.js'; import { getOption } from '../../../utils/options.js'; import { getLoginMethod } from '../../../auth/s3-client.js'; import { getAuthClient } from '../../../auth/client.js'; @@ -108,6 +109,8 @@ export default async function updateRole(options: Record) { return; } + requireInteractive('Provide user ID(s) as a positional argument'); + const { selected } = await prompt<{ selected: string[] }>({ type: 'multiselect', name: 'selected', diff --git a/src/lib/login/credentials.ts b/src/lib/login/credentials.ts index 3c7cb89..35b5800 100644 --- a/src/lib/login/credentials.ts +++ b/src/lib/login/credentials.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive } from '../../utils/interactive.js'; import { getSavedCredentials, storeLoginMethod, @@ -58,6 +59,8 @@ export default async function credentials(options: Record) { }); } + requireInteractive('Provide --access-key and --access-secret'); + const responses = await prompt<{ accessKey?: string; accessSecret?: string; diff --git a/src/lib/login/select.ts b/src/lib/login/select.ts index a765c72..bc2ca7b 100644 --- a/src/lib/login/select.ts +++ b/src/lib/login/select.ts @@ -1,5 +1,6 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; +import { requireInteractive } from '../../utils/interactive.js'; import { oauth } from './oauth.js'; import credentials from './credentials.js'; @@ -30,6 +31,10 @@ export default async function select(options: Record) { } // Prompt user to choose login method + requireInteractive( + 'Use "tigris login oauth" or "tigris login credentials --access-key ... --access-secret ..."' + ); + const { method } = await prompt<{ method: string }>({ type: 'select', name: 'method', diff --git a/src/lib/ls.ts b/src/lib/ls.ts index a7682ce..eed18d7 100644 --- a/src/lib/ls.ts +++ b/src/lib/ls.ts @@ -11,6 +11,10 @@ export default async function ls(options: Record) { 'snapshotVersion', 'snapshot', ]); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); if (!pathString) { // No path provided, list all buckets @@ -27,7 +31,7 @@ export default async function ls(options: Record) { created: bucket.creationDate, })); - const output = formatOutput(buckets, 'table', 'buckets', 'bucket', [ + const output = formatOutput(buckets, format!, 'buckets', 'bucket', [ { key: 'name', header: 'Name' }, { key: 'created', header: 'Created' }, ]); @@ -85,7 +89,7 @@ export default async function ls(options: Record) { item.key !== '' && arr.findIndex((i) => i.key === item.key) === index ); - const output = formatOutput(objects, 'table', 'objects', 'object', [ + const output = formatOutput(objects, format!, 'objects', 'object', [ { key: 'key', header: 'Key' }, { key: 'size', header: 'Size' }, { key: 'modified', header: 'Modified' }, diff --git a/src/lib/mk.ts b/src/lib/mk.ts index b9bee3e..cf6024e 100644 --- a/src/lib/mk.ts +++ b/src/lib/mk.ts @@ -20,6 +20,10 @@ export default async function mk(options: Record) { } const config = await getStorageConfig(); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); if (!path) { // Create a bucket @@ -88,7 +92,13 @@ export default async function mk(options: Record) { process.exit(1); } - console.log(`Bucket '${bucket}' created`); + if (format === 'json') { + console.log( + JSON.stringify({ action: 'created', type: 'bucket', name: bucket }) + ); + } else { + console.log(`Bucket '${bucket}' created`); + } process.exit(0); } else { // Create a "folder" (empty object with trailing slash) @@ -106,7 +116,18 @@ export default async function mk(options: Record) { process.exit(1); } - console.log(`Folder '${bucket}/${folderPath}' created`); + if (format === 'json') { + console.log( + JSON.stringify({ + action: 'created', + type: 'folder', + bucket, + path: folderPath, + }) + ); + } else { + console.log(`Folder '${bucket}/${folderPath}' created`); + } process.exit(0); } } diff --git a/src/lib/mv.ts b/src/lib/mv.ts index be7062c..c2b68c4 100644 --- a/src/lib/mv.ts +++ b/src/lib/mv.ts @@ -1,4 +1,3 @@ -import * as readline from 'readline'; import { isRemotePath, parseRemotePath, @@ -10,28 +9,22 @@ import { import { getOption } from '../utils/options.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { formatSize } from '../utils/format.js'; +import { requireInteractive, confirm } from '../utils/interactive.js'; import { get, put, remove, list, head } from '@tigrisdata/storage'; import { calculateUploadParams } from '../utils/upload.js'; -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${message} (y/N): `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y'); - }); - }); -} +let _jsonMode = false; export default async function mv(options: Record) { const src = getOption(options, ['src']); const dest = getOption(options, ['dest']); - const force = getOption(options, ['force', 'f', 'F']); + const force = getOption(options, ['force', 'f', 'F', 'yes', 'y']); const recursive = !!getOption(options, ['recursive', 'r']); + const jsonFlag = getOption(options, ['json']); + const format = jsonFlag + ? 'json' + : getOption(options, ['format'], 'table'); + _jsonMode = format === 'json'; if (!src || !dest) { console.error('both src and dest arguments are required'); @@ -156,17 +149,22 @@ export default async function mv(options: Record) { : false; if (itemsToMove.length === 0 && !hasFolderMarker) { - console.log('No objects to move'); + if (_jsonMode) { + console.log(JSON.stringify({ action: 'moved', count: 0 })); + } else { + console.log('No objects to move'); + } return; } const totalToMove = itemsToMove.length + (hasFolderMarker ? 1 : 0); if (!force) { + requireInteractive('Use --yes to skip confirmation'); const confirmed = await confirm( `Are you sure you want to move ${totalToMove} object(s)?` ); if (!confirmed) { - console.log('Aborted'); + if (!_jsonMode) console.log('Aborted'); return; } } @@ -189,9 +187,10 @@ export default async function mv(options: Record) { if (moveResult.error) { console.error(`Failed to move ${item.name}: ${moveResult.error}`); } else { - console.log( - `Moved t3://${srcPath.bucket}/${item.name} -> t3://${destPath.bucket}/${destKey}` - ); + if (!_jsonMode) + console.log( + `Moved t3://${srcPath.bucket}/${item.name} -> t3://${destPath.bucket}/${destKey}` + ); moved++; } } @@ -237,7 +236,11 @@ export default async function mv(options: Record) { moved = 1; } - console.log(`Moved ${moved} object(s)`); + if (_jsonMode) { + console.log(JSON.stringify({ action: 'moved', count: moved })); + } else { + console.log(`Moved ${moved} object(s)`); + } } else { // Move single object const srcFileName = srcPath.path.split('/').pop()!; @@ -270,11 +273,12 @@ export default async function mv(options: Record) { } if (!force) { + requireInteractive('Use --yes to skip confirmation'); const confirmed = await confirm( `Are you sure you want to move 't3://${srcPath.bucket}/${srcPath.path}'?` ); if (!confirmed) { - console.log('Aborted'); + if (!_jsonMode) console.log('Aborted'); return; } } @@ -285,7 +289,7 @@ export default async function mv(options: Record) { srcPath.path, destPath.bucket, destKey, - true // show progress for single file + !_jsonMode // show progress for single file (not in JSON mode) ); if (result.error) { @@ -293,9 +297,20 @@ export default async function mv(options: Record) { process.exit(1); } - console.log( - `Moved t3://${srcPath.bucket}/${srcPath.path} -> t3://${destPath.bucket}/${destKey}` - ); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'moved', + count: 1, + src: `t3://${srcPath.bucket}/${srcPath.path}`, + dest: `t3://${destPath.bucket}/${destKey}`, + }) + ); + } else { + console.log( + `Moved t3://${srcPath.bucket}/${srcPath.path} -> t3://${destPath.bucket}/${destKey}` + ); + } } process.exit(0); } diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index 9c743d9..4cec92f 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -7,14 +7,21 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { requireInteractive, confirm } from '../../utils/interactive.js'; const context = msg('objects', 'delete'); export default async function deleteObject(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const bucket = getOption(options, ['bucket']); const keys = getOption(options, ['key']); + const force = getOption(options, ['force', 'yes', 'y']); if (!bucket) { printFailure(context, 'Bucket name is required'); @@ -29,6 +36,19 @@ export default async function deleteObject(options: Record) { const config = await getStorageConfig(); const keyList = Array.isArray(keys) ? keys : [keys]; + if (!force) { + requireInteractive('Use --yes to skip confirmation'); + const confirmed = await confirm( + `Delete ${keyList.length} object(s) from '${bucket}'?` + ); + 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: { @@ -39,9 +59,20 @@ export default async function deleteObject(options: Record) { if (error) { printFailure(context, error.message, { key }); - process.exit(1); + errors.push({ key, error: error.message }); + } else { + deleted.push(key); + printSuccess(context, { key }); } + } + + if (format === 'json') { + console.log( + JSON.stringify({ action: 'deleted', bucket, keys: deleted, errors }) + ); + } - printSuccess(context, { key }); + if (errors.length > 0) { + process.exit(1); } } diff --git a/src/lib/objects/get.ts b/src/lib/objects/get.ts index 7c7ae16..16560e0 100644 --- a/src/lib/objects/get.ts +++ b/src/lib/objects/get.ts @@ -108,6 +108,11 @@ function detectFormat(key: string, output?: string): 'string' | 'stream' { export default async function getObject(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const outputFormat = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const bucket = getOption(options, ['bucket']); const key = getOption(options, ['key']); const output = getOption(options, ['output', 'o', 'O']); @@ -151,6 +156,11 @@ export default async function getObject(options: Record) { const writeStream = createWriteStream(output); await pipeline(Readable.fromWeb(data as ReadableStream), writeStream); printSuccess(context, { key, output }); + if (outputFormat === 'json') { + console.log( + JSON.stringify({ action: 'downloaded', bucket, key, output }) + ); + } } else { // Stream to stdout for binary data await pipeline(Readable.fromWeb(data as ReadableStream), process.stdout); @@ -173,6 +183,11 @@ export default async function getObject(options: Record) { if (output) { writeFileSync(output, data); printSuccess(context, { key, output }); + if (outputFormat === 'json') { + console.log( + JSON.stringify({ action: 'downloaded', bucket, key, output }) + ); + } } else { console.log(data); printSuccess(context); diff --git a/src/lib/objects/list.ts b/src/lib/objects/list.ts index 5f83235..16dfdf6 100644 --- a/src/lib/objects/list.ts +++ b/src/lib/objects/list.ts @@ -17,7 +17,10 @@ export default async function listObjects(options: Record) { const bucket = getOption(options, ['bucket']); const prefix = getOption(options, ['prefix', 'p', 'P']); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const snapshotVersion = getOption(options, [ 'snapshot-version', 'snapshotVersion', diff --git a/src/lib/objects/put.ts b/src/lib/objects/put.ts index 0d00fe5..d4c7df1 100644 --- a/src/lib/objects/put.ts +++ b/src/lib/objects/put.ts @@ -27,7 +27,10 @@ export default async function putObject(options: Record) { 't', 'T', ]); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); if (!bucket) { printFailure(context, 'Bucket name is required'); diff --git a/src/lib/objects/set.ts b/src/lib/objects/set.ts index 84df854..d89926d 100644 --- a/src/lib/objects/set.ts +++ b/src/lib/objects/set.ts @@ -13,6 +13,11 @@ const context = msg('objects', 'set'); export default async function setObject(options: Record) { printStart(context); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const bucket = getOption(options, ['bucket']); const key = getOption(options, ['key']); const access = getOption(options, ['access', 'a', 'A']); @@ -49,5 +54,17 @@ export default async function setObject(options: Record) { process.exit(1); } + if (format === 'json') { + console.log( + JSON.stringify({ + action: 'updated', + bucket, + key, + access, + ...(newKey ? { newKey } : {}), + }) + ); + } + printSuccess(context, { key, bucket }); } diff --git a/src/lib/organizations/list.ts b/src/lib/organizations/list.ts index 5a08ff2..3c9a155 100644 --- a/src/lib/organizations/list.ts +++ b/src/lib/organizations/list.ts @@ -11,6 +11,7 @@ import { import { getAuthClient } from '../../auth/client.js'; import { isFlyUser, fetchOrganizationsFromUserInfo } from '../../auth/fly.js'; import Enquirer from 'enquirer'; +import { requireInteractive } from '../../utils/interactive.js'; import { printStart, printSuccess, @@ -42,7 +43,10 @@ export default async function list(options: Record) { return; } - const format = getOption(options, ['format', 'f', 'F'], 'select'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'select'); // For Fly users, fetch organizations from userinfo endpoint const authClient = getAuthClient(); @@ -86,6 +90,8 @@ export default async function list(options: Record) { hint: org.id === currentSelection ? 'currently selected' : undefined, })); + requireInteractive('Use --format table or --format json'); + const response = await Enquirer.prompt<{ organization: string }>({ type: 'select', name: 'organization', diff --git a/src/lib/presign.ts b/src/lib/presign.ts index 9045f04..4af628b 100644 --- a/src/lib/presign.ts +++ b/src/lib/presign.ts @@ -36,7 +36,10 @@ export default async function presign(options: Record) { getOption(options, ['expires-in', 'expiresIn', 'e']) ?? '3600', 10 ); - const format = getOption(options, ['format', 'f']) ?? 'url'; + const json = getOption(options, ['json']); + const format = json + ? 'json' + : (getOption(options, ['format', 'f']) ?? 'url'); const accessKeyFlag = getOption(options, ['access-key', 'accessKey']); const config = await getStorageConfig(); diff --git a/src/lib/rm.ts b/src/lib/rm.ts index 90c0017..937af3c 100644 --- a/src/lib/rm.ts +++ b/src/lib/rm.ts @@ -1,4 +1,3 @@ -import * as readline from 'readline'; import { isRemotePath, parseRemotePath, @@ -10,25 +9,19 @@ import { import { getOption } from '../utils/options.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { remove, removeBucket, list } from '@tigrisdata/storage'; +import { requireInteractive, confirm } from '../utils/interactive.js'; -async function confirm(message: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question(`${message} (y/N): `, (answer) => { - rl.close(); - resolve(answer.toLowerCase() === 'y'); - }); - }); -} +let _jsonMode = false; export default async function rm(options: Record) { const pathString = getOption(options, ['path']); - const force = getOption(options, ['force', 'f', 'F']); + const force = getOption(options, ['force', 'f', 'F', 'yes', 'y']); const recursive = !!getOption(options, ['recursive', 'r']); + const jsonFlag = getOption(options, ['json']); + const format = jsonFlag + ? 'json' + : getOption(options, ['format'], 'table'); + _jsonMode = format === 'json'; if (!pathString) { console.error('path argument is required'); @@ -53,11 +46,12 @@ export default async function rm(options: Record) { const rawEndsWithSlash = pathString.endsWith('/'); if (!path && !rawEndsWithSlash) { if (!force) { + requireInteractive('Use --yes to skip confirmation'); const confirmed = await confirm( `Are you sure you want to delete bucket '${bucket}'?` ); if (!confirmed) { - console.log('Aborted'); + if (!_jsonMode) console.log('Aborted'); return; } } @@ -69,7 +63,11 @@ export default async function rm(options: Record) { process.exit(1); } - console.log(`Removed bucket '${bucket}'`); + if (_jsonMode) { + console.log(JSON.stringify({ action: 'removed', bucket })); + } else { + console.log(`Removed bucket '${bucket}'`); + } return; } @@ -146,16 +144,21 @@ export default async function rm(options: Record) { const totalItems = itemsToRemove.length + (hasSeparateFolderMarker ? 1 : 0); if (totalItems === 0) { - console.log('No objects to remove'); + if (_jsonMode) { + console.log(JSON.stringify({ action: 'removed', count: 0 })); + } else { + console.log('No objects to remove'); + } return; } if (!force) { + requireInteractive('Use --yes to skip confirmation'); const confirmed = await confirm( `Are you sure you want to delete ${totalItems} object(s)?` ); if (!confirmed) { - console.log('Aborted'); + if (!_jsonMode) console.log('Aborted'); return; } } @@ -174,7 +177,7 @@ export default async function rm(options: Record) { if (removeError) { console.error(`Failed to remove ${item.name}: ${removeError.message}`); } else { - console.log(`Removed t3://${bucket}/${item.name}`); + if (!_jsonMode) console.log(`Removed t3://${bucket}/${item.name}`); removed++; } } @@ -193,20 +196,25 @@ export default async function rm(options: Record) { `Failed to remove ${folderMarker}: ${removeError.message}` ); } else { - console.log(`Removed t3://${bucket}/${folderMarker}`); + if (!_jsonMode) console.log(`Removed t3://${bucket}/${folderMarker}`); removed++; } } - console.log(`Removed ${removed} object(s)`); + if (_jsonMode) { + console.log(JSON.stringify({ action: 'removed', count: removed })); + } else { + console.log(`Removed ${removed} object(s)`); + } } else { // Remove single object if (!force) { + requireInteractive('Use --yes to skip confirmation'); const confirmed = await confirm( `Are you sure you want to delete 't3://${bucket}/${path}'?` ); if (!confirmed) { - console.log('Aborted'); + if (!_jsonMode) console.log('Aborted'); return; } } @@ -223,7 +231,17 @@ export default async function rm(options: Record) { process.exit(1); } - console.log(`Removed t3://${bucket}/${path}`); + if (_jsonMode) { + console.log( + JSON.stringify({ + action: 'removed', + count: 1, + path: `t3://${bucket}/${path}`, + }) + ); + } else { + console.log(`Removed t3://${bucket}/${path}`); + } } process.exit(0); } diff --git a/src/lib/snapshots/list.ts b/src/lib/snapshots/list.ts index e8a1785..0da6c44 100644 --- a/src/lib/snapshots/list.ts +++ b/src/lib/snapshots/list.ts @@ -16,7 +16,10 @@ export default async function list(options: Record) { printStart(context); const name = getOption(options, ['name']); - const format = getOption(options, ['format', 'f', 'F'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); if (!name) { printFailure(context, 'Bucket name is required'); diff --git a/src/lib/stat.ts b/src/lib/stat.ts index 76abb67..b201e7e 100644 --- a/src/lib/stat.ts +++ b/src/lib/stat.ts @@ -17,7 +17,10 @@ export default async function stat(options: Record) { printStart(context); const pathString = getOption(options, ['path']); - const format = getOption(options, ['format'], 'table'); + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const snapshotVersion = getOption(options, [ 'snapshot-version', 'snapshotVersion', diff --git a/src/lib/touch.ts b/src/lib/touch.ts index 5408c7c..0c98914 100644 --- a/src/lib/touch.ts +++ b/src/lib/touch.ts @@ -23,6 +23,11 @@ export default async function touch(options: Record) { process.exit(1); } + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); + const config = await getStorageConfig(); const { error } = await put(path, '', { @@ -37,6 +42,10 @@ export default async function touch(options: Record) { process.exit(1); } - console.log(`Created '${bucket}/${path}'`); + if (format === 'json') { + console.log(JSON.stringify({ action: 'created', bucket, path })); + } else { + console.log(`Created '${bucket}/${path}'`); + } process.exit(0); } diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts index 3c00b9c..8929889 100644 --- a/src/lib/whoami.ts +++ b/src/lib/whoami.ts @@ -7,11 +7,18 @@ import { } from '../auth/storage.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { printFailure, printAlreadyDone, msg } from '../utils/messages.js'; +import { getOption } from '../utils/options.js'; const context = msg('whoami'); -export default async function whoami(): Promise { +export default async function whoami( + options: Record = {} +): Promise { try { + const json = getOption(options, ['json']); + const format = json + ? 'json' + : getOption(options, ['format', 'f', 'F'], 'table'); const loginMethod = getLoginMethod(); const credentials = getCredentials(); @@ -47,9 +54,12 @@ export default async function whoami(): Promise { lines.push(` User ID: ${userId || 'N/A'}`); // Only fetch organizations for OAuth users (credentials don't have session tokens) + let organizations: { id: string; name: string }[] = []; + let selectedOrg: string | null | undefined; + if (loginMethod === 'oauth') { const config = await getStorageConfig(); - const selectedOrg = getSelectedOrganization(); + selectedOrg = getSelectedOrganization(); const { data, error } = await listOrganizations({ config }); if (error) { @@ -57,7 +67,7 @@ export default async function whoami(): Promise { process.exit(1); } - const organizations = data?.organizations ?? []; + organizations = data?.organizations ?? []; if (organizations.length > 0) { lines.push(''); @@ -87,6 +97,22 @@ export default async function whoami(): Promise { ); } + if (format === 'json') { + const result: Record = { email, userId, loginMethod }; + if (loginMethod === 'oauth') { + result.organizations = organizations.map((org) => ({ + id: org.id, + name: org.name, + })); + if (selectedOrg) { + const selected = organizations.find((o) => o.id === selectedOrg); + if (selected) result.activeOrganization = selected.name; + } + } + console.log(JSON.stringify(result)); + return; + } + lines.push(''); console.log(lines.join('\n')); } catch (error) { diff --git a/src/specs.yaml b/src/specs.yaml index cedd2c4..5de5628 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -189,6 +189,15 @@ commands: onSuccess: '' onFailure: 'Failed to get user information' onAlreadyDone: "Not authenticated\nRun \"tigris login\" to authenticate" + arguments: + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # logout - name: logout @@ -223,6 +232,14 @@ commands: description: Bucket name to test access against (optional) alias: b required: false + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag ######################### @@ -252,6 +269,14 @@ 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: format + description: Output format + alias: f + options: [json, table, xml] + default: table + - name: json + description: Output as JSON + type: flag # mk - name: mk @@ -315,6 +340,14 @@ commands: - name: source-snapshot description: 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 alias: source-snap + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # touch - name: touch @@ -332,6 +365,14 @@ commands: examples: - my-bucket/my-file.txt - t3://my-bucket/my-file.txt + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # stat - name: stat @@ -358,6 +399,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag - 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 @@ -398,6 +442,9 @@ commands: alias: f options: [url, json] default: url + - name: json + description: Output as JSON + type: flag # cp - name: cp @@ -434,6 +481,14 @@ commands: type: flag alias: r description: Copy directories recursively + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # mv - name: mv @@ -469,6 +524,13 @@ commands: type: flag alias: f description: Skip confirmation prompt + - name: format + description: Output format + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # rm - name: rm @@ -499,6 +561,13 @@ commands: type: flag alias: f description: Skip confirmation prompt + - name: format + description: Output format + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag ######################### # Manage organizations @@ -529,6 +598,9 @@ commands: alias: f options: [json, table, xml, select] default: select + - name: json + description: Output as JSON + type: flag - name: select description: Interactive selection mode alias: i @@ -598,6 +670,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag - name: forks-of description: Only list buckets that are forks of the named source bucket # create @@ -660,6 +735,14 @@ commands: - name: source-snapshot description: 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 alias: source-snap + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # get - name: get description: Show details for a bucket including access level, region, tier, and custom domain @@ -677,13 +760,21 @@ commands: required: true examples: - my-bucket + - name: format + description: Output format + alias: f + options: [json, table, xml] + default: table + - name: json + description: Output as JSON + type: flag # delete - name: delete description: Delete one or more buckets by name. The bucket must be empty or delete-protection must be off alias: d examples: - - "tigris buckets delete my-bucket" - - "tigris buckets delete bucket-a,bucket-b" + - "tigris buckets delete my-bucket --force" + - "tigris buckets delete bucket-a,bucket-b --force" messages: onStart: 'Deleting bucket...' onSuccess: "Bucket '{{name}}' deleted successfully" @@ -696,6 +787,17 @@ commands: multiple: true examples: - my-bucket + - name: force + type: flag + description: Skip confirmation prompt + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # set - name: set description: Update settings on an existing bucket such as access level, location, caching, or custom domain @@ -743,6 +845,13 @@ commands: - name: enable-additional-headers description: Enable additional HTTP headers (X-Content-Type-Options nosniff) type: boolean + - name: format + description: Output format + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # set-ttl - name: set-ttl description: Configure object expiration (TTL) on a bucket. Objects expire after a number of days or on a specific date @@ -986,6 +1095,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag # create - name: create description: (Deprecated, use "buckets create --fork-of") Create a new fork (copy-on-write clone) of the source bucket @@ -1050,6 +1162,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag # take - name: take description: Take a new snapshot of the bucket's current state. Optionally provide a name for the snapshot @@ -1115,6 +1230,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag - 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 @@ -1152,6 +1270,14 @@ 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: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # put - name: put description: Upload a local file as an object. Content-type is auto-detected from extension unless overridden @@ -1194,13 +1320,16 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag # delete - name: delete description: Delete one or more objects by key from the given bucket alias: d examples: - - "tigris objects delete my-bucket old-file.txt" - - "tigris objects delete my-bucket file-a.txt,file-b.txt" + - "tigris objects delete my-bucket old-file.txt --force" + - "tigris objects delete my-bucket file-a.txt,file-b.txt --force" messages: onStart: 'Deleting object...' onSuccess: "Object '{{key}}' deleted successfully" @@ -1219,6 +1348,17 @@ commands: multiple: true examples: - my-file.txt + - name: force + type: flag + description: Skip confirmation prompt + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag # set - name: set description: Update settings on an existing object such as access level @@ -1247,6 +1387,14 @@ commands: - name: new-key description: Rename the object to a new key alias: n + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag ######################### # Manage access keys @@ -1269,6 +1417,15 @@ commands: onSuccess: '' onFailure: 'Failed to list access keys' onEmpty: 'No access keys found' + arguments: + - name: format + description: Output format + alias: f + options: [json, table, xml] + default: table + - name: json + description: Output as JSON + type: flag - name: create description: Create a new access key with the given name. Returns the key ID and secret (shown only once) alias: c @@ -1285,11 +1442,19 @@ commands: required: true examples: - my-key + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag - name: delete description: Permanently delete an access key by its ID. This revokes all access immediately alias: d examples: - - "tigris access-keys delete tid_AaBbCcDdEeFf" + - "tigris access-keys delete tid_AaBbCcDdEeFf --force" messages: onStart: 'Deleting access key...' onSuccess: 'Access key deleted' @@ -1301,6 +1466,17 @@ commands: required: true examples: - tid_AaBbCcDdEeFf + - name: force + type: flag + description: Skip confirmation prompt + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag - name: get description: Show details for an access key including its name, creation date, and assigned bucket roles alias: g @@ -1317,6 +1493,14 @@ commands: required: true examples: - tid_AaBbCcDdEeFf + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag - name: assign description: Assign per-bucket roles to an access key. Pair each --bucket with a --role (Editor or ReadOnly), or use --admin for org-wide access alias: a @@ -1353,6 +1537,14 @@ commands: - name: revoke-roles description: Revoke all bucket roles from the access key type: flag + - name: format + description: Output format + alias: f + options: [json, table] + default: table + - name: json + description: Output as JSON + type: flag ######################### # IAM - Identity and Access Management @@ -1388,6 +1580,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag - name: get description: Show details for a policy including its document and attached users. If no ARN provided, shows interactive selection alias: g @@ -1411,6 +1606,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag - name: create description: Create a new policy with the given name and policy document. Document can be provided via file, inline JSON, or stdin alias: c @@ -1466,7 +1664,7 @@ commands: alias: d examples: - "tigris iam policies delete" - - "tigris iam policies delete arn:aws:iam::org_id:policy/my-policy" + - "tigris iam policies delete arn:aws:iam::org_id:policy/my-policy --force" messages: onStart: 'Deleting policy...' onSuccess: "Policy '{{resource}}' deleted" @@ -1479,6 +1677,9 @@ commands: required: false examples: - arn:aws:iam::org_id:policy/my-policy + - name: force + type: flag + description: Skip confirmation prompt - name: users description: Manage organization users and invitations @@ -1505,6 +1706,9 @@ commands: alias: f options: [json, table, xml] default: table + - name: json + description: Output as JSON + type: flag - name: invite description: Invite users to the organization by email alias: i @@ -1535,8 +1739,8 @@ commands: alias: ri examples: - "tigris iam users revoke-invitation" - - "tigris iam users revoke-invitation invitation_id" - - "tigris iam users revoke-invitation id1,id2,id3" + - "tigris iam users revoke-invitation invitation_id --force" + - "tigris iam users revoke-invitation id1,id2,id3 --force" messages: onStart: 'Revoking invitation...' onSuccess: "Invitation(s) revoked" @@ -1548,6 +1752,9 @@ commands: type: positional required: false multiple: true + - name: force + type: flag + description: Skip confirmation prompt - name: update-role description: Update user roles in the organization. If no user ID provided, shows interactive selection alias: ur @@ -1578,8 +1785,8 @@ commands: alias: rm examples: - "tigris iam users remove" - - "tigris iam users remove user@example.com" - - "tigris iam users remove user@example.com,user@example.net" + - "tigris iam users remove user@example.com --force" + - "tigris iam users remove user@example.com,user@example.net --force" messages: onStart: 'Removing user...' onSuccess: "User(s) removed" @@ -1591,3 +1798,6 @@ commands: type: positional required: false multiple: true + - name: force + type: flag + description: Skip confirmation prompt diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts new file mode 100644 index 0000000..a528214 --- /dev/null +++ b/src/utils/interactive.ts @@ -0,0 +1,33 @@ +import * as readline from 'readline'; + +/** + * Guard for interactive-only operations. + * Fails fast with a helpful hint when stdin is not a TTY + * (e.g., when called from a script or AI agent). + */ +export function requireInteractive(hint: string): void { + if (process.stdin.isTTY) return; + console.error( + 'Error: this command requires interactive input (not available in piped/scripted mode)' + ); + console.error(`Hint: ${hint}`); + process.exit(1); +} + +/** + * Prompt the user for y/N confirmation via readline. + * Returns true only if the user types "y" (case-insensitive). + */ +export async function confirm(message: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(`${message} (y/N): `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y'); + }); + }); +} diff --git a/src/utils/locations.ts b/src/utils/locations.ts index 7e27fab..5e502ad 100644 --- a/src/utils/locations.ts +++ b/src/utils/locations.ts @@ -1,5 +1,6 @@ import type { BucketLocations } from '@tigrisdata/storage'; import enquirer from 'enquirer'; +import { requireInteractive } from './interactive.js'; const { prompt } = enquirer; @@ -86,6 +87,8 @@ async function promptRegion( } export async function promptLocations(): Promise { + requireInteractive('Provide --locations flag'); + let locationType: string; try { ({ locationType } = await prompt<{ locationType: string }>({ diff --git a/test/cli-core.test.ts b/test/cli-core.test.ts new file mode 100644 index 0000000..6478107 --- /dev/null +++ b/test/cli-core.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isValidCommandName, + formatArgumentHelp, + extractArgumentValues, + validateRequiredWhen, + addArgumentsToCommand, +} from '../src/cli-core.js'; +import type { Command as CommanderCommand } from 'commander'; +import type { Argument } from '../src/types.js'; + +describe('isValidCommandName', () => { + it.each(['buckets', 'access-keys', 'set_ttl', 'ls', 'a1'])( + 'accepts valid name: %s', + (name) => { + expect(isValidCommandName(name)).toBe(true); + } + ); + + it.each(['', '../etc', 'foo bar', 'rm;ls', 'a/b', 'cmd@1'])( + 'rejects invalid name: %s', + (name) => { + expect(isValidCommandName(name)).toBe(false); + } + ); +}); + +describe('formatArgumentHelp', () => { + it('formats positional argument', () => { + const arg: Argument = { + name: 'path', + description: 'The file path', + type: 'positional', + }; + const result = formatArgumentHelp(arg); + expect(result).toContain(' path'); + expect(result).toContain('[positional argument]'); + }); + + it('formats flag', () => { + const arg: Argument = { + name: 'force', + description: 'Force the operation', + type: 'flag', + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('--force'); + }); + + it('formats short alias', () => { + const arg: Argument = { + name: 'format', + description: 'Output format', + alias: 'f', + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('--format, -f'); + }); + + it('formats long alias', () => { + const arg: Argument = { + name: 'fork-of', + description: 'Fork source', + alias: 'fork', + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('--fork-of, --fork'); + }); + + it('formats string[] options', () => { + const arg: Argument = { + name: 'format', + description: 'Output format', + options: ['json', 'table'], + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('(options: json, table)'); + }); + + it('formats object options', () => { + const arg: Argument = { + name: 'tier', + description: 'Storage tier', + options: [ + { name: 'Standard', value: 'STANDARD', description: 'Default tier' }, + ], + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('(options: STANDARD)'); + }); + + it('formats default value', () => { + const arg: Argument = { + name: 'format', + description: 'Output format', + default: 'table', + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('[default: table]'); + }); + + it('formats required', () => { + const arg: Argument = { + name: 'name', + description: 'Bucket name', + required: true, + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('[required]'); + }); + + it('formats required-when', () => { + const arg: Argument = { + name: 'target', + description: 'Target bucket', + 'required-when': 'type=bucket', + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('[required when: type=bucket]'); + }); + + it('formats multiple', () => { + const arg: Argument = { + name: 'regions', + description: 'Regions', + multiple: true, + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('[multiple values: comma-separated]'); + }); + + it('formats examples', () => { + const arg: Argument = { + name: 'path', + description: 'Object path', + examples: ['t3://bucket/key'], + }; + const result = formatArgumentHelp(arg); + expect(result).toContain('(examples: t3://bucket/key)'); + }); + + it('pads short names to at least 26 chars', () => { + const arg: Argument = { name: 'x', description: 'desc' }; + const result = formatArgumentHelp(arg); + // " --x" is 5 chars, should be padded to 26 + const descIndex = result.indexOf('desc'); + expect(descIndex).toBeGreaterThanOrEqual(26); + }); +}); + +describe('extractArgumentValues', () => { + it('passes through plain object', () => { + const args: Argument[] = []; + const result = extractArgumentValues(args, [], { foo: 'bar' }); + expect(result).toEqual({ foo: 'bar' }); + }); + + it('calls optsWithGlobals() when present', () => { + const args: Argument[] = []; + const commandObj = { + optsWithGlobals: () => ({ fromGlobals: true }), + }; + const result = extractArgumentValues( + args, + [], + commandObj as unknown as Record + ); + expect(result).toEqual({ fromGlobals: true }); + }); + + it('calls opts() when present (no optsWithGlobals)', () => { + const args: Argument[] = []; + const commandObj = { + opts: () => ({ fromOpts: true }), + }; + const result = extractArgumentValues( + args, + [], + commandObj as unknown as Record + ); + expect(result).toEqual({ fromOpts: true }); + }); + + it('maps positional args by index', () => { + const args: Argument[] = [ + { name: 'source', description: 'Source', type: 'positional' }, + { name: 'dest', description: 'Destination', type: 'positional' }, + ]; + const result = extractArgumentValues(args, ['a.txt', 'b.txt'], {}); + expect(result.source).toBe('a.txt'); + expect(result.dest).toBe('b.txt'); + }); + + it('comma-splits multiple positional args', () => { + const args: Argument[] = [ + { + name: 'regions', + description: 'Regions', + type: 'positional', + multiple: true, + }, + ]; + const result = extractArgumentValues(args, ['ams,fra,sjc'], {}); + expect(result.regions).toEqual(['ams', 'fra', 'sjc']); + }); + + it('comma-splits multiple non-positional string values', () => { + const args: Argument[] = [ + { name: 'tags', description: 'Tags', multiple: true }, + ]; + const result = extractArgumentValues(args, [], { tags: 'a,b,c' }); + expect(result.tags).toEqual(['a', 'b', 'c']); + }); +}); + +describe('validateRequiredWhen', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('returns true when no required args', () => { + const args: Argument[] = [ + { name: 'format', description: 'Format' }, + ]; + expect(validateRequiredWhen(args, {})).toBe(true); + }); + + it('returns true when required arg is present', () => { + const args: Argument[] = [ + { name: 'name', description: 'Name', required: true }, + ]; + expect(validateRequiredWhen(args, { name: 'test' })).toBe(true); + }); + + it('returns false when required arg is missing', () => { + const args: Argument[] = [ + { name: 'name', description: 'Name', required: true }, + ]; + expect(validateRequiredWhen(args, {})).toBe(false); + expect(errorSpy).toHaveBeenCalledWith('--name is required'); + }); + + it('returns false when required-when condition met but value missing', () => { + const args: Argument[] = [ + { name: 'type', description: 'Type' }, + { + name: 'target', + description: 'Target', + 'required-when': 'type=bucket', + }, + ]; + expect(validateRequiredWhen(args, { type: 'bucket' })).toBe(false); + expect(errorSpy).toHaveBeenCalledWith( + '--target is required when --type is bucket' + ); + }); + + it('returns true when required-when condition not met', () => { + const args: Argument[] = [ + { name: 'type', description: 'Type' }, + { + name: 'target', + description: 'Target', + 'required-when': 'type=bucket', + }, + ]; + expect(validateRequiredWhen(args, { type: 'object' })).toBe(true); + }); + + it('returns true when required-when condition met and value present', () => { + const args: Argument[] = [ + { name: 'type', description: 'Type' }, + { + name: 'target', + description: 'Target', + 'required-when': 'type=bucket', + }, + ]; + expect( + validateRequiredWhen(args, { type: 'bucket', target: 'my-bucket' }) + ).toBe(true); + }); +}); + +describe('addArgumentsToCommand', () => { + function createMockCmd() { + const calls = { + argument: [] as Array<[string, string]>, + option: [] as Array<[string, string, string?]>, + }; + const cmd = { + argument(name: string, desc: string) { + calls.argument.push([name, desc]); + return cmd; + }, + option(flags: string, desc: string, defaultVal?: string) { + calls.option.push([flags, desc, defaultVal]); + return cmd; + }, + }; + return { cmd, calls }; + } + + it('adds required positional as ', () => { + const { cmd, calls } = createMockCmd(); + addArgumentsToCommand(cmd as unknown as CommanderCommand, [ + { name: 'path', description: 'Path', type: 'positional', required: true }, + ]); + expect(calls.argument[0][0]).toBe(''); + }); + + it('adds optional positional as [name]', () => { + const { cmd, calls } = createMockCmd(); + addArgumentsToCommand(cmd as unknown as CommanderCommand, [ + { name: 'path', description: 'Path', type: 'positional' }, + ]); + expect(calls.argument[0][0]).toBe('[path]'); + }); + + it('adds flag without value placeholder', () => { + const { cmd, calls } = createMockCmd(); + addArgumentsToCommand(cmd as unknown as CommanderCommand, [ + { name: 'force', description: 'Force', type: 'flag' }, + ]); + expect(calls.option[0][0]).toBe('--force'); + }); + + it('adds short alias', () => { + const { cmd, calls } = createMockCmd(); + addArgumentsToCommand(cmd as unknown as CommanderCommand, [ + { name: 'format', description: 'Format', alias: 'f', options: ['json', 'table'] }, + ]); + expect(calls.option[0][0]).toBe('-f, --format '); + }); + + it('adds long alias', () => { + const { cmd, calls } = createMockCmd(); + addArgumentsToCommand(cmd as unknown as CommanderCommand, [ + { name: 'fork-of', description: 'Fork', alias: 'fork', required: true }, + ]); + expect(calls.option[0][0]).toBe('--fork, --fork-of '); + }); + + it('passes default value as 3rd arg', () => { + const { cmd, calls } = createMockCmd(); + addArgumentsToCommand(cmd as unknown as CommanderCommand, [ + { name: 'format', description: 'Format', default: 'table' }, + ]); + expect(calls.option[0][2]).toBe('table'); + }); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index ae32053..2ba5a46 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { execSync } from 'child_process'; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + rmSync, +} from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; import { shouldSkipIntegrationTests, getTestPrefix } from './setup.js'; const skipTests = shouldSkipIntegrationTests(); @@ -14,7 +23,7 @@ function runCli(args: string): { const stdout = execSync(`node dist/cli.js ${args}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], // Ignore stdin to prevent hanging on prompts - timeout: 30000, // 30 second timeout per command + timeout: 60000, // 60 second timeout per command env: { ...process.env, // Pass through auth env vars @@ -232,12 +241,33 @@ describe('CLI Help Commands', () => { }); }); +describe('Destructive commands require --force in non-TTY', () => { + // These tests verify that destructive commands refuse to run without --force + // when stdin is not a TTY (piped/scripted mode). Since runCli uses + // stdio: ['ignore', ...], stdin is not a TTY. + + it('objects delete should require confirmation in non-TTY', () => { + const result = runCli('objects delete fake-bucket fake-key'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Use --yes to skip confirmation'); + }); + + it('buckets delete should require confirmation in non-TTY', () => { + const result = runCli('buckets delete fake-bucket'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Use --yes to skip confirmation'); + }); +}); + describe.skipIf(skipTests)('CLI Integration Tests', () => { // Generate unique prefix for all test resources const testPrefix = getTestPrefix(); const testBucket = testPrefix; const testContent = 'Hello from CLI test'; + /** Prefix a bucket/path with t3:// for commands that require remote paths (cp, mv, rm) */ + const t3 = (path: string) => `t3://${path}`; + beforeAll(async () => { // Setup credentials from .env console.log('Setting up credentials from .env...'); @@ -248,7 +278,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { console.log(`Test prefix: ${testPrefix}`); console.log(`Creating test bucket: ${testBucket}`); // Use mk command instead of buckets create to avoid interactive prompts - const result = runCli(`mk ${testBucket}`); + const result = runCli(`mk ${testBucket} --enable-snapshots`); if (result.exitCode !== 0) { console.error('Failed to create test bucket:', result.stderr); throw new Error('Failed to create test bucket'); @@ -258,8 +288,8 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { afterAll(async () => { console.log(`Cleaning up test bucket: ${testBucket}`); // Force remove all objects and the bucket - runCli(`rm ${testBucket}/* -f`); - runCli(`rm ${testBucket} -f`); + runCli(`rm ${t3(testBucket)}/* -f`); + runCli(`rm ${t3(testBucket)} -f`); }); describe('ls command', () => { @@ -347,7 +377,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should copy an object within same bucket', () => { const result = runCli( - `cp ${testBucket}/${srcFile} ${testBucket}/${destFile}` + `cp ${t3(testBucket)}/${srcFile} ${t3(testBucket)}/${destFile}` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Copied'); @@ -370,7 +400,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should move an object with force flag', () => { const result = runCli( - `mv ${testBucket}/${srcFile} ${testBucket}/${destFile} -f` + `mv ${t3(testBucket)}/${srcFile} ${t3(testBucket)}/${destFile} -f` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Moved'); @@ -392,7 +422,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should remove an object with force flag', () => { - const result = runCli(`rm ${testBucket}/${fileName} -f`); + const result = runCli(`rm ${t3(testBucket)}/${fileName} -f`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Removed'); }); @@ -418,7 +448,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should auto-detect folder for cp without trailing slash', () => { const result = runCli( - `cp ${testBucket}/${autoFolder} ${testBucket}/${copiedFolder}` + `cp ${t3(testBucket)}/${autoFolder} ${t3(testBucket)}/${copiedFolder} -r` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Copied'); @@ -427,21 +457,22 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should auto-detect folder for mv without trailing slash', () => { const result = runCli( - `mv ${testBucket}/${copiedFolder} ${testBucket}/${movedFolder} -f` + `mv ${t3(testBucket)}/${copiedFolder} ${t3(testBucket)}/${movedFolder} -r -f` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Moved'); - expect(result.stdout).toContain('2 object(s)'); + // cp nests: autodetect → copied/autodetect/ (marker + 2 files = 3) + expect(result.stdout).toContain('3 object(s)'); }); it('should auto-detect folder for rm without trailing slash', () => { - const result = runCli(`rm ${testBucket}/${movedFolder} -f`); + const result = runCli(`rm ${t3(testBucket)}/${movedFolder} -r -f`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Removed'); }); afterAll(() => { - runCli(`rm ${testBucket}/${autoFolder} -f`); + runCli(`rm ${t3(testBucket)}/${autoFolder} -r -f`); }); }); @@ -457,7 +488,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should copy an empty folder', () => { const result = runCli( - `cp ${testBucket}/${emptyFolder}/ ${testBucket}/${copiedEmptyFolder}/` + `cp ${t3(testBucket)}/${emptyFolder}/ ${t3(testBucket)}/${copiedEmptyFolder}/ -r` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Copied'); @@ -472,7 +503,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should move an empty folder', () => { const result = runCli( - `mv ${testBucket}/${copiedEmptyFolder}/ ${testBucket}/${movedEmptyFolder}/ -f` + `mv ${t3(testBucket)}/${copiedEmptyFolder}/ ${t3(testBucket)}/${movedEmptyFolder}/ -r -f` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Moved'); @@ -487,8 +518,8 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); afterAll(() => { - runCli(`rm ${testBucket}/${emptyFolder}/ -f`); - runCli(`rm ${testBucket}/${movedEmptyFolder}/ -f`); + runCli(`rm ${t3(testBucket)}/${emptyFolder}/ -r -f`); + runCli(`rm ${t3(testBucket)}/${movedEmptyFolder}/ -r -f`); }); }); @@ -508,7 +539,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should copy file to existing folder (auto-detect)', () => { const result = runCli( - `cp ${testBucket}/${srcFile} ${testBucket}/${targetFolder}` + `cp ${t3(testBucket)}/${srcFile} ${t3(testBucket)}/${targetFolder}` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Copied'); @@ -517,7 +548,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should copy file to explicit folder path (trailing slash)', () => { const result = runCli( - `cp ${testBucket}/${srcFile2} ${testBucket}/${targetFolder}/` + `cp ${t3(testBucket)}/${srcFile2} ${t3(testBucket)}/${targetFolder}/` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Copied'); @@ -526,7 +557,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should move file to existing folder with force flag', () => { const result = runCli( - `mv ${testBucket}/${srcFile3} ${testBucket}/${targetFolder} -f` + `mv ${t3(testBucket)}/${srcFile3} ${t3(testBucket)}/${targetFolder} -f` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Moved'); @@ -542,9 +573,9 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); afterAll(() => { - runCli(`rm ${testBucket}/${targetFolder}/ -f`); - runCli(`rm ${testBucket}/${srcFile} -f`); - runCli(`rm ${testBucket}/${srcFile2} -f`); + runCli(`rm ${t3(testBucket)}/${targetFolder}/ -r -f`); + runCli(`rm ${t3(testBucket)}/${srcFile} -f`); + runCli(`rm ${t3(testBucket)}/${srcFile2} -f`); }); }); @@ -568,13 +599,13 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error on cp with bucket-only source', () => { - const result = runCli(`cp ${testBucket} ${testBucket}-other/`); + const result = runCli(`cp ${t3(testBucket)} ${t3(testBucket)}-other/`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Cannot copy a bucket'); }); it('should error on mv with bucket-only source', () => { - const result = runCli(`mv ${testBucket} ${testBucket}-other/`); + const result = runCli(`mv ${t3(testBucket)} ${t3(testBucket)}-other/`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Cannot move a bucket'); }); @@ -590,7 +621,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should remove files matching wildcard pattern', () => { - const result = runCli(`rm ${testBucket}/${wildcardPrefix}-* -f`); + const result = runCli(`rm ${t3(testBucket)}/${wildcardPrefix}-* -f`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Removed'); expect(result.stdout).toContain('3 object(s)'); @@ -617,7 +648,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should copy folder contents and marker using wildcard', () => { const result = runCli( - `cp ${testBucket}/${wcFolder}/* ${testBucket}/${wcCopied}/` + `cp ${t3(testBucket)}/${wcFolder}/* ${t3(testBucket)}/${wcCopied}/` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Copied'); @@ -632,7 +663,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should move folder contents and marker using wildcard', () => { const result = runCli( - `mv ${testBucket}/${wcCopied}/* ${testBucket}/${wcMoved}/ -f` + `mv ${t3(testBucket)}/${wcCopied}/* ${t3(testBucket)}/${wcMoved}/ -f` ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Moved'); @@ -647,8 +678,1155 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); afterAll(() => { - runCli(`rm ${testBucket}/${wcFolder}/ -f`); - runCli(`rm ${testBucket}/${wcMoved}/ -f`); + runCli(`rm ${t3(testBucket)}/${wcFolder}/ -r -f`); + runCli(`rm ${t3(testBucket)}/${wcMoved}/ -r -f`); + }); + }); + + // ─── Section A: Missing branches in already-tested commands ─── + + describe('mk command - bucket creation variants', () => { + const mkBuckets: string[] = []; + + afterAll(() => { + for (const b of mkBuckets) { + runCli(`rm ${t3(b)} -f`); + } + }); + + it('should create a public bucket with --public', () => { + const name = `${testPrefix}-mk-pub`; + mkBuckets.push(name); + const result = runCli(`mk ${name} --public`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + }); + + it('should create a bucket with --enable-snapshots', () => { + const name = `${testPrefix}-mk-snap`; + mkBuckets.push(name); + const result = runCli(`mk ${name} --enable-snapshots`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + + // Verify snapshots enabled via buckets get (table format) + const info = runCli(`buckets get ${name}`); + expect(info.exitCode).toBe(0); + expect(info.stdout).toContain('Snapshots Enabled'); + expect(info.stdout).toContain('Yes'); + }); + + it('should create a bucket with --default-tier STANDARD_IA', () => { + const name = `${testPrefix}-mk-tier`; + mkBuckets.push(name); + const result = runCli(`mk ${name} --default-tier STANDARD_IA`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + }); + + it('should create a bucket with --locations usa', () => { + const name = `${testPrefix}-mk-loc`; + mkBuckets.push(name); + const result = runCli(`mk ${name} --locations usa`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + }); + + it('should create a bucket with --fork-of', () => { + const name = `${testPrefix}-mk-fork`; + mkBuckets.push(name); + const result = runCli(`mk ${name} --fork-of ${testBucket}`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('created'); + }); + + it('should error on --source-snapshot without --fork-of', () => { + const result = runCli( + `mk ${testPrefix}-mk-nofork --source-snapshot snap1` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--source-snapshot requires --fork-of'); + }); + + it('should error on no path argument', () => { + const result = runCli('mk'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('required'); + }); + }); + + describe('touch command - validation', () => { + it('should error on bucket-only path', () => { + const result = runCli(`touch ${testBucket}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Object key is required'); + }); + }); + + describe('cp command - local/remote operations', () => { + const tmpBase = join(tmpdir(), `cli-test-cp-${testPrefix}`); + + beforeAll(() => { + mkdirSync(tmpBase, { recursive: true }); + // Create test content remotely for download tests + const tmpUp = join(tmpBase, 'upload-src.txt'); + writeFileSync(tmpUp, testContent); + runCli(`objects put ${testBucket} cp-dl-test.txt ${tmpUp}`); + }); + + afterAll(() => { + rmSync(tmpBase, { recursive: true, force: true }); + runCli(`rm ${t3(testBucket)}/cp-dl-test.txt -f`); + runCli(`rm ${t3(testBucket)}/cp-ul-test.txt -f`); + runCli(`rm ${t3(testBucket)}/cp-ul-dir/ -r -f`); + runCli(`rm ${t3(testBucket)}/cp-wc-dest/ -r -f`); + runCli(`rm ${t3(testBucket)}/cp-dl-dir/ -r -f`); + }); + + it('should download a remote file to local path', () => { + const localDest = join(tmpBase, 'downloaded.txt'); + const result = runCli( + `cp ${t3(testBucket)}/cp-dl-test.txt ${localDest}` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Downloaded'); + expect(existsSync(localDest)).toBe(true); + }); + + it('should upload a local file to remote', () => { + const localSrc = join(tmpBase, 'to-upload.txt'); + writeFileSync(localSrc, 'upload test content'); + const result = runCli( + `cp ${localSrc} ${t3(testBucket)}/cp-ul-test.txt` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Uploaded'); + + // Verify it exists + const ls = runCli(`ls ${testBucket}`); + expect(ls.stdout).toContain('cp-ul-test.txt'); + }); + + it('should upload a local directory recursively with -r', () => { + const localDir = join(tmpBase, 'upload-dir'); + mkdirSync(localDir, { recursive: true }); + writeFileSync(join(localDir, 'a.txt'), 'file-a'); + writeFileSync(join(localDir, 'b.txt'), 'file-b'); + const result = runCli( + `cp ${localDir}/ ${t3(testBucket)}/cp-ul-dir/ -r` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Uploaded'); + expect(result.stdout).toContain('2 file(s)'); + }); + + it('should download a remote directory recursively with -r', () => { + // Create remote files + runCli(`touch ${testBucket}/cp-dl-dir/x.txt`); + runCli(`touch ${testBucket}/cp-dl-dir/y.txt`); + const localDest = join(tmpBase, 'dl-dir'); + mkdirSync(localDest, { recursive: true }); + const result = runCli( + `cp ${t3(testBucket)}/cp-dl-dir/ ${localDest} -r` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Downloaded'); + expect(result.stdout).toContain('2 file(s)'); + }); + + it('should copy objects matching wildcard pattern', () => { + runCli(`touch ${testBucket}/cp-wc-a.txt`); + runCli(`touch ${testBucket}/cp-wc-b.txt`); + const result = runCli( + `cp ${t3(testBucket)}/cp-wc-* ${t3(testBucket)}/cp-wc-dest/` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Copied'); + expect(result.stdout).toContain('2 object(s)'); + + // Cleanup source wildcard files + runCli(`rm ${t3(testBucket)}/cp-wc-a.txt -f`); + runCli(`rm ${t3(testBucket)}/cp-wc-b.txt -f`); + }); + }); + + describe('mv command - additional branches', () => { + it('should move objects matching wildcard with -f', () => { + runCli(`touch ${testBucket}/mv-wc-a.txt`); + runCli(`touch ${testBucket}/mv-wc-b.txt`); + const result = runCli( + `mv ${t3(testBucket)}/mv-wc-* ${t3(testBucket)}/mv-wc-dest/ -f` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Moved'); + expect(result.stdout).toContain('2 object(s)'); + + // Cleanup + runCli(`rm ${t3(testBucket)}/mv-wc-dest/ -r -f`); + }); + + it('should error on folder move without -r', () => { + runCli(`mk ${testBucket}/mv-no-r/`); + runCli(`touch ${testBucket}/mv-no-r/file.txt`); + const result = runCli( + `mv ${t3(testBucket)}/mv-no-r ${t3(testBucket)}/mv-no-r-dest -f` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Use -r to move recursively'); + + // Cleanup + runCli(`rm ${t3(testBucket)}/mv-no-r/ -r -f`); + }); + }); + + describe('rm command - additional branches', () => { + it('should delete a bucket with -f', () => { + const name = `${testPrefix}-rm-bkt`; + runCli(`mk ${name}`); + const result = runCli(`rm ${t3(name)} -f`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`Removed bucket '${name}'`); + }); + + it('should error on folder removal without -r', () => { + runCli(`mk ${testBucket}/rm-no-r/`); + runCli(`touch ${testBucket}/rm-no-r/file.txt`); + const result = runCli(`rm ${t3(testBucket)}/rm-no-r -f`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Use -r to remove recursively'); + + // Cleanup + runCli(`rm ${t3(testBucket)}/rm-no-r/ -r -f`); + }); + }); + + describe('objects get - additional branches', () => { + const tmpBase = join(tmpdir(), `cli-test-objget-${testPrefix}`); + + beforeAll(() => { + mkdirSync(tmpBase, { recursive: true }); + // Upload a text file for get tests + const tmpFile = join(tmpBase, 'src.txt'); + writeFileSync(tmpFile, testContent); + runCli(`objects put ${testBucket} objget-test.txt ${tmpFile}`); + }); + + afterAll(() => { + rmSync(tmpBase, { recursive: true, force: true }); + runCli(`rm ${t3(testBucket)}/objget-test.txt -f`); + }); + + it('should get object with --output to file', () => { + const outPath = join(tmpBase, 'output.txt'); + const result = runCli( + `objects get ${testBucket} objget-test.txt --output ${outPath}` + ); + expect(result.exitCode).toBe(0); + expect(existsSync(outPath)).toBe(true); + const content = readFileSync(outPath, 'utf-8'); + expect(content).toContain(testContent); + }); + + it('should get object with --mode string', () => { + const result = runCli( + `objects get ${testBucket} objget-test.txt --mode string` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(testContent); + }); + }); + + describe('objects put - additional branches', () => { + const tmpBase = join(tmpdir(), `cli-test-objput-${testPrefix}`); + + beforeAll(() => { + mkdirSync(tmpBase, { recursive: true }); + }); + + afterAll(() => { + rmSync(tmpBase, { recursive: true, force: true }); + runCli(`rm ${t3(testBucket)}/objput-pub.txt -f`); + runCli(`rm ${t3(testBucket)}/objput-ct.json -f`); + runCli(`rm ${t3(testBucket)}/objput-fmt.txt -f`); + }); + + it('should upload with --access public', () => { + const tmpFile = join(tmpBase, 'pub.txt'); + writeFileSync(tmpFile, 'public content'); + const result = runCli( + `objects put ${testBucket} objput-pub.txt ${tmpFile} --access public` + ); + expect(result.exitCode).toBe(0); + }); + + it('should upload with --content-type application/json', () => { + const tmpFile = join(tmpBase, 'ct.json'); + writeFileSync(tmpFile, '{"key":"value"}'); + const result = runCli( + `objects put ${testBucket} objput-ct.json ${tmpFile} --content-type application/json` + ); + expect(result.exitCode).toBe(0); + }); + + it('should upload with --format json', () => { + const tmpFile = join(tmpBase, 'fmt.txt'); + writeFileSync(tmpFile, 'format test'); + const result = runCli( + `objects put ${testBucket} objput-fmt.txt ${tmpFile} --format json` + ); + expect(result.exitCode).toBe(0); + // stdout contains progress line then JSON; extract just the JSON portion + const jsonStart = result.stdout.indexOf('['); + expect(jsonStart).toBeGreaterThanOrEqual(0); + expect(() => JSON.parse(result.stdout.slice(jsonStart))).not.toThrow(); + }); + }); + + describe('objects list - additional branches', () => { + beforeAll(() => { + runCli(`touch ${testBucket}/objlist-a.txt`); + runCli(`touch ${testBucket}/objlist-b.txt`); + }); + + afterAll(() => { + runCli(`rm ${t3(testBucket)}/objlist-a.txt -f`); + runCli(`rm ${t3(testBucket)}/objlist-b.txt -f`); + }); + + it('should list with --prefix filter', () => { + const result = runCli( + `objects list ${testBucket} --prefix objlist-a` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('objlist-a.txt'); + expect(result.stdout).not.toContain('objlist-b.txt'); + }); + + it('should list with --format json', () => { + const result = runCli( + `objects list ${testBucket} --format json` + ); + expect(result.exitCode).toBe(0); + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + }); + + it('should handle empty results gracefully', () => { + const result = runCli( + `objects list ${testBucket} --prefix nonexistent-prefix-xyz` + ); + expect(result.exitCode).toBe(0); + }); + }); + + // ─── Section B: Completely untested commands ─── + + describe('stat command', () => { + beforeAll(() => { + runCli(`touch ${testBucket}/stat-test.txt`); + }); + + afterAll(() => { + runCli(`rm ${t3(testBucket)}/stat-test.txt -f`); + }); + + it('should show overall stats (no path)', () => { + const result = runCli('stat'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Active Buckets'); + expect(result.stdout).toContain('Total Objects'); + }); + + it('should show bucket info', () => { + const result = runCli(`stat ${testBucket}`); + expect(result.exitCode).toBe(0); + // Bucket stat shows a table with metrics + expect(result.stdout).toContain('Metric'); + }); + + it('should show object metadata', () => { + const result = runCli(`stat ${testBucket}/stat-test.txt`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Size'); + expect(result.stdout).toContain('Content-Type'); + }); + + it('should output --format json for overall stats', () => { + const result = runCli('stat --format json'); + expect(result.exitCode).toBe(0); + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + }); + + it('should output --format json for bucket info', () => { + const result = runCli(`stat ${testBucket} --format json`); + expect(result.exitCode).toBe(0); + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + }); + + it('should output --format json for object info', () => { + const result = runCli( + `stat ${testBucket}/stat-test.txt --format json` + ); + expect(result.exitCode).toBe(0); + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + }); + }); + + describe('presign command', () => { + const accessKey = process.env.TIGRIS_STORAGE_ACCESS_KEY_ID!; + + beforeAll(() => { + runCli(`touch ${testBucket}/presign-test.txt`); + }); + + afterAll(() => { + runCli(`rm ${t3(testBucket)}/presign-test.txt -f`); + }); + + it('should generate presigned GET URL', () => { + const result = runCli( + `presign ${testBucket}/presign-test.txt --access-key ${accessKey}` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toMatch(/^https:\/\//); + }); + + it('should generate presigned PUT URL with --method put', () => { + const result = runCli( + `presign ${testBucket}/presign-test.txt --method put --access-key ${accessKey}` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toMatch(/^https:\/\//); + }); + + it('should accept --expires-in 600', () => { + const result = runCli( + `presign ${testBucket}/presign-test.txt --expires-in 600 --access-key ${accessKey}` + ); + expect(result.exitCode).toBe(0); + }); + + it('should output --format json', () => { + const result = runCli( + `presign ${testBucket}/presign-test.txt --format json --access-key ${accessKey}` + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(parsed).toHaveProperty('url'); + expect(parsed).toHaveProperty('method'); + expect(parsed).toHaveProperty('bucket'); + expect(parsed).toHaveProperty('key'); + }); + + it('should output URL-only with default format', () => { + const result = runCli( + `presign ${testBucket}/presign-test.txt --access-key ${accessKey}` + ); + expect(result.exitCode).toBe(0); + // Should not be JSON, just a URL + expect(() => JSON.parse(result.stdout.trim())).toThrow(); + expect(result.stdout.trim()).toMatch(/^https:\/\//); + }); + + it('should error without path', () => { + const result = runCli('presign'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('required'); + }); + + it('should error on bucket-only path', () => { + const result = runCli( + `presign ${testBucket} --access-key ${accessKey}` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Object key is required'); + }); + }); + + describe('buckets list command', () => { + it('should list buckets', () => { + const result = runCli('buckets list'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(testBucket); + }); + + it('should list buckets with --format json', () => { + const result = runCli('buckets list --format json'); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.some((b: { name: string }) => b.name === testBucket)).toBe( + true + ); + }); + }); + + describe('buckets get command', () => { + it('should get bucket info', () => { + const result = runCli(`buckets get ${testBucket}`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Property'); + }); + + it('should error without bucket name', () => { + const result = runCli('buckets get'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("missing required argument 'name'"); + }); + }); + + describe('buckets delete command', () => { + it('should delete a single bucket with --force', () => { + const name = `${testPrefix}-bd-1`; + runCli(`mk ${name}`); + const result = runCli(`buckets delete ${name} --force`); + expect(result.exitCode).toBe(0); + }); + + it('should delete multiple buckets with --force', () => { + const name1 = `${testPrefix}-bd-2`; + const name2 = `${testPrefix}-bd-3`; + runCli(`mk ${name1}`); + runCli(`mk ${name2}`); + const result = runCli(`buckets delete ${name1},${name2} --force`); + expect(result.exitCode).toBe(0); + }); + + it('should fail without --force in non-TTY', () => { + const name = `${testPrefix}-bd-nf`; + runCli(`mk ${name}`); + const result = runCli(`buckets delete ${name}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--yes'); + // Cleanup + runCli(`buckets delete ${name} --force`); + }); + }); + + describe('buckets create command (non-interactive)', () => { + const bcBuckets: string[] = []; + + afterAll(() => { + for (const b of bcBuckets) { + runCli(`rm ${t3(b)} -f`); + } + }); + + it('should create with positional name', () => { + const name = `${testPrefix}-bc-1`; + bcBuckets.push(name); + const result = runCli(`buckets create ${name}`); + expect(result.exitCode).toBe(0); + }); + + it('should create with all flags', () => { + const name = `${testPrefix}-bc-all`; + bcBuckets.push(name); + const result = runCli( + `buckets create ${name} --access private --default-tier STANDARD --enable-snapshots --locations global` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error on --source-snapshot without --fork-of', () => { + const result = runCli( + `buckets create ${testPrefix}-bc-err --source-snapshot snap1` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--source-snapshot requires --fork-of'); + }); + }); + + describe('bucket settings commands', () => { + const setBucket = `${testPrefix}-set`; + + beforeAll(() => { + runCli(`mk ${setBucket}`); + }); + + afterAll(() => { + // Disable delete protection before cleanup + runCli( + `buckets set ${setBucket} --enable-delete-protection false` + ); + runCli(`rm ${t3(setBucket)} -f`); + }); + + describe('buckets set', () => { + it('should set --access public', () => { + const result = runCli( + `buckets set ${setBucket} --access public` + ); + expect(result.exitCode).toBe(0); + // Reset back + runCli(`buckets set ${setBucket} --access private`); + }); + + it('should set --cache-control "max-age=3600"', () => { + const result = runCli( + `buckets set ${setBucket} --cache-control "max-age=3600"` + ); + expect(result.exitCode).toBe(0); + }); + + it('should set --enable-delete-protection true', () => { + const result = runCli( + `buckets set ${setBucket} --enable-delete-protection true` + ); + expect(result.exitCode).toBe(0); + // Disable for cleanup + runCli( + `buckets set ${setBucket} --enable-delete-protection false` + ); + }); + + it('should set --locations usa', () => { + const result = runCli( + `buckets set ${setBucket} --locations usa` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error when no settings provided', () => { + const result = runCli(`buckets set ${setBucket}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('At least one setting is required'); + }); + + it('should error without bucket name', () => { + const result = runCli('buckets set --access public'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("missing required argument 'name'"); + }); + }); + + describe('buckets set-ttl', () => { + it('should set TTL with --days 30', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --days 30` + ); + expect(result.exitCode).toBe(0); + }); + + it('should set TTL with --date 2027-01-01', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --date 2027-01-01` + ); + expect(result.exitCode).toBe(0); + }); + + it('should enable with --enable', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --enable` + ); + expect(result.exitCode).toBe(0); + }); + + it('should disable with --disable', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --disable` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error when using both --enable and --disable', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --enable --disable` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use both --enable and --disable' + ); + }); + + it('should error when using --disable with --days', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --disable --days 30` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use --disable with --days or --date' + ); + }); + + it('should error on invalid --days', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --days -5` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--days must be a positive number'); + }); + + it('should error on invalid --date', () => { + const result = runCli( + `buckets set-ttl ${setBucket} --date not-a-date` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + '--date must be a valid ISO-8601 date' + ); + }); + + it('should error when no action provided', () => { + const result = runCli(`buckets set-ttl ${setBucket}`); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Provide --days, --date, --enable, or --disable' + ); + }); + }); + + describe('buckets set-locations', () => { + it('should set locations with --locations usa', () => { + const result = runCli( + `buckets set-locations ${setBucket} --locations usa` + ); + expect(result.exitCode).toBe(0); + }); + }); + + describe('buckets set-migration', () => { + it('should disable migration', () => { + const result = runCli( + `buckets set-migration ${setBucket} --disable` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error on --disable with other options', () => { + const result = runCli( + `buckets set-migration ${setBucket} --disable --bucket other` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use --disable with other migration options' + ); + }); + + it('should error when missing required params', () => { + const result = runCli( + `buckets set-migration ${setBucket} --bucket other` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Required:'); + }); + }); + + describe('buckets set-transition', () => { + it('should set with --days 30 --storage-class GLACIER', () => { + const result = runCli( + `buckets set-transition ${setBucket} --days 30 --storage-class GLACIER` + ); + expect(result.exitCode).toBe(0); + }); + + it('should set with --date 2027-01-01 --storage-class GLACIER_IR', () => { + const result = runCli( + `buckets set-transition ${setBucket} --date 2027-01-01 --storage-class GLACIER_IR` + ); + expect(result.exitCode).toBe(0); + }); + + it('should enable with --enable', () => { + const result = runCli( + `buckets set-transition ${setBucket} --enable` + ); + expect(result.exitCode).toBe(0); + }); + + it('should disable with --disable', () => { + const result = runCli( + `buckets set-transition ${setBucket} --disable` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error on invalid storage class STANDARD', () => { + const result = runCli( + `buckets set-transition ${setBucket} --days 30 --storage-class STANDARD` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'STANDARD is not a valid transition target' + ); + }); + + it('should error on --days without --storage-class', () => { + const result = runCli( + `buckets set-transition ${setBucket} --days 30` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + '--storage-class is required when setting --days or --date' + ); + }); + + it('should error when using both --enable and --disable', () => { + const result = runCli( + `buckets set-transition ${setBucket} --enable --disable` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use both --enable and --disable' + ); + }); + + it('should error on --disable with --days', () => { + const result = runCli( + `buckets set-transition ${setBucket} --disable --days 30` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use --disable with --days, --date, or --storage-class' + ); + }); + + it('should error when no action provided', () => { + const result = runCli( + `buckets set-transition ${setBucket}` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Provide --days, --date, --enable, or --disable' + ); + }); + + it('should error on invalid --days', () => { + const result = runCli( + `buckets set-transition ${setBucket} --days -1 --storage-class GLACIER` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--days must be a positive number'); + }); + }); + + describe('buckets set-notifications', () => { + it('should enable with --url', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --url https://example.com/webhook` + ); + expect(result.exitCode).toBe(0); + }); + + it('should disable', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --disable` + ); + expect(result.exitCode).toBe(0); + }); + + it('should reset', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --reset` + ); + expect(result.exitCode).toBe(0); + }); + + it('should accept --token auth', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --url https://example.com/webhook --token my-secret-token` + ); + expect(result.exitCode).toBe(0); + }); + + it('should accept --username/--password auth', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --url https://example.com/webhook --username user1 --password pass1` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error on multiple action flags', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --enable --disable` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Only one of --enable, --disable, or --reset can be used' + ); + }); + + it('should error on --reset with other options', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --reset --url https://example.com` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use --reset with other options' + ); + }); + + it('should error on --token with --username', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --url https://example.com --token tok --username user` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use --token with --username/--password' + ); + }); + + it('should error on --username without --password', () => { + const result = runCli( + `buckets set-notifications ${setBucket} --url https://example.com --username user` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Both --username and --password are required' + ); + }); + + it('should error when no options provided', () => { + const result = runCli( + `buckets set-notifications ${setBucket}` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Provide at least one option'); + }); + }); + + describe('buckets set-cors', () => { + it('should set with --origins and --methods', () => { + const result = runCli( + `buckets set-cors ${setBucket} --origins "*" --methods "GET,POST"` + ); + expect(result.exitCode).toBe(0); + }); + + it('should reset with --reset', () => { + const result = runCli( + `buckets set-cors ${setBucket} --reset` + ); + expect(result.exitCode).toBe(0); + }); + + it('should set with --override', () => { + const result = runCli( + `buckets set-cors ${setBucket} --origins "*" --override` + ); + expect(result.exitCode).toBe(0); + }); + + it('should error on --reset with other options', () => { + const result = runCli( + `buckets set-cors ${setBucket} --reset --origins "*"` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Cannot use --reset with other options' + ); + }); + + it('should error without --origins or --reset', () => { + const result = runCli( + `buckets set-cors ${setBucket} --methods "GET"` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Provide --origins or --reset'); + }); + + it('should error on invalid --max-age', () => { + const result = runCli( + `buckets set-cors ${setBucket} --origins "*" --max-age -1` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + '--max-age must be a positive number' + ); + }); + }); + }); + + describe('objects delete command', () => { + it('should delete a single object with --force', () => { + runCli(`touch ${testBucket}/objdel-1.txt`); + const result = runCli( + `objects delete ${testBucket} objdel-1.txt --force` + ); + expect(result.exitCode).toBe(0); + }); + + it('should delete multiple objects with --force', () => { + runCli(`touch ${testBucket}/objdel-2.txt`); + runCli(`touch ${testBucket}/objdel-3.txt`); + const result = runCli( + `objects delete ${testBucket} objdel-2.txt,objdel-3.txt --force` + ); + expect(result.exitCode).toBe(0); + }); + + it('should fail without --force in non-TTY', () => { + runCli(`touch ${testBucket}/objdel-noforce.txt`); + const result = runCli( + `objects delete ${testBucket} objdel-noforce.txt` + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--yes'); + // Cleanup + runCli(`objects delete ${testBucket} objdel-noforce.txt --force`); + }); + }); + + describe('objects set command', () => { + beforeAll(() => { + runCli(`touch ${testBucket}/objset-test.txt`); + }); + + afterAll(() => { + // The object may have been renamed + runCli(`rm ${t3(testBucket)}/objset-test.txt -f`); + runCli(`rm ${t3(testBucket)}/objset-renamed.txt -f`); + }); + + it('should set --access public', () => { + const result = runCli( + `objects set ${testBucket} objset-test.txt --access public` + ); + expect(result.exitCode).toBe(0); + }); + + it('should set --access private', () => { + const result = runCli( + `objects set ${testBucket} objset-test.txt --access private` + ); + expect(result.exitCode).toBe(0); + }); + + it('should rename with --new-key', () => { + const result = runCli( + `objects set ${testBucket} objset-test.txt --access private --new-key objset-renamed.txt` + ); + expect(result.exitCode).toBe(0); + + // Verify rename + const ls = runCli(`ls ${testBucket}`); + expect(ls.stdout).toContain('objset-renamed.txt'); + }); + }); + + describe('snapshot and fork lifecycle', () => { + const snapBucket = `${testPrefix}-snap`; + const forkBucket = `${testPrefix}-fork`; + let snapshotVersion: string; + + beforeAll(() => { + runCli(`mk ${snapBucket} --enable-snapshots`); + runCli(`touch ${snapBucket}/snap-file.txt`); + }); + + afterAll(() => { + runCli(`rm ${t3(forkBucket)} -f`); + runCli(`rm ${t3(snapBucket)}/snap-file.txt -f`); + runCli(`rm ${t3(snapBucket)} -f`); + }); + + it('should take a snapshot', () => { + const result = runCli(`snapshots take ${snapBucket}`); + expect(result.exitCode).toBe(0); + }); + + it('should take a named snapshot with --snapshot-name', () => { + const result = runCli( + `snapshots take ${snapBucket} test-snap` + ); + expect(result.exitCode).toBe(0); + }); + + it('should list snapshots', () => { + const result = runCli(`snapshots list ${snapBucket}`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Version'); + }); + + it('should list snapshots with --format json', () => { + const result = runCli( + `snapshots list ${snapBucket} --format json` + ); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout.trim()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + // Save version for later tests + snapshotVersion = parsed[0].version; + expect(snapshotVersion).toBeTruthy(); + }); + + it('should ls with --snapshot-version', () => { + const result = runCli( + `ls ${snapBucket} --snapshot-version ${snapshotVersion}` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('snap-file.txt'); + }); + + it('should objects list with --snapshot-version', () => { + const result = runCli( + `objects list ${snapBucket} --snapshot-version ${snapshotVersion}` + ); + expect(result.exitCode).toBe(0); + }); + + it('should stat object with --snapshot-version', () => { + const result = runCli( + `stat ${snapBucket}/snap-file.txt --snapshot-version ${snapshotVersion}` + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Size'); + }); + + it('should create a fork via forks create', () => { + const result = runCli( + `forks create ${snapBucket} ${forkBucket}` + ); + expect(result.exitCode).toBe(0); + }); + + it('should list forks', () => { + // Retry — fork visibility is eventually consistent + let result = { stdout: '', stderr: '', exitCode: 1 }; + for (let i = 0; i < 3; i++) { + result = runCli(`forks list ${snapBucket}`); + if (result.exitCode === 0 && result.stdout.includes(forkBucket)) break; + if (i < 2) execSync('sleep 5'); + } + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(forkBucket); + }, 120_000); + + it('should list forks with --format json', () => { + const result = runCli( + `forks list ${snapBucket} --format json` + ); + expect(result.exitCode).toBe(0); + // May return JSON array or empty (printEmpty is TTY-gated) + if (result.stdout.trim()) { + expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); + } + }, 120_000); + + it('should list forks via buckets list --forks-of', () => { + // Retry — fork visibility is eventually consistent + let result = { stdout: '', stderr: '', exitCode: 1 }; + for (let i = 0; i < 3; i++) { + result = runCli(`buckets list --forks-of ${snapBucket}`); + if (result.exitCode === 0 && result.stdout.includes(forkBucket)) break; + if (i < 2) execSync('sleep 5'); + } + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(forkBucket); + }, 120_000); + }); + + describe('credentials test command', () => { + it('should verify credentials (no bucket)', () => { + const result = runCli('credentials test'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Access verified'); + }); + + it('should verify credentials for specific bucket', () => { + const result = runCli(`credentials test --bucket ${testBucket}`); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Access verified'); }); }); }); diff --git a/test/specs-completeness.test.ts b/test/specs-completeness.test.ts new file mode 100644 index 0000000..0f5d12f --- /dev/null +++ b/test/specs-completeness.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import * as YAML from 'yaml'; +import { setSpecs, loadSpecs } from '../src/utils/specs.js'; +import type { CommandSpec, Specs } from '../src/types.js'; + +interface LeafCommand { + spec: CommandSpec; + path: string[]; +} + +/** + * Recursively walk the spec tree and collect all leaf commands. + * A leaf is a command with no children, OR whose only role is as a + * default target of its parent (in which case the parent routes to it). + */ +function collectLeaves( + commands: CommandSpec[], + parentPath: string[] = [] +): LeafCommand[] { + const leaves: LeafCommand[] = []; + + for (const cmd of commands) { + const currentPath = [...parentPath, cmd.name]; + + if (!cmd.commands || cmd.commands.length === 0) { + // No children → leaf + leaves.push({ spec: cmd, path: currentPath }); + } else { + // Has children → recurse into them + leaves.push(...collectLeaves(cmd.commands, currentPath)); + } + } + + return leaves; +} + +/** + * Recursively collect ALL commands (not just leaves) for structural checks. + */ +function collectAllCommands( + commands: CommandSpec[], + parentPath: string[] = [] +): LeafCommand[] { + const all: LeafCommand[] = []; + + for (const cmd of commands) { + const currentPath = [...parentPath, cmd.name]; + all.push({ spec: cmd, path: currentPath }); + + if (cmd.commands && cmd.commands.length > 0) { + all.push(...collectAllCommands(cmd.commands, currentPath)); + } + } + + return all; +} + +const srcRoot = join(process.cwd(), 'src', 'lib'); + +// Pre-populate specs cache from source YAML so we don't need dist/ +const specsYaml = readFileSync(join(process.cwd(), 'src', 'specs.yaml'), 'utf8'); +setSpecs(YAML.parse(specsYaml, { schema: 'core' }) as Specs); + +describe('specs completeness', () => { + const specs = loadSpecs(); + const leaves = collectLeaves(specs.commands); + const allCommands = collectAllCommands(specs.commands); + + it('found leaf commands to validate', () => { + expect(leaves.length).toBeGreaterThan(0); + }); + + describe('every leaf command has a handler file', () => { + for (const { path } of leaves) { + const label = path.join(' '); + it(`${label}`, () => { + const filePath = join(srcRoot, ...path) + '.ts'; + const indexPath = join(srcRoot, ...path, 'index.ts'); + const exists = existsSync(filePath) || existsSync(indexPath); + expect(exists, `Missing handler: ${filePath} or ${indexPath}`).toBe( + true + ); + }); + } + }); + + describe('every leaf command has a messages block', () => { + for (const { spec, path } of leaves) { + const label = path.join(' '); + it(`${label}`, () => { + expect( + spec.messages, + `${label} is missing a messages block` + ).toBeDefined(); + }); + } + }); + + describe('no duplicate argument names within a command', () => { + for (const { spec, path } of allCommands) { + if (!spec.arguments || spec.arguments.length === 0) continue; + const label = path.join(' '); + it(`${label}`, () => { + const names = spec.arguments!.map((a) => a.name); + expect(names.length).toBe(new Set(names).size); + }); + } + }); + + describe('no alias collisions within a command', () => { + for (const { spec, path } of allCommands) { + if (!spec.arguments || spec.arguments.length === 0) continue; + const label = path.join(' '); + it(`${label}`, () => { + const names = spec.arguments!.map((a) => a.name); + const aliases = spec.arguments! + .filter((a) => a.alias) + .map((a) => a.alias as string); + + // No alias should match another arg's name + for (const alias of aliases) { + // An alias matching its own arg's name is fine (long alias pattern), + // but it shouldn't match a *different* arg's name + const argsWithThisAlias = spec.arguments!.filter( + (a) => a.alias === alias + ); + const otherNames = names.filter( + (n) => !argsWithThisAlias.some((a) => a.name === n) && n === alias + ); + expect( + otherNames.length, + `Alias "${alias}" collides with arg name in ${label}` + ).toBe(0); + } + + // No two aliases should be the same + expect(aliases.length).toBe(new Set(aliases).size); + }); + } + }); + + describe('deprecated commands have onDeprecated message', () => { + const deprecated = allCommands.filter(({ spec }) => spec.deprecated); + + if (deprecated.length === 0) { + it('no deprecated commands found (skip)', () => { + expect(true).toBe(true); + }); + } + + for (const { spec, path } of deprecated) { + const label = path.join(' '); + it(`${label}`, () => { + expect( + spec.messages?.onDeprecated, + `Deprecated command ${label} is missing onDeprecated message` + ).toBeDefined(); + }); + } + }); +}); diff --git a/test/utils/format.test.ts b/test/utils/format.test.ts new file mode 100644 index 0000000..7e2938e --- /dev/null +++ b/test/utils/format.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from 'vitest'; +import { + formatSize, + formatJson, + formatXml, + formatXmlObject, + formatTable, + formatOutput, + type TableColumn, +} from '../../src/utils/format.js'; + +describe('formatSize', () => { + it.each([ + [0, '0 B'], + [500, '500 B'], + [1024, '1.0 KB'], + [1536, '1.5 KB'], + [1048576, '1.0 MB'], + [1073741824, '1.0 GB'], + [1099511627776, '1.0 TB'], + ])('formatSize(%d) → %s', (bytes, expected) => { + expect(formatSize(bytes)).toBe(expected); + }); +}); + +describe('formatJson', () => { + it('formats object with 2-space indent', () => { + const result = formatJson({ name: 'test', count: 42 }); + expect(result).toBe(JSON.stringify({ name: 'test', count: 42 }, null, 2)); + }); + + it('formats array', () => { + const result = formatJson([1, 2, 3]); + expect(result).toBe(JSON.stringify([1, 2, 3], null, 2)); + }); +}); + +describe('formatXml', () => { + it('wraps items in root and item tags', () => { + const items = [{ name: 'test', size: 100 }]; + const result = formatXml(items, 'Buckets', 'Bucket'); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain('test'); + expect(result).toContain('100'); + }); + + it('escapes special XML characters', () => { + const items = [{ text: '& < > " \'' }]; + const result = formatXml(items, 'Root', 'Item'); + expect(result).toContain('&'); + expect(result).toContain('<'); + expect(result).toContain('>'); + expect(result).toContain('"'); + expect(result).toContain('''); + }); +}); + +describe('formatXmlObject', () => { + it('formats object fields as XML elements', () => { + const result = formatXmlObject({ key: 'value' }, ' '); + expect(result).toBe(' value'); + }); +}); + +describe('formatTable', () => { + const columns: TableColumn[] = [ + { key: 'name', header: 'Name' }, + { key: 'size', header: 'Size' }, + ]; + + it('contains box-drawing characters', () => { + const items = [{ name: 'test', size: '1 KB' }]; + const result = formatTable(items, columns); + expect(result).toContain('┌'); + expect(result).toContain('─'); + expect(result).toContain('┬'); + expect(result).toContain('┐'); + expect(result).toContain('│'); + expect(result).toContain('├'); + expect(result).toContain('┼'); + expect(result).toContain('┤'); + expect(result).toContain('└'); + expect(result).toContain('┴'); + expect(result).toContain('┘'); + }); + + it('includes header row with column names', () => { + const items = [{ name: 'test', size: '1 KB' }]; + const result = formatTable(items, columns); + expect(result).toContain('Name'); + expect(result).toContain('Size'); + }); + + it('includes data values', () => { + const items = [{ name: 'my-bucket', size: '42 MB' }]; + const result = formatTable(items, columns); + expect(result).toContain('my-bucket'); + expect(result).toContain('42 MB'); + }); + + it('right-aligns when specified', () => { + const cols: TableColumn[] = [ + { key: 'name', header: 'Name' }, + { key: 'count', header: 'Count', align: 'right' }, + ]; + const items = [{ name: 'a', count: '5' }]; + const result = formatTable(items, cols); + // The right-aligned cell should have leading spaces before the value + const lines = result.split('\n'); + const dataLine = lines.find( + (l) => l.includes('│') && l.includes('5') && !l.includes('Count') + ); + expect(dataLine).toBeDefined(); + // In right-align, "5" is padStart'd, so the cell content ends with "5 │" + // meaning there are spaces before "5" and the value is right-justified + const cells = dataLine!.split('│'); + const countCell = cells[2]; // space + value + space + // Right-aligned: value should be at the end of the cell (after trimming the border space) + const trimmed = countCell.slice(1, -1); // remove border padding spaces + expect(trimmed).toBe('5'.padStart(trimmed.length)); + }); + + it('renders header even with empty items', () => { + const result = formatTable([], columns); + expect(result).toContain('Name'); + expect(result).toContain('Size'); + expect(result).toContain('┌'); + expect(result).toContain('┘'); + }); +}); + +describe('formatOutput', () => { + const columns: TableColumn[] = [ + { key: 'name', header: 'Name' }, + ]; + const items = [{ name: 'test' }]; + + it("'json' → JSON output", () => { + const result = formatOutput(items, 'json', 'Root', 'Item', columns); + expect(JSON.parse(result)).toEqual(items); + }); + + it("'xml' → XML output", () => { + const result = formatOutput(items, 'xml', 'Root', 'Item', columns); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it("'table' → table output", () => { + const result = formatOutput(items, 'table', 'Root', 'Item', columns); + expect(result).toContain('┌'); + expect(result).toContain('Name'); + }); + + it('default format → table output', () => { + const result = formatOutput(items, 'anything', 'Root', 'Item', columns); + expect(result).toContain('┌'); + }); +}); diff --git a/test/utils/locations.test.ts b/test/utils/locations.test.ts new file mode 100644 index 0000000..5da0081 --- /dev/null +++ b/test/utils/locations.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { parseLocations } from '../../src/utils/locations.js'; + +describe('parseLocations', () => { + it("'global' → {type: 'global'}", () => { + expect(parseLocations('global')).toEqual({ type: 'global' }); + }); + + it("'' → {type: 'global'}", () => { + expect(parseLocations('')).toEqual({ type: 'global' }); + }); + + it("[] → {type: 'global'}", () => { + expect(parseLocations([])).toEqual({ type: 'global' }); + }); + + it("'usa' → multi region", () => { + expect(parseLocations('usa')).toEqual({ type: 'multi', values: 'usa' }); + }); + + it("'eur' → multi region", () => { + expect(parseLocations('eur')).toEqual({ type: 'multi', values: 'eur' }); + }); + + it("'ams' → single region", () => { + expect(parseLocations('ams')).toEqual({ type: 'single', values: 'ams' }); + }); + + it("'sjc' → single region", () => { + expect(parseLocations('sjc')).toEqual({ type: 'single', values: 'sjc' }); + }); + + it("'ams,fra' → dual region", () => { + expect(parseLocations('ams,fra')).toEqual({ + type: 'dual', + values: ['ams', 'fra'], + }); + }); + + it("['ams', 'fra'] → dual region", () => { + expect(parseLocations(['ams', 'fra'])).toEqual({ + type: 'dual', + values: ['ams', 'fra'], + }); + }); + + it("trims whitespace: ' ams , fra '", () => { + expect(parseLocations(' ams , fra ')).toEqual({ + type: 'dual', + values: ['ams', 'fra'], + }); + }); + + it("flattens: ['ams,fra', 'sjc']", () => { + expect(parseLocations(['ams,fra', 'sjc'])).toEqual({ + type: 'dual', + values: ['ams', 'fra', 'sjc'], + }); + }); +}); diff --git a/test/utils/messages.test.ts b/test/utils/messages.test.ts new file mode 100644 index 0000000..5827288 --- /dev/null +++ b/test/utils/messages.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as YAML from 'yaml'; +import { setSpecs } from '../../src/utils/specs.js'; +import { + printFailure, + printDeprecated, + printStart, + printSuccess, + printEmpty, + printAlreadyDone, + printHint, +} from '../../src/utils/messages.js'; + +// Save original descriptor so we can restore it +const originalIsTTY = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY' +); + +function setTTY(value: boolean) { + Object.defineProperty(process.stdout, 'isTTY', { + value, + writable: true, + configurable: true, + }); +} + +function restoreTTY() { + if (originalIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTY); + } else { + delete (process.stdout as unknown as Record).isTTY; + } +} + +// Pre-populate specs cache from source YAML so we don't need dist/ +const specsYaml = readFileSync(join(process.cwd(), 'src', 'specs.yaml'), 'utf8'); +setSpecs(YAML.parse(specsYaml, { schema: 'core' })); + +describe('messages', () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + restoreTTY(); + }); + + // Use a real command from specs for testing + const ctx = { command: 'buckets', operation: 'create' }; + + describe('printFailure', () => { + it('prints error with ✖ prefix (TTY)', () => { + setTTY(true); + printFailure(ctx, 'something went wrong'); + expect(errorSpy).toHaveBeenCalled(); + const allArgs = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allArgs).toContain('✖'); + expect(allArgs).toContain('something went wrong'); + }); + + it('prints error even when not TTY', () => { + setTTY(false); + printFailure(ctx, 'something went wrong'); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('printDeprecated', () => { + it('prints ⚠ Deprecated when TTY', () => { + setTTY(true); + printDeprecated('use new-command instead'); + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toContain('⚠ Deprecated:'); + expect(warnSpy.mock.calls[0][0]).toContain('use new-command instead'); + }); + + it('is silent when not TTY', () => { + setTTY(false); + printDeprecated('use new-command instead'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('printStart', () => { + it('prints onStart message when TTY', () => { + setTTY(true); + printStart(ctx); + expect(logSpy).toHaveBeenCalled(); + }); + + it('is silent when not TTY', () => { + setTTY(false); + printStart(ctx); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('printSuccess', () => { + it('prints ✔ prefix when TTY', () => { + setTTY(true); + printSuccess(ctx); + expect(logSpy).toHaveBeenCalled(); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain('✔'); + }); + + it('is silent when not TTY', () => { + setTTY(false); + printSuccess(ctx); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('printEmpty', () => { + // Use a command that has onEmpty message + const emptyCtx = { command: 'buckets', operation: 'list' }; + + it('prints when TTY', () => { + setTTY(true); + printEmpty(emptyCtx); + // Will only print if the spec has an onEmpty message + // Either way, it should not throw + }); + + it('is silent when not TTY', () => { + setTTY(false); + printEmpty(emptyCtx); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('printAlreadyDone', () => { + it('is silent when not TTY', () => { + setTTY(false); + printAlreadyDone(ctx); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('printHint', () => { + it('is silent when not TTY', () => { + setTTY(false); + printHint(ctx); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe('variable interpolation', () => { + it('replaces {{name}} in output', () => { + setTTY(true); + // buckets create onSuccess is "Bucket '{{name}}' created" + printSuccess(ctx, { name: 'my-bucket' }); + expect(logSpy).toHaveBeenCalled(); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain('my-bucket'); + expect(output).not.toContain('{{name}}'); + }); + }); +}); diff --git a/test/utils/specs.test.ts b/test/utils/specs.test.ts index fbfa961..8b964a5 100644 --- a/test/utils/specs.test.ts +++ b/test/utils/specs.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { getCommandSpec, getArgumentSpec } from '../../dist/utils/specs.js'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as YAML from 'yaml'; +import { setSpecs, getCommandSpec, getArgumentSpec } from '../../src/utils/specs.js'; + +// Pre-populate specs cache from source YAML so we don't need dist/ +const specsYaml = readFileSync(join(process.cwd(), 'src', 'specs.yaml'), 'utf8'); +setSpecs(YAML.parse(specsYaml, { schema: 'core' })); describe('getCommandSpec', () => { describe('top-level commands', () => {