From 76889527f380ee798b26e0656fae6ff791134429 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Mon, 30 Mar 2026 12:46:48 +0200 Subject: [PATCH 1/2] fix: restore --force flag on destructive commands and remove -f alias from --format The previous refactor (auto-inject global arguments) silently changed the meaning of -f from --force to --format on rm/mv commands. This restores --force/-f on rm and mv (supporting the -rf convention), adds --force (without -f alias) back to buckets delete, objects delete, access-keys delete, iam policies delete, iam users remove, and iam users revoke-invitation for backwards compatibility, and removes -f as an alias for --format globally. Also excludes auto-generated command-registry.ts from eslint. Co-Authored-By: Claude Opus 4.6 --- eslint.config.js | 2 +- src/lib/access-keys/delete.ts | 2 +- src/lib/buckets/delete.ts | 2 +- src/lib/iam/policies/delete.ts | 2 +- src/lib/iam/users/remove.ts | 2 +- src/lib/iam/users/revoke-invitation.ts | 2 +- src/lib/mv.ts | 2 +- src/lib/objects/delete.ts | 2 +- src/lib/rm.ts | 2 +- src/specs.yaml | 61 +++++++++++++++----------- src/utils/options.ts | 5 +-- test/cli.test.ts | 54 +++++++++++++++-------- 12 files changed, 81 insertions(+), 57 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index c73d7a0..da7d714 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,6 @@ export default tseslint.config( }, }, { - ignores: ["dist/", "node_modules/", "**/*.cjs"], + ignores: ["dist/", "node_modules/", "**/*.cjs", "src/command-registry.ts"], }, ); diff --git a/src/lib/access-keys/delete.ts b/src/lib/access-keys/delete.ts index adef649..9b61e89 100644 --- a/src/lib/access-keys/delete.ts +++ b/src/lib/access-keys/delete.ts @@ -13,7 +13,7 @@ export default async function remove(options: Record) { const format = getFormat(options); const id = getOption(options, ['id']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force']); if (!id) { failWithError(context, 'Access key ID is required'); diff --git a/src/lib/buckets/delete.ts b/src/lib/buckets/delete.ts index 9a3a1b8..9aea017 100644 --- a/src/lib/buckets/delete.ts +++ b/src/lib/buckets/delete.ts @@ -23,7 +23,7 @@ export default async function deleteBucket(options: Record) { const format = getFormat(options); const names = getOption(options, ['name']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force']); if (!names) { failWithError(context, 'Bucket name is required'); diff --git a/src/lib/iam/policies/delete.ts b/src/lib/iam/policies/delete.ts index bcc2de8..d652f3c 100644 --- a/src/lib/iam/policies/delete.ts +++ b/src/lib/iam/policies/delete.ts @@ -15,7 +15,7 @@ export default async function del(options: Record) { const format = getFormat(options); let resource = getOption(options, ['resource']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force']); const iamConfig = await getOAuthIAMConfig(context); diff --git a/src/lib/iam/users/remove.ts b/src/lib/iam/users/remove.ts index 5503813..ca64162 100644 --- a/src/lib/iam/users/remove.ts +++ b/src/lib/iam/users/remove.ts @@ -15,7 +15,7 @@ export default async function removeUser(options: Record) { const format = getFormat(options); const resourceOption = getOption(options, ['resource']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force']); if (isFlyOrganization()) return; diff --git a/src/lib/iam/users/revoke-invitation.ts b/src/lib/iam/users/revoke-invitation.ts index 293a1d6..639c4fe 100644 --- a/src/lib/iam/users/revoke-invitation.ts +++ b/src/lib/iam/users/revoke-invitation.ts @@ -17,7 +17,7 @@ export default async function revokeInvitation( const format = getFormat(options); const resourceOption = getOption(options, ['resource']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force']); if (isFlyOrganization()) return; diff --git a/src/lib/mv.ts b/src/lib/mv.ts index 3be9b63..b597bac 100644 --- a/src/lib/mv.ts +++ b/src/lib/mv.ts @@ -19,7 +19,7 @@ let _jsonMode = false; export default async function mv(options: Record) { const src = getOption(options, ['src']); const dest = getOption(options, ['dest']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force', 'f']); const recursive = !!getOption(options, ['recursive', 'r']); const format = getFormat(options); _jsonMode = format === 'json'; diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index ef70d84..02eb11d 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -24,7 +24,7 @@ export default async function deleteObject(options: Record) { const bucket = getOption(options, ['bucket']); const keys = getOption(options, ['key']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force']); if (!bucket) { failWithError(context, 'Bucket name is required'); diff --git a/src/lib/rm.ts b/src/lib/rm.ts index 4c17f81..16ee82b 100644 --- a/src/lib/rm.ts +++ b/src/lib/rm.ts @@ -16,7 +16,7 @@ let _jsonMode = false; export default async function rm(options: Record) { const pathString = getOption(options, ['path']); - const force = getOption(options, ['yes', 'y']); + const force = getOption(options, ['yes', 'y', 'force', 'f']); const recursive = !!getOption(options, ['recursive', 'r']); const format = getFormat(options); _jsonMode = format === 'json'; diff --git a/src/specs.yaml b/src/specs.yaml index 6adfd49..8c08053 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -89,7 +89,6 @@ definitions: global_arguments: &global_arguments - name: format description: Output format - alias: f options: [json, table] default: table - name: json @@ -280,7 +279,6 @@ commands: alias: snapshot - name: format description: Output format - alias: f options: [json, table, xml] default: table @@ -386,7 +384,6 @@ commands: - t3://my-bucket/my-object.json - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: snapshot-version @@ -426,7 +423,6 @@ commands: description: Access key ID to use for signing. If not provided, resolved from credentials or prompted interactively - name: format description: Output format - alias: f options: [url, json] default: url @@ -496,6 +492,10 @@ commands: type: flag alias: r description: Move directories recursively + - name: force + type: flag + alias: f + description: Skip confirmation prompts (alias for --yes) # rm - name: rm @@ -522,6 +522,10 @@ commands: type: flag alias: r description: Remove directories recursively + - name: force + type: flag + alias: f + description: Skip confirmation prompts (alias for --yes) ######################### # Manage organizations @@ -549,7 +553,6 @@ commands: arguments: - name: format description: Output format - alias: f options: [json, table, xml, select] default: select - name: select @@ -626,7 +629,6 @@ commands: arguments: - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: forks-of @@ -715,7 +717,6 @@ commands: - my-bucket - name: format description: Output format - alias: f options: [json, table, xml] default: table # delete @@ -723,8 +724,8 @@ commands: 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 --force" - - "tigris buckets delete bucket-a,bucket-b --force" + - "tigris buckets delete my-bucket --yes" + - "tigris buckets delete bucket-a,bucket-b --yes" messages: onStart: 'Deleting bucket...' onSuccess: "Bucket '{{name}}' deleted successfully" @@ -740,6 +741,9 @@ commands: multiple: true examples: - my-bucket + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) # set - name: set description: Update settings on an existing bucket such as access level, location, caching, or custom domain @@ -1027,7 +1031,6 @@ commands: - my-bucket - name: format description: Output format - alias: f options: [json, table, xml] default: table # create @@ -1091,7 +1094,6 @@ commands: - my-bucket - name: format description: Output format - alias: f options: [json, table, xml] default: table # take @@ -1156,7 +1158,6 @@ commands: alias: p - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: snapshot-version @@ -1238,7 +1239,6 @@ commands: alias: t - name: format description: Output format - alias: f options: [json, table, xml] default: table # delete @@ -1246,8 +1246,8 @@ commands: description: Delete one or more objects by key from the given bucket alias: d examples: - - "tigris objects delete my-bucket old-file.txt --force" - - "tigris objects delete my-bucket file-a.txt,file-b.txt --force" + - "tigris objects delete my-bucket old-file.txt --yes" + - "tigris objects delete my-bucket file-a.txt,file-b.txt --yes" messages: onStart: 'Deleting object...' onSuccess: "Object '{{key}}' deleted successfully" @@ -1269,6 +1269,9 @@ commands: multiple: true examples: - my-file.txt + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) # set - name: set description: Update settings on an existing object such as access level @@ -1322,7 +1325,6 @@ commands: arguments: - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: create @@ -1348,7 +1350,7 @@ commands: description: Permanently delete an access key by its ID. This revokes all access immediately alias: d examples: - - "tigris access-keys delete tid_AaBbCcDdEeFf --force" + - "tigris access-keys delete tid_AaBbCcDdEeFf --yes" messages: onStart: 'Deleting access key...' onSuccess: 'Access key deleted' @@ -1360,6 +1362,9 @@ commands: required: true examples: - tid_AaBbCcDdEeFf + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) - name: get description: Show details for an access key including its name, creation date, and assigned bucket roles alias: g @@ -1447,7 +1452,6 @@ commands: arguments: - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: get @@ -1470,7 +1474,6 @@ commands: - arn:aws:iam::org_id:policy/my-policy - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: create @@ -1528,7 +1531,7 @@ commands: alias: d examples: - "tigris iam policies delete" - - "tigris iam policies delete arn:aws:iam::org_id:policy/my-policy --force" + - "tigris iam policies delete arn:aws:iam::org_id:policy/my-policy --yes" messages: onStart: 'Deleting policy...' onSuccess: "Policy '{{resource}}' deleted" @@ -1541,6 +1544,9 @@ commands: required: false examples: - arn:aws:iam::org_id:policy/my-policy + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) - name: users description: Manage organization users and invitations @@ -1564,7 +1570,6 @@ commands: arguments: - name: format description: Output format - alias: f options: [json, table, xml] default: table - name: invite @@ -1597,8 +1602,8 @@ commands: alias: ri examples: - "tigris iam users revoke-invitation" - - "tigris iam users revoke-invitation invitation_id --force" - - "tigris iam users revoke-invitation id1,id2,id3 --force" + - "tigris iam users revoke-invitation invitation_id --yes" + - "tigris iam users revoke-invitation id1,id2,id3 --yes" messages: onStart: 'Revoking invitation...' onSuccess: "Invitation(s) revoked" @@ -1610,6 +1615,9 @@ commands: type: positional required: false multiple: true + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) - name: update-role description: Update user roles in the organization. If no user ID provided, shows interactive selection alias: ur @@ -1640,8 +1648,8 @@ commands: alias: rm examples: - "tigris iam users remove" - - "tigris iam users remove user@example.com --force" - - "tigris iam users remove user@example.com,user@example.net --force" + - "tigris iam users remove user@example.com --yes" + - "tigris iam users remove user@example.com,user@example.net --yes" messages: onStart: 'Removing user...' onSuccess: "User(s) removed" @@ -1653,3 +1661,6 @@ commands: type: positional required: false multiple: true + - name: force + type: flag + description: Skip confirmation prompts (alias for --yes) diff --git a/src/utils/options.ts b/src/utils/options.ts index b5434da..612b077 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -28,10 +28,7 @@ export function getFormat( ): string { const json = getOption(options, ['json']); if (json) return 'json'; - return ( - getOption(options, ['format', 'f', 'F'], defaultFormat) ?? - defaultFormat - ); + return getOption(options, ['format'], defaultFormat) ?? defaultFormat; } /** diff --git a/test/cli.test.ts b/test/cli.test.ts index 2ba5a46..0b29d45 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,15 +1,16 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { execSync } from 'child_process'; import { existsSync, - readFileSync, - writeFileSync, mkdirSync, + readFileSync, rmSync, + writeFileSync, } from 'fs'; -import { join } from 'path'; import { tmpdir } from 'os'; -import { shouldSkipIntegrationTests, getTestPrefix } from './setup.js'; +import { join } from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { getTestPrefix, shouldSkipIntegrationTests } from './setup.js'; const skipTests = shouldSkipIntegrationTests(); @@ -241,8 +242,8 @@ describe('CLI Help Commands', () => { }); }); -describe('Destructive commands require --force in non-TTY', () => { - // These tests verify that destructive commands refuse to run without --force +describe('Destructive commands require --yes in non-TTY', () => { + // These tests verify that destructive commands refuse to run without --yes/-y // when stdin is not a TTY (piped/scripted mode). Since runCli uses // stdio: ['ignore', ...], stdin is not a TTY. @@ -1175,30 +1176,37 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); describe('buckets delete command', () => { - it('should delete a single bucket with --force', () => { + it('should delete a single bucket with --yes', () => { const name = `${testPrefix}-bd-1`; runCli(`mk ${name}`); - const result = runCli(`buckets delete ${name} --force`); + const result = runCli(`buckets delete ${name} --yes`); expect(result.exitCode).toBe(0); }); - it('should delete multiple buckets with --force', () => { + it('should delete multiple buckets with --yes', () => { const name1 = `${testPrefix}-bd-2`; const name2 = `${testPrefix}-bd-3`; runCli(`mk ${name1}`); runCli(`mk ${name2}`); - const result = runCli(`buckets delete ${name1},${name2} --force`); + const result = runCli(`buckets delete ${name1},${name2} --yes`); + expect(result.exitCode).toBe(0); + }); + + it('should delete a bucket with --force (backwards compat)', () => { + const name = `${testPrefix}-bd-force`; + runCli(`mk ${name}`); + const result = runCli(`buckets delete ${name} --force`); expect(result.exitCode).toBe(0); }); - it('should fail without --force in non-TTY', () => { + it('should fail without --yes 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`); + runCli(`buckets delete ${name} --yes`); }); }); @@ -1637,24 +1645,32 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); describe('objects delete command', () => { - it('should delete a single object with --force', () => { + it('should delete a single object with --yes', () => { runCli(`touch ${testBucket}/objdel-1.txt`); const result = runCli( - `objects delete ${testBucket} objdel-1.txt --force` + `objects delete ${testBucket} objdel-1.txt --yes` ); expect(result.exitCode).toBe(0); }); - it('should delete multiple objects with --force', () => { + it('should delete multiple objects with --yes', () => { 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` + `objects delete ${testBucket} objdel-2.txt,objdel-3.txt --yes` + ); + expect(result.exitCode).toBe(0); + }); + + it('should delete an object with --force (backwards compat)', () => { + runCli(`touch ${testBucket}/objdel-force.txt`); + const result = runCli( + `objects delete ${testBucket} objdel-force.txt --force` ); expect(result.exitCode).toBe(0); }); - it('should fail without --force in non-TTY', () => { + it('should fail without --yes in non-TTY', () => { runCli(`touch ${testBucket}/objdel-noforce.txt`); const result = runCli( `objects delete ${testBucket} objdel-noforce.txt` @@ -1662,7 +1678,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain('--yes'); // Cleanup - runCli(`objects delete ${testBucket} objdel-noforce.txt --force`); + runCli(`objects delete ${testBucket} objdel-noforce.txt --yes`); }); }); From 453ea27745fb4161d1b85b89157e1fa1b2e19411 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Mon, 30 Mar 2026 13:06:05 +0200 Subject: [PATCH 2/2] ci: enable workflow_dispatch for integration tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7bcfae5..85dba44 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,7 @@ on: push: branches: - main + workflow_dispatch: jobs: build: @@ -28,7 +29,7 @@ jobs: integration: runs-on: ubuntu-latest - if: github.event_name == 'push' + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 with: