From aafe39a1aa1b6763d46b36df94b6ebb28fed3e69 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 31 Mar 2026 16:28:07 +0200 Subject: [PATCH 1/8] chore: include test files in lint and format scripts Extend lint, lint:fix, format, and format:check to cover test/**/*.ts alongside src. Fix import ordering and formatting across all test files. Co-Authored-By: Claude Opus 4.6 --- package.json | 8 +- test/auth/storage.test.ts | 9 +- test/cli-core.test.ts | 22 +++-- test/cli.test.ts | 164 ++++++++------------------------ test/setup.ts | 2 +- test/specs-completeness.test.ts | 17 ++-- test/utils/errors.test.ts | 15 ++- test/utils/exit.test.ts | 7 +- test/utils/format.test.ts | 13 ++- test/utils/locations.test.ts | 3 +- test/utils/messages.test.ts | 23 ++--- test/utils/options.test.ts | 3 +- test/utils/path.test.ts | 9 +- test/utils/specs.test.ts | 14 ++- test/utils/update-check.test.ts | 3 +- 15 files changed, 124 insertions(+), 188 deletions(-) diff --git a/package.json b/package.json index 992ab44..3fb7f83 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,10 @@ "build": "tsc --noEmit && tsup", "dev": "export $(grep -v '^#' .env | xargs) && (tsc --noEmit --watch --preserveWatchOutput & tsup --watch)", "cli": "node dist/cli.js", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "format": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"", + "lint": "eslint src test", + "lint:fix": "eslint src test --fix", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "test": "vitest run --exclude test/cli.test.ts", "test:watch": "vitest", "test:all": "vitest run", diff --git a/test/auth/storage.test.ts b/test/auth/storage.test.ts index a02586a..04dac8b 100644 --- a/test/auth/storage.test.ts +++ b/test/auth/storage.test.ts @@ -1,17 +1,18 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { - mkdtempSync, mkdirSync, - writeFileSync, + mkdtempSync, readFileSync, rmSync, + writeFileSync, } from 'fs'; -import { join } from 'path'; import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock os.homedir() to return a temp directory so tests don't touch real config let tempHome: string; vi.mock('os', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actual = await importOriginal(); return { ...actual, diff --git a/test/cli-core.test.ts b/test/cli-core.test.ts index 6478107..43b6ec6 100644 --- a/test/cli-core.test.ts +++ b/test/cli-core.test.ts @@ -1,12 +1,13 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Command as CommanderCommand } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + import { - isValidCommandName, - formatArgumentHelp, + addArgumentsToCommand, extractArgumentValues, + formatArgumentHelp, + isValidCommandName, validateRequiredWhen, - addArgumentsToCommand, } from '../src/cli-core.js'; -import type { Command as CommanderCommand } from 'commander'; import type { Argument } from '../src/types.js'; describe('isValidCommandName', () => { @@ -225,9 +226,7 @@ describe('validateRequiredWhen', () => { }); it('returns true when no required args', () => { - const args: Argument[] = [ - { name: 'format', description: 'Format' }, - ]; + const args: Argument[] = [{ name: 'format', description: 'Format' }]; expect(validateRequiredWhen(args, {})).toBe(true); }); @@ -334,7 +333,12 @@ describe('addArgumentsToCommand', () => { it('adds short alias', () => { const { cmd, calls } = createMockCmd(); addArgumentsToCommand(cmd as unknown as CommanderCommand, [ - { name: 'format', description: 'Format', alias: 'f', options: ['json', 'table'] }, + { + name: 'format', + description: 'Format', + alias: 'f', + options: ['json', 'table'], + }, ]); expect(calls.option[0][0]).toBe('-f, --format '); }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 213cd68..c5cc777 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,11 +1,5 @@ import { execSync } from 'child_process'; -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -786,9 +780,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { 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}` - ); + 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); @@ -797,9 +789,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { 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` - ); + const result = runCli(`cp ${localSrc} ${t3(testBucket)}/cp-ul-test.txt`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Uploaded'); @@ -813,9 +803,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { 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` - ); + 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)'); @@ -827,9 +815,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { 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` - ); + 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)'); @@ -995,18 +981,14 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should list with --prefix filter', () => { - const result = runCli( - `objects list ${testBucket} --prefix objlist-a` - ); + 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` - ); + const result = runCli(`objects list ${testBucket} --format json`); expect(result.exitCode).toBe(0); expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); }); @@ -1064,9 +1046,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should output --format json for object info', () => { - const result = runCli( - `stat ${testBucket}/stat-test.txt --format json` - ); + const result = runCli(`stat ${testBucket}/stat-test.txt --format json`); expect(result.exitCode).toBe(0); expect(() => JSON.parse(result.stdout.trim())).not.toThrow(); }); @@ -1135,9 +1115,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error on bucket-only path', () => { - const result = runCli( - `presign ${testBucket} --access-key ${accessKey}` - ); + const result = runCli(`presign ${testBucket} --access-key ${accessKey}`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Object key is required'); }); @@ -1253,17 +1231,13 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { afterAll(() => { // Disable delete protection before cleanup - runCli( - `buckets set ${setBucket} --enable-delete-protection false` - ); + 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` - ); + const result = runCli(`buckets set ${setBucket} --access public`); expect(result.exitCode).toBe(0); // Reset back runCli(`buckets set ${setBucket} --access private`); @@ -1282,15 +1256,11 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { ); expect(result.exitCode).toBe(0); // Disable for cleanup - runCli( - `buckets set ${setBucket} --enable-delete-protection false` - ); + runCli(`buckets set ${setBucket} --enable-delete-protection false`); }); it('should set --locations usa', () => { - const result = runCli( - `buckets set ${setBucket} --locations usa` - ); + const result = runCli(`buckets set ${setBucket} --locations usa`); expect(result.exitCode).toBe(0); }); @@ -1309,30 +1279,22 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { describe('buckets set-ttl', () => { it('should set TTL with --days 30', () => { - const result = runCli( - `buckets set-ttl ${setBucket} --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` - ); + 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` - ); + 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` - ); + const result = runCli(`buckets set-ttl ${setBucket} --disable`); expect(result.exitCode).toBe(0); }); @@ -1357,21 +1319,15 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error on invalid --days', () => { - const result = runCli( - `buckets set-ttl ${setBucket} --days -5` - ); + 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` - ); + 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' - ); + expect(result.stderr).toContain('--date must be a valid ISO-8601 date'); }); it('should error when no action provided', () => { @@ -1394,9 +1350,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { describe('buckets set-migration', () => { it('should disable migration', () => { - const result = runCli( - `buckets set-migration ${setBucket} --disable` - ); + const result = runCli(`buckets set-migration ${setBucket} --disable`); expect(result.exitCode).toBe(0); }); @@ -1435,16 +1389,12 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should enable with --enable', () => { - const result = runCli( - `buckets set-transition ${setBucket} --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` - ); + const result = runCli(`buckets set-transition ${setBucket} --disable`); expect(result.exitCode).toBe(0); }); @@ -1459,9 +1409,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error on --days without --storage-class', () => { - const result = runCli( - `buckets set-transition ${setBucket} --days 30` - ); + 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' @@ -1489,9 +1437,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error when no action provided', () => { - const result = runCli( - `buckets set-transition ${setBucket}` - ); + const result = runCli(`buckets set-transition ${setBucket}`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain( 'Provide --days, --date, --enable, or --disable' @@ -1523,9 +1469,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should reset', () => { - const result = runCli( - `buckets set-notifications ${setBucket} --reset` - ); + const result = runCli(`buckets set-notifications ${setBucket} --reset`); expect(result.exitCode).toBe(0); }); @@ -1584,9 +1528,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error when no options provided', () => { - const result = runCli( - `buckets set-notifications ${setBucket}` - ); + const result = runCli(`buckets set-notifications ${setBucket}`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Provide at least one option'); }); @@ -1601,9 +1543,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should reset with --reset', () => { - const result = runCli( - `buckets set-cors ${setBucket} --reset` - ); + const result = runCli(`buckets set-cors ${setBucket} --reset`); expect(result.exitCode).toBe(0); }); @@ -1625,9 +1565,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should error without --origins or --reset', () => { - const result = runCli( - `buckets set-cors ${setBucket} --methods "GET"` - ); + const result = runCli(`buckets set-cors ${setBucket} --methods "GET"`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('Provide --origins or --reset'); }); @@ -1637,9 +1575,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { `buckets set-cors ${setBucket} --origins "*" --max-age -1` ); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - '--max-age must be a positive number' - ); + expect(result.stderr).toContain('--max-age must be a positive number'); }); }); }); @@ -1647,9 +1583,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { describe('objects delete command', () => { it('should delete a single object with --yes', () => { runCli(`touch ${testBucket}/objdel-1.txt`); - const result = runCli( - `objects delete ${testBucket} objdel-1.txt --yes` - ); + const result = runCli(`objects delete ${testBucket} objdel-1.txt --yes`); expect(result.exitCode).toBe(0); }); @@ -1672,9 +1606,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { it('should fail without --yes in non-TTY', () => { runCli(`touch ${testBucket}/objdel-noforce.txt`); - const result = runCli( - `objects delete ${testBucket} objdel-noforce.txt` - ); + const result = runCli(`objects delete ${testBucket} objdel-noforce.txt`); expect(result.exitCode).toBe(1); expect(result.stderr).toContain('--yes'); // Cleanup @@ -1762,9 +1694,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { writeFileSync(tmpFile, 't3 path get test'); runCli(`objects put ${testBucket} t3path-get.txt ${tmpFile}`); - const result = runCli( - `objects get t3://${testBucket}/t3path-get.txt` - ); + const result = runCli(`objects get t3://${testBucket}/t3path-get.txt`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('t3 path get test'); }); @@ -1776,26 +1706,20 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should list with t3://bucket/prefix', () => { - const result = runCli( - `objects list t3://${testBucket}/t3path-put` - ); + const result = runCli(`objects list t3://${testBucket}/t3path-put`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('t3path-put'); }); it('should info with t3://bucket/key', () => { - const result = runCli( - `objects info t3://${testBucket}/t3path-put.txt` - ); + const result = runCli(`objects info t3://${testBucket}/t3path-put.txt`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Size'); expect(result.stdout).toContain('Content-Type'); }); it('should info with bare bucket/key', () => { - const result = runCli( - `objects info ${testBucket}/t3path-put.txt` - ); + const result = runCli(`objects info ${testBucket}/t3path-put.txt`); expect(result.exitCode).toBe(0); expect(result.stdout).toContain('Size'); }); @@ -1848,9 +1772,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should take a named snapshot with --snapshot-name', () => { - const result = runCli( - `snapshots take ${snapBucket} test-snap` - ); + const result = runCli(`snapshots take ${snapBucket} test-snap`); expect(result.exitCode).toBe(0); }); @@ -1861,9 +1783,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should list snapshots with --format json', () => { - const result = runCli( - `snapshots list ${snapBucket} --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); @@ -1897,9 +1817,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }); it('should create a fork via forks create', () => { - const result = runCli( - `forks create ${snapBucket} ${forkBucket}` - ); + const result = runCli(`forks create ${snapBucket} ${forkBucket}`); expect(result.exitCode).toBe(0); }); @@ -1916,9 +1834,7 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { }, 120_000); it('should list forks with --format json', () => { - const result = runCli( - `forks list ${snapBucket} --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()) { diff --git a/test/setup.ts b/test/setup.ts index e297485..68d2f19 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,7 +1,7 @@ -import { beforeAll, afterAll } from 'vitest'; import { execSync } from 'child_process'; import { existsSync } from 'fs'; import path from 'path'; +import { afterAll, beforeAll } from 'vitest'; const distPath = path.resolve(process.cwd(), 'dist/cli.js'); diff --git a/test/specs-completeness.test.ts b/test/specs-completeness.test.ts index 943e9cb..cbb4471 100644 --- a/test/specs-completeness.test.ts +++ b/test/specs-completeness.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { describe, expect, it } from 'vitest'; import * as YAML from 'yaml'; -import { setSpecs, loadSpecs } from '../src/utils/specs.js'; + import type { CommandSpec, Specs } from '../src/types.js'; +import { loadSpecs, setSpecs } from '../src/utils/specs.js'; interface LeafCommand { spec: CommandSpec; @@ -60,7 +61,10 @@ function collectAllCommands( 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'); +const specsYaml = readFileSync( + join(process.cwd(), 'src', 'specs.yaml'), + 'utf8' +); setSpecs(YAML.parse(specsYaml, { schema: 'core' }) as Specs); describe('specs completeness', () => { @@ -115,8 +119,8 @@ describe('specs completeness', () => { const label = path.join(' '); it(`${label}`, () => { const names = spec.arguments!.map((a) => a.name); - const aliases = spec.arguments! - .filter((a) => a.alias) + const aliases = spec + .arguments!.filter((a) => a.alias) .map((a) => a.alias as string); // No alias should match another arg's name @@ -144,8 +148,7 @@ describe('specs completeness', () => { describe('nextActions entries have command and description', () => { const withNextActions = allCommands.filter( ({ spec }) => - spec.messages && - (spec.messages as Record).nextActions + spec.messages && (spec.messages as Record).nextActions ); if (withNextActions.length === 0) { diff --git a/test/utils/errors.test.ts b/test/utils/errors.test.ts index 2fa4eac..2373f8e 100644 --- a/test/utils/errors.test.ts +++ b/test/utils/errors.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { + type ClassifiedError, classifyError, ExitCode, - type ClassifiedError, } from '../../src/utils/errors.js'; describe('classifyError', () => { @@ -23,9 +24,9 @@ describe('classifyError', () => { expect(result.exitCode).toBe(ExitCode.AuthFailure); expect(result.category).toBe('auth'); expect(result.nextActions.length).toBeGreaterThan(0); - expect(result.nextActions.some((a) => a.command.includes('login'))).toBe( - true - ); + expect( + result.nextActions.some((a) => a.command.includes('login')) + ).toBe(true); }); } }); @@ -109,9 +110,7 @@ describe('classifyError', () => { expect(result.category).toBe('network'); expect(result.nextActions.length).toBeGreaterThan(0); expect( - result.nextActions.some((a) => - a.command.includes('credentials test') - ) + result.nextActions.some((a) => a.command.includes('credentials test')) ).toBe(true); }); } diff --git a/test/utils/exit.test.ts b/test/utils/exit.test.ts index 0a1db3a..18b39d4 100644 --- a/test/utils/exit.test.ts +++ b/test/utils/exit.test.ts @@ -1,14 +1,15 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as YAML from 'yaml'; -import { setSpecs } from '../../src/utils/specs.js'; + +import { ExitCode } from '../../src/utils/errors.js'; import { exitWithError, getSuccessNextActions, printNextActions, } from '../../src/utils/exit.js'; -import { ExitCode } from '../../src/utils/errors.js'; +import { setSpecs } from '../../src/utils/specs.js'; // Pre-populate specs cache const specsYaml = readFileSync( diff --git a/test/utils/format.test.ts b/test/utils/format.test.ts index 7e2938e..fe290b9 100644 --- a/test/utils/format.test.ts +++ b/test/utils/format.test.ts @@ -1,11 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { - formatSize, formatJson, + formatOutput, + formatSize, + formatTable, formatXml, formatXmlObject, - formatTable, - formatOutput, type TableColumn, } from '../../src/utils/format.js'; @@ -133,9 +134,7 @@ describe('formatTable', () => { }); describe('formatOutput', () => { - const columns: TableColumn[] = [ - { key: 'name', header: 'Name' }, - ]; + const columns: TableColumn[] = [{ key: 'name', header: 'Name' }]; const items = [{ name: 'test' }]; it("'json' → JSON output", () => { diff --git a/test/utils/locations.test.ts b/test/utils/locations.test.ts index 5da0081..8e15a3d 100644 --- a/test/utils/locations.test.ts +++ b/test/utils/locations.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { parseLocations } from '../../src/utils/locations.js'; describe('parseLocations', () => { diff --git a/test/utils/messages.test.ts b/test/utils/messages.test.ts index 5827288..0daa77f 100644 --- a/test/utils/messages.test.ts +++ b/test/utils/messages.test.ts @@ -1,23 +1,21 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as YAML from 'yaml'; -import { setSpecs } from '../../src/utils/specs.js'; + import { - printFailure, + printAlreadyDone, printDeprecated, - printStart, - printSuccess, printEmpty, - printAlreadyDone, + printFailure, printHint, + printStart, + printSuccess, } from '../../src/utils/messages.js'; +import { setSpecs } from '../../src/utils/specs.js'; // Save original descriptor so we can restore it -const originalIsTTY = Object.getOwnPropertyDescriptor( - process.stdout, - 'isTTY' -); +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); function setTTY(value: boolean) { Object.defineProperty(process.stdout, 'isTTY', { @@ -36,7 +34,10 @@ function restoreTTY() { } // Pre-populate specs cache from source YAML so we don't need dist/ -const specsYaml = readFileSync(join(process.cwd(), 'src', 'specs.yaml'), 'utf8'); +const specsYaml = readFileSync( + join(process.cwd(), 'src', 'specs.yaml'), + 'utf8' +); setSpecs(YAML.parse(specsYaml, { schema: 'core' })); describe('messages', () => { diff --git a/test/utils/options.test.ts b/test/utils/options.test.ts index 4385cfd..d4282a5 100644 --- a/test/utils/options.test.ts +++ b/test/utils/options.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { getOption } from '../../src/utils/options.js'; describe('getOption', () => { diff --git a/test/utils/path.test.ts b/test/utils/path.test.ts index d8ac177..941ef92 100644 --- a/test/utils/path.test.ts +++ b/test/utils/path.test.ts @@ -1,12 +1,13 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { + globToRegex, + isRemotePath, + parseAnyPath, parsePath, parsePaths, - isRemotePath, parseRemotePath, - parseAnyPath, resolveObjectArgs, - globToRegex, wildcardPrefix, } from '../../src/utils/path.js'; diff --git a/test/utils/specs.test.ts b/test/utils/specs.test.ts index 8b964a5..b88e7e8 100644 --- a/test/utils/specs.test.ts +++ b/test/utils/specs.test.ts @@ -1,11 +1,19 @@ -import { describe, it, expect } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; +import { describe, expect, it } from 'vitest'; import * as YAML from 'yaml'; -import { setSpecs, getCommandSpec, getArgumentSpec } from '../../src/utils/specs.js'; + +import { + getArgumentSpec, + getCommandSpec, + setSpecs, +} 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'); +const specsYaml = readFileSync( + join(process.cwd(), 'src', 'specs.yaml'), + 'utf8' +); setSpecs(YAML.parse(specsYaml, { schema: 'core' })); describe('getCommandSpec', () => { diff --git a/test/utils/update-check.test.ts b/test/utils/update-check.test.ts index 41158f6..bd6c939 100644 --- a/test/utils/update-check.test.ts +++ b/test/utils/update-check.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { isNewerVersion } from '../../src/utils/update-check.js'; describe('isNewerVersion', () => { From 15829f9f33bf5f975ded2136e2e93611c92a77c5 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 31 Mar 2026 16:45:36 +0200 Subject: [PATCH 2/8] fix: clear stale auth state on re-login Allow OAuth re-login (remove isAuthenticated block) and clear the other method's temporary state when switching login methods to prevent stale sessions from persisting. Co-Authored-By: Claude Opus 4.6 --- src/auth/storage.ts | 20 +++++++++ src/lib/login/credentials.ts | 4 ++ src/lib/login/oauth.ts | 16 +++---- test/auth/storage.test.ts | 84 ++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/auth/storage.ts b/src/auth/storage.ts index 8284d80..7bd1ca6 100644 --- a/src/auth/storage.ts +++ b/src/auth/storage.ts @@ -494,6 +494,26 @@ export async function storeCredentialOrganization( } } +/** + * Clear temporary credentials (from login command) + */ +export async function clearTemporaryCredentials(): Promise { + const config = readConfig(); + if (config.credentials) { + delete config.credentials.temporary; + } + await writeConfig(config); +} + +/** + * Clear all OAuth data (tokens, organizations, selectedOrganization) + */ +export async function clearOAuthData(): Promise { + const config = readConfig(); + delete config.oauth; + await writeConfig(config); +} + /** * Clear all stored data (except saved credentials from configure) */ diff --git a/src/lib/login/credentials.ts b/src/lib/login/credentials.ts index 38ea31f..b1ca568 100644 --- a/src/lib/login/credentials.ts +++ b/src/lib/login/credentials.ts @@ -2,6 +2,7 @@ import enquirer from 'enquirer'; const { prompt } = enquirer; import { getTigrisConfig } from '@auth/provider.js'; import { + clearOAuthData, getStoredCredentials, storeCredentialOrganization, storeLoginMethod, @@ -91,6 +92,9 @@ export default async function credentials(options: Record) { await storeLoginMethod('credentials'); + // Clear stale OAuth session from a previous login method + await clearOAuthData(); + // Fetch and store organizationId from whoami (best-effort) try { const tigrisConfig = getTigrisConfig(); diff --git a/src/lib/login/oauth.ts b/src/lib/login/oauth.ts index acd7719..bac9099 100644 --- a/src/lib/login/oauth.ts +++ b/src/lib/login/oauth.ts @@ -1,9 +1,11 @@ import { getAuthClient } from '@auth/client.js'; -import { storeSelectedOrganization } from '@auth/storage.js'; +import { + clearTemporaryCredentials, + storeSelectedOrganization, +} from '@auth/storage.js'; import { exitWithError, printNextActions } from '@utils/exit.js'; import { msg, - printAlreadyDone, printFailure, printHint, printStart, @@ -22,13 +24,6 @@ export async function oauth( try { const authClient = getAuthClient(); - // Check if already authenticated - const isAuth = await authClient.isAuthenticated(); - if (isAuth) { - printAlreadyDone(context); - return; - } - // Initiate login flow with callbacks for output await authClient.login({ onDeviceCode: (code, uri) => { @@ -38,6 +33,9 @@ export async function oauth( onWaiting: () => console.log('\nWaiting for authentication...'), }); + // Clear stale credentials session from a previous login method + await clearTemporaryCredentials(); + // After successful login, automatically select the first organization const orgs = await authClient.getOrganizations(); if (orgs.length > 0) { diff --git a/test/auth/storage.test.ts b/test/auth/storage.test.ts index 04dac8b..22b69cd 100644 --- a/test/auth/storage.test.ts +++ b/test/auth/storage.test.ts @@ -397,6 +397,90 @@ describe('auth/storage', () => { }); }); + // --------------------------------------------------------------------------- + // clearTemporaryCredentials + // --------------------------------------------------------------------------- + describe('clearTemporaryCredentials', () => { + it('removes temporary credentials while preserving saved', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'credentials', + credentials: { + saved: { + accessKeyId: 'SAVED', + secretAccessKey: 'S', + endpoint: 'https://s.com', + }, + temporary: { + accessKeyId: 'TEMP', + secretAccessKey: 'T', + endpoint: 'https://t.com', + }, + }, + }); + + const storage = await import('../../src/auth/storage.js'); + await storage.clearTemporaryCredentials(); + + const raw = readRawConfig() as Record; + const creds = raw['credentials'] as Record; + expect(creds['saved']).toBeDefined(); + expect(creds['temporary']).toBeUndefined(); + }); + + it('does nothing when no credentials exist', async () => { + writeRawConfig({ version: 2 }); + + const storage = await import('../../src/auth/storage.js'); + await storage.clearTemporaryCredentials(); + + const raw = readRawConfig() as Record; + expect(raw['version']).toBe(2); + }); + }); + + // --------------------------------------------------------------------------- + // clearOAuthData + // --------------------------------------------------------------------------- + describe('clearOAuthData', () => { + it('removes all OAuth data while preserving credentials', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'oauth', + oauth: { + tokens: { accessToken: 't', expiresAt: 1 }, + organizations: [{ id: 'org-1', name: 'Org' }], + selectedOrganization: 'org-1', + }, + credentials: { + saved: { + accessKeyId: 'SAVED', + secretAccessKey: 'S', + endpoint: 'https://s.com', + }, + }, + }); + + const storage = await import('../../src/auth/storage.js'); + await storage.clearOAuthData(); + + const raw = readRawConfig() as Record; + expect(raw['oauth']).toBeUndefined(); + expect(raw['credentials']).toBeDefined(); + expect(raw['activeMethod']).toBe('oauth'); + }); + + it('does nothing when no OAuth data exists', async () => { + writeRawConfig({ version: 2 }); + + const storage = await import('../../src/auth/storage.js'); + await storage.clearOAuthData(); + + const raw = readRawConfig() as Record; + expect(raw['version']).toBe(2); + }); + }); + // --------------------------------------------------------------------------- // storeLoginMethod / getLoginMethod // --------------------------------------------------------------------------- From 7973731a79f97322d9ced3f3e17a9be496fa3954 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Wed, 1 Apr 2026 10:48:10 +0200 Subject: [PATCH 3/8] feat: make whoami reflect auth resolution priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add resolveAuthMethod() as a single source of truth for the auth priority chain (AWS profile → OAuth → credentials login → env vars → configured). Refactor getStorageConfig() to use it, and rewrite whoami to display the active auth method with IAM identity for credential-based methods. Move getEnvCredentials, getEnvCredentialSource, and getCredentials from storage.ts to provider.ts where they belong as auth resolution logic. Add comprehensive tests for resolveAuthMethod() covering all 5 auth methods, priority ordering, and edge cases (19 tests). Co-Authored-By: Claude Opus 4.6 --- src/auth/iam.ts | 4 +- src/auth/provider.ts | 302 +++++++++++++++++++++-------- src/auth/storage.ts | 58 ------ src/lib/whoami.ts | 181 +++++++++++++----- test/auth/provider.test.ts | 378 +++++++++++++++++++++++++++++++++++++ test/auth/storage.test.ts | 12 +- 6 files changed, 738 insertions(+), 197 deletions(-) create mode 100644 test/auth/provider.test.ts diff --git a/src/auth/iam.ts b/src/auth/iam.ts index 2e76644..d3d3662 100644 --- a/src/auth/iam.ts +++ b/src/auth/iam.ts @@ -8,8 +8,8 @@ import type { MessageContext } from '@utils/messages.js'; import { getAuthClient } from './client.js'; import { isFlyUser } from './fly.js'; -import { getLoginMethod, getTigrisConfig } from './provider.js'; -import { getCredentials, getSelectedOrganization } from './storage.js'; +import { getCredentials, getLoginMethod, getTigrisConfig } from './provider.js'; +import { getSelectedOrganization } from './storage.js'; /** * Check if current org is Fly.io. Prints message and returns true if so. diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 4c753fc..00948d7 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -12,9 +12,8 @@ import { } from '../constants.js'; import { getAuth0Config, getAuthClient } from './client.js'; import { + type CredentialsConfig, getAwsProfileConfig, - getCredentials, - getEnvCredentials, getLoginMethod as getStoredLoginMethod, getSelectedOrganization, getStoredCredentials, @@ -48,6 +47,75 @@ export function getTigrisConfig(): TigrisConfig { const tigrisConfig = getTigrisConfig(); const auth0Config = getAuth0Config(); +// --------------------------------------------------------------------------- +// Environment credential helpers +// --------------------------------------------------------------------------- + +/** + * Return which env var family is providing credentials ('tigris' | 'aws' | null) + */ +export function getEnvCredentialSource(): 'tigris' | 'aws' | null { + if ( + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID || + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY + ) { + return 'tigris'; + } + if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SECRET_ACCESS_KEY) { + return 'aws'; + } + return null; +} + +/** + * Get credentials from environment variables. + * If any TIGRIS_ var is set, use TIGRIS_ vars exclusively. + * Otherwise, fall back to AWS_ vars. + */ +export function getEnvCredentials(): CredentialsConfig | null { + // Check TIGRIS_ vars first + if ( + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID || + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY + ) { + const accessKeyId = process.env.TIGRIS_STORAGE_ACCESS_KEY_ID; + const secretAccessKey = process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY; + + if (!accessKeyId || !secretAccessKey) { + return null; + } + + const endpoint = + process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT; + + return { accessKeyId, secretAccessKey, endpoint }; + } + + // Fall back to AWS_ vars + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + + if (!accessKeyId || !secretAccessKey) { + return null; + } + + const endpoint = process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT; + + return { accessKeyId, secretAccessKey, endpoint }; +} + +/** + * Get non-login credentials in priority order: + * 1. Environment variables (TIGRIS_ACCESS_KEY / AWS_ACCESS_KEY_ID) + * 2. Stored credentials (temporary from login, then saved from configure) + * + * Note: AWS profile and login method checks are handled separately. + * Full resolution order: AWS_PROFILE → login → env vars → configured + */ +export function getCredentials(): CredentialsConfig | null { + return getEnvCredentials() || getStoredCredentials() || null; +} + /** * Trigger interactive login when not authenticated and stdin is a TTY. * Returns true if login was triggered, false if non-interactive or already attempted. @@ -72,6 +140,92 @@ export async function getLoginMethod(): Promise< return getStoredLoginMethod(); } +// --------------------------------------------------------------------------- +// Auth method resolution — single source of truth for auth priority +// --------------------------------------------------------------------------- + +export type AuthMethod = + | { + type: 'aws-profile'; + profile: string; + accessKeyId: string; + secretAccessKey: string; + } + | { type: 'oauth' } + | { type: 'credentials'; accessKeyId: string; secretAccessKey: string } + | { + type: 'environment'; + accessKeyId: string; + secretAccessKey: string; + source: 'tigris' | 'aws'; + } + | { type: 'configured'; accessKeyId: string; secretAccessKey: string } + | { type: 'none' }; + +/** + * Resolve which auth method is active, following the same priority as getStorageConfig(). + * 1. AWS Profile 2. OAuth login 3. Credentials login 4. Env vars 5. Configured + */ +export async function resolveAuthMethod(): Promise { + // 1. AWS profile + if (hasAwsProfile()) { + const profile = process.env.AWS_PROFILE || 'default'; + const resolved = await fromIni({ profile })(); + return { + type: 'aws-profile', + profile, + accessKeyId: resolved.accessKeyId, + secretAccessKey: resolved.secretAccessKey, + }; + } + + // 2–3. Login (oauth or credentials) + const loginMethod = getStoredLoginMethod(); + + if (loginMethod === 'oauth') { + return { type: 'oauth' }; + } + + if (loginMethod === 'credentials') { + const stored = getStoredCredentials(); + if (stored) { + return { + type: 'credentials', + accessKeyId: stored.accessKeyId, + secretAccessKey: stored.secretAccessKey, + }; + } + } + + // 4. Env vars + const envCreds = getEnvCredentials(); + if (envCreds) { + const source = getEnvCredentialSource(); + return { + type: 'environment', + accessKeyId: envCreds.accessKeyId, + secretAccessKey: envCreds.secretAccessKey, + source: source ?? 'aws', + }; + } + + // 5. Configured credentials + const configured = getStoredCredentials(); + if (configured) { + return { + type: 'configured', + accessKeyId: configured.accessKeyId, + secretAccessKey: configured.secretAccessKey, + }; + } + + return { type: 'none' }; +} + +// --------------------------------------------------------------------------- +// Storage config +// --------------------------------------------------------------------------- + export type TigrisStorageConfig = { bucket?: string; accessKeyId?: string; @@ -92,99 +246,89 @@ export type TigrisStorageConfig = { export async function getStorageConfig(options?: { withCredentialProvider?: boolean; }): Promise { - // 1. AWS profile (only if AWS_PROFILE is set) - if (hasAwsProfile()) { - const profile = process.env.AWS_PROFILE || 'default'; - const profileConfig = await getAwsProfileConfig(profile); - const resolved = await fromIni({ profile })(); - return { - accessKeyId: resolved.accessKeyId, - secretAccessKey: resolved.secretAccessKey, - endpoint: - profileConfig.endpoint || - tigrisConfig.endpoint || - DEFAULT_STORAGE_ENDPOINT, - iamEndpoint: profileConfig.iamEndpoint || tigrisConfig.iamEndpoint, - }; - } + const method = await resolveAuthMethod(); - // 2. Login (oauth or credentials) - const loginMethod = await getLoginMethod(); + switch (method.type) { + case 'aws-profile': { + const profileConfig = await getAwsProfileConfig(method.profile); + return { + accessKeyId: method.accessKeyId, + secretAccessKey: method.secretAccessKey, + endpoint: + profileConfig.endpoint || + tigrisConfig.endpoint || + DEFAULT_STORAGE_ENDPOINT, + iamEndpoint: profileConfig.iamEndpoint || tigrisConfig.iamEndpoint, + }; + } - if (loginMethod === 'oauth') { - const authClient = getAuthClient(); - const selectedOrg = getSelectedOrganization(); + case 'oauth': { + const authClient = getAuthClient(); + const selectedOrg = getSelectedOrganization(); - if (!selectedOrg) { - throw new Error( - 'No organization selected. Please run "tigris orgs select" first.' - ); - } + if (!selectedOrg) { + throw new Error( + 'No organization selected. Please run "tigris orgs select" first.' + ); + } - return { - sessionToken: await authClient.getAccessToken(), - accessKeyId: '', - secretAccessKey: '', - // Only include credentialProvider for long-running operations (uploads) - // that need token refresh. Short-lived operations (ls, rm, head) use - // the static sessionToken above and benefit from S3Client caching. - ...(options?.withCredentialProvider && { - credentialProvider: async () => ({ - accessKeyId: '', - secretAccessKey: '', - sessionToken: await authClient.getAccessToken(), - expiration: new Date(Date.now() + 10 * 60 * 1000), + return { + sessionToken: await authClient.getAccessToken(), + accessKeyId: '', + secretAccessKey: '', + // Only include credentialProvider for long-running operations (uploads) + // that need token refresh. Short-lived operations (ls, rm, head) use + // the static sessionToken above and benefit from S3Client caching. + ...(options?.withCredentialProvider && { + credentialProvider: async () => ({ + accessKeyId: '', + secretAccessKey: '', + sessionToken: await authClient.getAccessToken(), + expiration: new Date(Date.now() + 10 * 60 * 1000), + }), }), - }), - endpoint: tigrisConfig.endpoint, - organizationId: selectedOrg, - iamEndpoint: tigrisConfig.iamEndpoint, - authDomain: auth0Config.domain, - }; - } + endpoint: tigrisConfig.endpoint, + organizationId: selectedOrg, + iamEndpoint: tigrisConfig.iamEndpoint, + authDomain: auth0Config.domain, + }; + } - if (loginMethod === 'credentials') { - const loginCredentials = getStoredCredentials(); - if (loginCredentials) { + case 'credentials': { const selectedOrg = getSelectedOrganization(); return { - accessKeyId: loginCredentials.accessKeyId, - secretAccessKey: loginCredentials.secretAccessKey, - endpoint: loginCredentials.endpoint, + accessKeyId: method.accessKeyId, + secretAccessKey: method.secretAccessKey, + endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT, organizationId: selectedOrg ?? undefined, iamEndpoint: tigrisConfig.iamEndpoint, }; } - } - // 3. Env vars - const envCredentials = getEnvCredentials(); - if (envCredentials) { - return { - accessKeyId: envCredentials.accessKeyId, - secretAccessKey: envCredentials.secretAccessKey, - endpoint: envCredentials.endpoint, - }; - } - - // 4. Configured credentials - const credentials = getStoredCredentials(); + case 'environment': + return { + accessKeyId: method.accessKeyId, + secretAccessKey: method.secretAccessKey, + endpoint: getEnvCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT, + }; - if (credentials) { - return { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - endpoint: credentials.endpoint, - }; - } + case 'configured': + return { + accessKeyId: method.accessKeyId, + secretAccessKey: method.secretAccessKey, + endpoint: getStoredCredentials()?.endpoint || DEFAULT_STORAGE_ENDPOINT, + }; - // No valid auth method found — try auto-login in interactive terminals - if (await triggerAutoLogin()) { - return getStorageConfig(options); + case 'none': { + // No valid auth method found — try auto-login in interactive terminals + if (await triggerAutoLogin()) { + return getStorageConfig(options); + } + throw new Error( + 'Not authenticated. Please run "tigris login" or "tigris configure" first.' + ); + } } - throw new Error( - 'Not authenticated. Please run "tigris login" or "tigris configure" first.' - ); } /** diff --git a/src/auth/storage.ts b/src/auth/storage.ts index 7bd1ca6..9aea7fd 100644 --- a/src/auth/storage.ts +++ b/src/auth/storage.ts @@ -14,8 +14,6 @@ import { import { homedir, platform } from 'os'; import { join } from 'path'; -import { DEFAULT_STORAGE_ENDPOINT } from '../constants.js'; - export interface TokenSet { accessToken: string; refreshToken?: string; @@ -298,43 +296,6 @@ export function getSelectedOrganization(): string | null { // Credential accessors // --------------------------------------------------------------------------- -/** - * Get credentials from environment variables. - * If any TIGRIS_ var is set, use TIGRIS_ vars exclusively. - * Otherwise, fall back to AWS_ vars. - */ -export function getEnvCredentials(): CredentialsConfig | null { - // Check TIGRIS_ vars first - if ( - process.env.TIGRIS_STORAGE_ACCESS_KEY_ID || - process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY - ) { - const accessKeyId = process.env.TIGRIS_STORAGE_ACCESS_KEY_ID; - const secretAccessKey = process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY; - - if (!accessKeyId || !secretAccessKey) { - return null; - } - - const endpoint = - process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT; - - return { accessKeyId, secretAccessKey, endpoint }; - } - - // Fall back to AWS_ vars - const accessKeyId = process.env.AWS_ACCESS_KEY_ID; - const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - - if (!accessKeyId || !secretAccessKey) { - return null; - } - - const endpoint = process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT; - - return { accessKeyId, secretAccessKey, endpoint }; -} - /** * Check if user explicitly requested an AWS profile via AWS_PROFILE env var */ @@ -380,25 +341,6 @@ export async function getAwsProfileConfig( } } -/** - * Get non-login credentials in priority order: - * 1. Environment variables (TIGRIS_ACCESS_KEY / AWS_ACCESS_KEY_ID) - * 2. Temporary credentials (from 'tigris login') - * 3. Saved credentials (from 'tigris configure') - * - * Note: AWS profile and login method checks are handled in provider.ts - * Full resolution order (in provider): AWS_PROFILE → login → env vars → configured - */ -export function getCredentials(): CredentialsConfig | null { - const config = readConfig(); - return ( - getEnvCredentials() || - toCredentialsConfig(config.credentials?.temporary) || - toCredentialsConfig(config.credentials?.saved) || - null - ); -} - /** * Get stored credentials only (no env vars): * 1. Temporary credentials (from 'tigris login') diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts index 4f6aa7f..075d628 100644 --- a/src/lib/whoami.ts +++ b/src/lib/whoami.ts @@ -1,71 +1,145 @@ import { getAuthClient } from '@auth/client.js'; -import { getStorageConfig } from '@auth/provider.js'; -import { - getCredentials, - getLoginMethod, - getSelectedOrganization, -} from '@auth/storage.js'; -import { listOrganizations } from '@tigrisdata/iam'; +import { getTigrisConfig, resolveAuthMethod } from '@auth/provider.js'; +import { getSelectedOrganization } from '@auth/storage.js'; +import { listOrganizations, whoami as iamWhoami } from '@tigrisdata/iam'; import { failWithError } from '@utils/exit.js'; import { msg, printAlreadyDone } from '@utils/messages.js'; import { getFormat } from '@utils/options.js'; const context = msg('whoami'); +/** Auth method label displayed to the user */ +const AUTH_LABELS: Record = { + 'aws-profile': 'AWS Profile', + oauth: 'OAuth', + credentials: 'Access Key Credentials', + environment: 'Environment Variables', + configured: 'Configured Credentials', +}; + +/** + * Call IAM whoami with the given key pair (best-effort). + * Returns userId + organizationId on success, undefined fields on failure. + */ +async function fetchIamIdentity(accessKeyId: string, secretAccessKey: string) { + try { + const tigrisConfig = getTigrisConfig(); + const { data } = await iamWhoami({ + config: { + accessKeyId, + secretAccessKey, + iamEndpoint: tigrisConfig.iamEndpoint, + }, + }); + return { userId: data?.userId, organizationId: data?.organizationId }; + } catch { + return { userId: undefined, organizationId: undefined }; + } +} + export default async function whoami( options: Record = {} ): Promise { try { const format = getFormat(options); - const loginMethod = getLoginMethod(); - const credentials = getCredentials(); + const method = await resolveAuthMethod(); + + if (method.type === 'none') { + printAlreadyDone(context); + return; + } - // Get user info based on login method let email: string | undefined; let userId: string | undefined; + let organizationId: string | undefined; + let organizations: { id: string; name: string }[] | undefined; + let selectedOrg: string | null | undefined; - if (loginMethod === 'oauth') { - const authClient = getAuthClient(); - // Verify OAuth tokens actually exist (handles case where tokens were cleared but loginMethod wasn't) - const isAuthenticated = await authClient.isAuthenticated(); - if (!isAuthenticated) { - printAlreadyDone(context); - return; + const lines: string[] = ['']; + const label = AUTH_LABELS[method.type] ?? method.type; + + switch (method.type) { + case 'aws-profile': { + lines.push(`Auth method: ${label} (${method.profile})`); + const iam = await fetchIamIdentity( + method.accessKeyId, + method.secretAccessKey + ); + userId = iam.userId; + organizationId = iam.organizationId; + break; } - const claims = await authClient.getIdTokenClaims(); - email = claims.email; - userId = claims.sub; - } else if (credentials) { - // Using access key credentials - email = undefined; - userId = credentials.accessKeyId; - } else { - // Not authenticated - printAlreadyDone(context); - return; - } - const lines: string[] = []; - lines.push(''); - lines.push('User Information:'); - lines.push(` Email: ${email || 'N/A'}`); - lines.push(` User ID: ${userId || 'N/A'}`); + case 'oauth': { + lines.push(`Auth method: ${label}`); + const authClient = getAuthClient(); + const isAuthenticated = await authClient.isAuthenticated(); + if (!isAuthenticated) { + printAlreadyDone(context); + return; + } + const claims = await authClient.getIdTokenClaims(); + email = claims.email; + userId = claims.sub; - // Only fetch organizations for OAuth users (credentials don't have session tokens) - let organizations: { id: string; name: string }[] = []; - let selectedOrg: string | null | undefined; + // Fetch organizations + const config = await ( + await import('@auth/provider.js') + ).getStorageConfig(); + selectedOrg = getSelectedOrganization(); + const { data, error } = await listOrganizations({ config }); + if (error) { + failWithError(context, error); + } + organizations = data?.organizations ?? []; + break; + } - if (loginMethod === 'oauth') { - const config = await getStorageConfig(); - selectedOrg = getSelectedOrganization(); - const { data, error } = await listOrganizations({ config }); + case 'credentials': { + lines.push(`Auth method: ${label}`); + const iam = await fetchIamIdentity( + method.accessKeyId, + method.secretAccessKey + ); + userId = iam.userId; + organizationId = iam.organizationId; + break; + } - if (error) { - failWithError(context, error); + case 'environment': { + const envLabel = + method.source === 'tigris' ? 'TIGRIS_STORAGE_*' : 'AWS_*'; + lines.push(`Auth method: ${label} (${envLabel})`); + const iam = await fetchIamIdentity( + method.accessKeyId, + method.secretAccessKey + ); + userId = iam.userId; + organizationId = iam.organizationId; + break; } - organizations = data?.organizations ?? []; + case 'configured': { + lines.push(`Auth method: ${label}`); + const iam = await fetchIamIdentity( + method.accessKeyId, + method.secretAccessKey + ); + userId = iam.userId; + organizationId = iam.organizationId; + break; + } + } + // User info + lines.push(''); + lines.push('User Information:'); + lines.push(` Email: ${email || 'N/A'}`); + lines.push(` User ID: ${userId || 'N/A'}`); + + // Organization display + if (organizations) { + // OAuth path — list all orgs if (organizations.length > 0) { lines.push(''); lines.push(`Organizations (${organizations.length}):`); @@ -86,17 +160,17 @@ export default async function whoami( lines.push(''); lines.push('Organizations: None'); } - } else { - lines.push(''); - lines.push('Login method: Access Key Credentials'); - lines.push( - ' (Organization listing requires OAuth login: tigris login)' - ); + } else if (organizationId) { + lines.push(` Organization: ${organizationId}`); } if (format === 'json') { - const result: Record = { email, userId, loginMethod }; - if (loginMethod === 'oauth') { + const result: Record = { + authMethod: method.type, + email, + userId, + }; + if (organizations) { result.organizations = organizations.map((org) => ({ id: org.id, name: org.name, @@ -106,6 +180,9 @@ export default async function whoami( if (selected) result.activeOrganization = selected.name; } } + if (organizationId) { + result.organizationId = organizationId; + } console.log(JSON.stringify(result)); return; } diff --git a/test/auth/provider.test.ts b/test/auth/provider.test.ts new file mode 100644 index 0000000..61711ea --- /dev/null +++ b/test/auth/provider.test.ts @@ -0,0 +1,378 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock os.homedir() to return a temp directory so tests don't touch real config +let tempHome: string; +vi.mock('os', async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + homedir: () => tempHome, + }; +}); + +// Mock fromIni — avoid real AWS credential resolution +vi.mock('@aws-sdk/credential-providers', () => ({ + fromIni: + ({ profile }: { profile: string }) => + async () => ({ + accessKeyId: `${profile}-access-key`, + secretAccessKey: `${profile}-secret-key`, + }), +})); + +// Mock auth client — provider.ts calls getAuth0Config() at module level +vi.mock('@auth/client.js', () => ({ + getAuth0Config: () => ({ + domain: 'test.auth0.com', + clientId: 'test-client-id', + audience: 'test-audience', + }), + getAuthClient: () => ({}), +})); + +function writeRawConfig(data: unknown): void { + mkdirSync(join(tempHome, '.tigris'), { recursive: true }); + writeFileSync( + join(tempHome, '.tigris', 'config.json'), + JSON.stringify(data, null, 2) + ); +} + +function createAwsFiles(): void { + const awsDir = join(tempHome, '.aws'); + mkdirSync(awsDir, { recursive: true }); + writeFileSync(join(awsDir, 'credentials'), '[default]\n'); +} + +// Env vars that affect auth resolution +const ENV_KEYS = [ + 'AWS_PROFILE', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_ENDPOINT_URL_S3', + 'TIGRIS_STORAGE_ACCESS_KEY_ID', + 'TIGRIS_STORAGE_SECRET_ACCESS_KEY', + 'TIGRIS_STORAGE_ENDPOINT', +]; + +describe('resolveAuthMethod', () => { + let savedEnv: Record; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'tigris-test-')); + vi.resetModules(); + // Save and clear env vars that interfere with auth resolution + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); + // Restore env vars + for (const key of ENV_KEYS) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + // ------------------------------------------------------------------------- + // Individual method detection + // ------------------------------------------------------------------------- + + it('returns none when nothing is configured', async () => { + writeRawConfig({ version: 2 }); + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('none'); + }); + + it('returns aws-profile when AWS_PROFILE is set and .aws files exist', async () => { + writeRawConfig({ version: 2 }); + createAwsFiles(); + process.env.AWS_PROFILE = 'my-profile'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + expect(method).toEqual({ + type: 'aws-profile', + profile: 'my-profile', + accessKeyId: 'my-profile-access-key', + secretAccessKey: 'my-profile-secret-key', + }); + }); + + it('returns oauth when activeMethod is oauth', async () => { + writeRawConfig({ version: 2, activeMethod: 'oauth' }); + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method).toEqual({ type: 'oauth' }); + }); + + it('returns credentials when activeMethod is credentials with stored creds', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'credentials', + credentials: { + temporary: { + accessKeyId: 'TEMP-AK', + secretAccessKey: 'TEMP-SK', + endpoint: 'https://t.com', + }, + }, + }); + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + expect(method).toEqual({ + type: 'credentials', + accessKeyId: 'TEMP-AK', + secretAccessKey: 'TEMP-SK', + }); + }); + + it('returns environment with tigris source for TIGRIS_ env vars', async () => { + writeRawConfig({ version: 2 }); + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + expect(method).toEqual({ + type: 'environment', + accessKeyId: 'TIG-AK', + secretAccessKey: 'TIG-SK', + source: 'tigris', + }); + }); + + it('returns environment with aws source for AWS_ env vars', async () => { + writeRawConfig({ version: 2 }); + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + expect(method).toEqual({ + type: 'environment', + accessKeyId: 'AWS-AK', + secretAccessKey: 'AWS-SK', + source: 'aws', + }); + }); + + it('returns configured when only saved credentials exist (no activeMethod)', async () => { + writeRawConfig({ + version: 2, + credentials: { + saved: { + accessKeyId: 'SAVED-AK', + secretAccessKey: 'SAVED-SK', + endpoint: 'https://s.com', + }, + }, + }); + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + expect(method).toEqual({ + type: 'configured', + accessKeyId: 'SAVED-AK', + secretAccessKey: 'SAVED-SK', + }); + }); + + // ------------------------------------------------------------------------- + // Priority ordering + // ------------------------------------------------------------------------- + + it('aws-profile takes priority over oauth login', async () => { + writeRawConfig({ version: 2, activeMethod: 'oauth' }); + createAwsFiles(); + process.env.AWS_PROFILE = 'prof'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('aws-profile'); + }); + + it('aws-profile takes priority over env vars', async () => { + writeRawConfig({ version: 2 }); + createAwsFiles(); + process.env.AWS_PROFILE = 'prof'; + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('aws-profile'); + }); + + it('oauth takes priority over credentials login', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'oauth', + credentials: { + temporary: { + accessKeyId: 'CRED-AK', + secretAccessKey: 'CRED-SK', + endpoint: 'https://c.com', + }, + }, + }); + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('oauth'); + }); + + it('oauth takes priority over env vars', async () => { + writeRawConfig({ version: 2, activeMethod: 'oauth' }); + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('oauth'); + }); + + it('credentials login takes priority over env vars', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'credentials', + credentials: { + temporary: { + accessKeyId: 'CRED-AK', + secretAccessKey: 'CRED-SK', + endpoint: 'https://c.com', + }, + }, + }); + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('credentials'); + }); + + it('env vars take priority over configured credentials', async () => { + writeRawConfig({ + version: 2, + credentials: { + saved: { + accessKeyId: 'SAVED-AK', + secretAccessKey: 'SAVED-SK', + endpoint: 'https://s.com', + }, + }, + }); + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('environment'); + }); + + // ------------------------------------------------------------------------- + // Edge cases / fallthrough + // ------------------------------------------------------------------------- + + it('credentials login with no stored creds falls through to env vars', async () => { + // activeMethod is credentials but no credential slots exist + writeRawConfig({ version: 2, activeMethod: 'credentials' }); + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('environment'); + }); + + it('credentials login with no stored creds falls through to configured', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'credentials', + credentials: { + // Only saved (from configure), no temporary (from login) + saved: { + accessKeyId: 'CFG-AK', + secretAccessKey: 'CFG-SK', + endpoint: 'https://cfg.com', + }, + }, + }); + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + // getStoredCredentials returns saved, so credentials login picks it up + expect(method.type).toBe('credentials'); + if (method.type === 'credentials') { + expect(method.accessKeyId).toBe('CFG-AK'); + } + }); + + it('ignores AWS_PROFILE when .aws directory has no files', async () => { + writeRawConfig({ version: 2, activeMethod: 'oauth' }); + // Set AWS_PROFILE but don't create .aws/ files + process.env.AWS_PROFILE = 'orphan-profile'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('oauth'); + }); + + it('ignores incomplete TIGRIS_ env vars (missing secret)', async () => { + writeRawConfig({ version: 2 }); + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + // No TIGRIS_STORAGE_SECRET_ACCESS_KEY + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('none'); + }); + + it('ignores incomplete AWS_ env vars (missing secret)', async () => { + writeRawConfig({ version: 2 }); + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + // No AWS_SECRET_ACCESS_KEY + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('none'); + }); + + it('TIGRIS_ env vars take priority over AWS_ env vars', async () => { + writeRawConfig({ version: 2 }); + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + + expect(method).toEqual({ + type: 'environment', + accessKeyId: 'TIG-AK', + secretAccessKey: 'TIG-SK', + source: 'tigris', + }); + }); +}); diff --git a/test/auth/storage.test.ts b/test/auth/storage.test.ts index 22b69cd..21d5dac 100644 --- a/test/auth/storage.test.ts +++ b/test/auth/storage.test.ts @@ -234,8 +234,8 @@ describe('auth/storage', () => { }, }); - const storage = await import('../../src/auth/storage.js'); - expect(storage.getCredentials()?.accessKeyId).toBe('TEMP'); + const provider = await import('../../src/auth/provider.js'); + expect(provider.getCredentials()?.accessKeyId).toBe('TEMP'); }); it('falls back to saved when no temporary', async () => { @@ -250,14 +250,14 @@ describe('auth/storage', () => { }, }); - const storage = await import('../../src/auth/storage.js'); - expect(storage.getCredentials()?.accessKeyId).toBe('SAVED'); + const provider = await import('../../src/auth/provider.js'); + expect(provider.getCredentials()?.accessKeyId).toBe('SAVED'); }); it('returns null when no credentials exist', async () => { writeRawConfig({ version: 2 }); - const storage = await import('../../src/auth/storage.js'); - expect(storage.getCredentials()).toBeNull(); + const provider = await import('../../src/auth/provider.js'); + expect(provider.getCredentials()).toBeNull(); }); }); From 6a11e23c1e87871a516e0d7480560d109ee7d816 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Wed, 1 Apr 2026 10:50:34 +0200 Subject: [PATCH 4/8] fix: limit whoami org list to first 5 with hint to see all Co-Authored-By: Claude Opus 4.6 --- src/lib/whoami.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts index 075d628..1dfd5c1 100644 --- a/src/lib/whoami.ts +++ b/src/lib/whoami.ts @@ -141,14 +141,23 @@ export default async function whoami( if (organizations) { // OAuth path — list all orgs if (organizations.length > 0) { + const maxVisible = 5; + const visible = organizations.slice(0, maxVisible); + lines.push(''); lines.push(`Organizations (${organizations.length}):`); - organizations.forEach((org) => { + visible.forEach((org) => { const isSelected = org.id === selectedOrg; const marker = isSelected ? '>' : ' '; lines.push(` ${marker} ${org.name} (${org.id})`); }); + if (organizations.length > maxVisible) { + lines.push( + ` ... and ${organizations.length - maxVisible} more. Run "tigris organizations list" to see all.` + ); + } + if (selectedOrg) { const selected = organizations.find((o) => o.id === selectedOrg); if (selected) { From ea0aaacbb343358092d48518420df8a0735e611b Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Wed, 1 Apr 2026 11:05:32 +0200 Subject: [PATCH 5/8] refactor: merge getEnvCredentialSource into getEnvCredentials Single function now returns credentials with source field, eliminating duplicated env var logic and the inconsistency flagged in PR review. Co-Authored-By: Claude Opus 4.6 --- src/auth/provider.ts | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 00948d7..4331390 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -51,29 +51,16 @@ const auth0Config = getAuth0Config(); // Environment credential helpers // --------------------------------------------------------------------------- -/** - * Return which env var family is providing credentials ('tigris' | 'aws' | null) - */ -export function getEnvCredentialSource(): 'tigris' | 'aws' | null { - if ( - process.env.TIGRIS_STORAGE_ACCESS_KEY_ID || - process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY - ) { - return 'tigris'; - } - if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SECRET_ACCESS_KEY) { - return 'aws'; - } - return null; -} +type EnvCredentials = CredentialsConfig & { source: 'tigris' | 'aws' }; /** * Get credentials from environment variables. * If any TIGRIS_ var is set, use TIGRIS_ vars exclusively. * Otherwise, fall back to AWS_ vars. + * Returns the resolved credentials along with the env var family ('tigris' | 'aws'). */ -export function getEnvCredentials(): CredentialsConfig | null { - // Check TIGRIS_ vars first +export function getEnvCredentials(): EnvCredentials | null { + // Check TIGRIS_ vars first — if either is set, stay in TIGRIS mode exclusively if ( process.env.TIGRIS_STORAGE_ACCESS_KEY_ID || process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY @@ -88,7 +75,7 @@ export function getEnvCredentials(): CredentialsConfig | null { const endpoint = process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT; - return { accessKeyId, secretAccessKey, endpoint }; + return { accessKeyId, secretAccessKey, endpoint, source: 'tigris' }; } // Fall back to AWS_ vars @@ -101,7 +88,7 @@ export function getEnvCredentials(): CredentialsConfig | null { const endpoint = process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT; - return { accessKeyId, secretAccessKey, endpoint }; + return { accessKeyId, secretAccessKey, endpoint, source: 'aws' }; } /** @@ -200,12 +187,11 @@ export async function resolveAuthMethod(): Promise { // 4. Env vars const envCreds = getEnvCredentials(); if (envCreds) { - const source = getEnvCredentialSource(); return { type: 'environment', accessKeyId: envCreds.accessKeyId, secretAccessKey: envCreds.secretAccessKey, - source: source ?? 'aws', + source: envCreds.source, }; } From 69a420d24a030a450b946c755f4e55f97b732a4d Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Wed, 1 Apr 2026 11:19:48 +0200 Subject: [PATCH 6/8] fix: reorder auth priority so env vars override login state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New priority: AWS profile → env vars → oauth → credentials → configured. Env vars are explicit per-session overrides and should win over a previously stored login. Also replace unnecessary dynamic import of getStorageConfig in whoami with the existing static import. Co-Authored-By: Claude Opus 4.6 --- src/auth/provider.ts | 35 +++++++++++----------- src/lib/whoami.ts | 10 ++++--- test/auth/provider.test.ts | 59 ++++++++++++++++---------------------- 3 files changed, 49 insertions(+), 55 deletions(-) diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 4331390..9fcc5ab 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -93,11 +93,11 @@ export function getEnvCredentials(): EnvCredentials | null { /** * Get non-login credentials in priority order: - * 1. Environment variables (TIGRIS_ACCESS_KEY / AWS_ACCESS_KEY_ID) + * 1. Environment variables (AWS_ACCESS_KEY_ID / TIGRIS_STORAGE_ACCESS_KEY_ID) * 2. Stored credentials (temporary from login, then saved from configure) * * Note: AWS profile and login method checks are handled separately. - * Full resolution order: AWS_PROFILE → login → env vars → configured + * Full resolution order: AWS_PROFILE → env vars → oauth → credentials login → configured */ export function getCredentials(): CredentialsConfig | null { return getEnvCredentials() || getStoredCredentials() || null; @@ -151,7 +151,10 @@ export type AuthMethod = /** * Resolve which auth method is active, following the same priority as getStorageConfig(). - * 1. AWS Profile 2. OAuth login 3. Credentials login 4. Env vars 5. Configured + * 1. AWS Profile 2. Env vars (AWS_ then TIGRIS_) 3. OAuth 4. Credentials login 5. Configured + * + * Env vars come before login methods because setting them is an explicit + * per-session override that should win over a previously stored login. */ export async function resolveAuthMethod(): Promise { // 1. AWS profile @@ -166,7 +169,18 @@ export async function resolveAuthMethod(): Promise { }; } - // 2–3. Login (oauth or credentials) + // 2. Env vars (explicit per-session override) + const envCreds = getEnvCredentials(); + if (envCreds) { + return { + type: 'environment', + accessKeyId: envCreds.accessKeyId, + secretAccessKey: envCreds.secretAccessKey, + source: envCreds.source, + }; + } + + // 3–4. Login (oauth or credentials) const loginMethod = getStoredLoginMethod(); if (loginMethod === 'oauth') { @@ -184,17 +198,6 @@ export async function resolveAuthMethod(): Promise { } } - // 4. Env vars - const envCreds = getEnvCredentials(); - if (envCreds) { - return { - type: 'environment', - accessKeyId: envCreds.accessKeyId, - secretAccessKey: envCreds.secretAccessKey, - source: envCreds.source, - }; - } - // 5. Configured credentials const configured = getStoredCredentials(); if (configured) { @@ -323,8 +326,8 @@ export async function getStorageConfig(options?: { export async function isAuthenticated(): Promise { return ( hasAwsProfile() || - (await getLoginMethod()) !== null || getEnvCredentials() !== null || + (await getLoginMethod()) !== null || getStoredCredentials() !== null ); } diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts index 1dfd5c1..9617473 100644 --- a/src/lib/whoami.ts +++ b/src/lib/whoami.ts @@ -1,5 +1,9 @@ import { getAuthClient } from '@auth/client.js'; -import { getTigrisConfig, resolveAuthMethod } from '@auth/provider.js'; +import { + getStorageConfig, + getTigrisConfig, + resolveAuthMethod, +} from '@auth/provider.js'; import { getSelectedOrganization } from '@auth/storage.js'; import { listOrganizations, whoami as iamWhoami } from '@tigrisdata/iam'; import { failWithError } from '@utils/exit.js'; @@ -83,9 +87,7 @@ export default async function whoami( userId = claims.sub; // Fetch organizations - const config = await ( - await import('@auth/provider.js') - ).getStorageConfig(); + const config = await getStorageConfig(); selectedOrg = getSelectedOrganization(); const { data, error } = await listOrganizations({ config }); if (error) { diff --git a/test/auth/provider.test.ts b/test/auth/provider.test.ts index 61711ea..48452b7 100644 --- a/test/auth/provider.test.ts +++ b/test/auth/provider.test.ts @@ -200,32 +200,42 @@ describe('resolveAuthMethod', () => { // Priority ordering // ------------------------------------------------------------------------- - it('aws-profile takes priority over oauth login', async () => { - writeRawConfig({ version: 2, activeMethod: 'oauth' }); + it('aws-profile takes priority over env vars', async () => { + writeRawConfig({ version: 2 }); createAwsFiles(); process.env.AWS_PROFILE = 'prof'; + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; const { resolveAuthMethod } = await import('../../src/auth/provider.js'); const method = await resolveAuthMethod(); expect(method.type).toBe('aws-profile'); }); - it('aws-profile takes priority over env vars', async () => { - writeRawConfig({ version: 2 }); + it('aws-profile takes priority over oauth login', async () => { + writeRawConfig({ version: 2, activeMethod: 'oauth' }); createAwsFiles(); process.env.AWS_PROFILE = 'prof'; - process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; - process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; const { resolveAuthMethod } = await import('../../src/auth/provider.js'); const method = await resolveAuthMethod(); expect(method.type).toBe('aws-profile'); }); - it('oauth takes priority over credentials login', async () => { + it('env vars take priority over oauth login', async () => { + writeRawConfig({ version: 2, activeMethod: 'oauth' }); + process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; + process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; + + const { resolveAuthMethod } = await import('../../src/auth/provider.js'); + const method = await resolveAuthMethod(); + expect(method.type).toBe('environment'); + }); + + it('env vars take priority over credentials login', async () => { writeRawConfig({ version: 2, - activeMethod: 'oauth', + activeMethod: 'credentials', credentials: { temporary: { accessKeyId: 'CRED-AK', @@ -234,26 +244,18 @@ describe('resolveAuthMethod', () => { }, }, }); + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; + process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; const { resolveAuthMethod } = await import('../../src/auth/provider.js'); const method = await resolveAuthMethod(); - expect(method.type).toBe('oauth'); - }); - - it('oauth takes priority over env vars', async () => { - writeRawConfig({ version: 2, activeMethod: 'oauth' }); - process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; - process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; - - const { resolveAuthMethod } = await import('../../src/auth/provider.js'); - const method = await resolveAuthMethod(); - expect(method.type).toBe('oauth'); + expect(method.type).toBe('environment'); }); - it('credentials login takes priority over env vars', async () => { + it('oauth takes priority over credentials login', async () => { writeRawConfig({ version: 2, - activeMethod: 'credentials', + activeMethod: 'oauth', credentials: { temporary: { accessKeyId: 'CRED-AK', @@ -262,12 +264,10 @@ describe('resolveAuthMethod', () => { }, }, }); - process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; - process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; const { resolveAuthMethod } = await import('../../src/auth/provider.js'); const method = await resolveAuthMethod(); - expect(method.type).toBe('credentials'); + expect(method.type).toBe('oauth'); }); it('env vars take priority over configured credentials', async () => { @@ -293,17 +293,6 @@ describe('resolveAuthMethod', () => { // Edge cases / fallthrough // ------------------------------------------------------------------------- - it('credentials login with no stored creds falls through to env vars', async () => { - // activeMethod is credentials but no credential slots exist - writeRawConfig({ version: 2, activeMethod: 'credentials' }); - process.env.AWS_ACCESS_KEY_ID = 'AWS-AK'; - process.env.AWS_SECRET_ACCESS_KEY = 'AWS-SK'; - - const { resolveAuthMethod } = await import('../../src/auth/provider.js'); - const method = await resolveAuthMethod(); - expect(method.type).toBe('environment'); - }); - it('credentials login with no stored creds falls through to configured', async () => { writeRawConfig({ version: 2, From 09ee8dc55ebff6618b328827d27cf1f3b38136f0 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Wed, 1 Apr 2026 11:31:57 +0200 Subject: [PATCH 7/8] fix: prioritize AWS_ env vars over TIGRIS_ in config and credentials AWS_ variables are the standard and should be checked before TIGRIS_ in getTigrisConfig() and getEnvCredentials(). Co-Authored-By: Claude Opus 4.6 --- src/auth/provider.ts | 67 +++++++++++++++++++------------------- test/auth/provider.test.ts | 8 ++--- 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/auth/provider.ts b/src/auth/provider.ts index 9fcc5ab..471d014 100644 --- a/src/auth/provider.ts +++ b/src/auth/provider.ts @@ -27,20 +27,20 @@ export interface TigrisConfig { } export function getTigrisConfig(): TigrisConfig { - // If any TIGRIS_ endpoint var is set, use TIGRIS_ vars exclusively - if (process.env.TIGRIS_STORAGE_ENDPOINT || process.env.TIGRIS_IAM_ENDPOINT) { + // AWS_ endpoint vars take priority + if (process.env.AWS_ENDPOINT_URL_S3 || process.env.AWS_ENDPOINT_URL_IAM) { return { - endpoint: process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT, - iamEndpoint: process.env.TIGRIS_IAM_ENDPOINT || DEFAULT_IAM_ENDPOINT, - mgmtEndpoint: process.env.TIGRIS_MGMT_ENDPOINT || DEFAULT_MGMT_ENDPOINT, + endpoint: process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT, + iamEndpoint: process.env.AWS_ENDPOINT_URL_IAM || DEFAULT_IAM_ENDPOINT, + mgmtEndpoint: process.env.AWS_ENDPOINT_URL_MGMT || DEFAULT_MGMT_ENDPOINT, }; } - // Fall back to AWS_ vars + // Fall back to TIGRIS_ vars return { - endpoint: process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT, - iamEndpoint: process.env.AWS_ENDPOINT_URL_IAM || DEFAULT_IAM_ENDPOINT, - mgmtEndpoint: process.env.AWS_ENDPOINT_URL_MGMT || DEFAULT_MGMT_ENDPOINT, + endpoint: process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT, + iamEndpoint: process.env.TIGRIS_IAM_ENDPOINT || DEFAULT_IAM_ENDPOINT, + mgmtEndpoint: process.env.TIGRIS_MGMT_ENDPOINT || DEFAULT_MGMT_ENDPOINT, }; } @@ -55,40 +55,39 @@ type EnvCredentials = CredentialsConfig & { source: 'tigris' | 'aws' }; /** * Get credentials from environment variables. - * If any TIGRIS_ var is set, use TIGRIS_ vars exclusively. - * Otherwise, fall back to AWS_ vars. - * Returns the resolved credentials along with the env var family ('tigris' | 'aws'). + * AWS_ vars take priority over TIGRIS_ vars. + * Within each family, both key and secret must be present. + * Returns the resolved credentials along with the env var family ('aws' | 'tigris'). */ export function getEnvCredentials(): EnvCredentials | null { - // Check TIGRIS_ vars first — if either is set, stay in TIGRIS mode exclusively + // Check AWS_ vars first + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + const endpoint = + process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT; + return { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + endpoint, + source: 'aws', + }; + } + + // Fall back to TIGRIS_ vars if ( - process.env.TIGRIS_STORAGE_ACCESS_KEY_ID || + process.env.TIGRIS_STORAGE_ACCESS_KEY_ID && process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY ) { - const accessKeyId = process.env.TIGRIS_STORAGE_ACCESS_KEY_ID; - const secretAccessKey = process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY; - - if (!accessKeyId || !secretAccessKey) { - return null; - } - const endpoint = process.env.TIGRIS_STORAGE_ENDPOINT || DEFAULT_STORAGE_ENDPOINT; - - return { accessKeyId, secretAccessKey, endpoint, source: 'tigris' }; - } - - // Fall back to AWS_ vars - const accessKeyId = process.env.AWS_ACCESS_KEY_ID; - const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - - if (!accessKeyId || !secretAccessKey) { - return null; + return { + accessKeyId: process.env.TIGRIS_STORAGE_ACCESS_KEY_ID, + secretAccessKey: process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY, + endpoint, + source: 'tigris', + }; } - const endpoint = process.env.AWS_ENDPOINT_URL_S3 || DEFAULT_STORAGE_ENDPOINT; - - return { accessKeyId, secretAccessKey, endpoint, source: 'aws' }; + return null; } /** diff --git a/test/auth/provider.test.ts b/test/auth/provider.test.ts index 48452b7..bb7641a 100644 --- a/test/auth/provider.test.ts +++ b/test/auth/provider.test.ts @@ -347,7 +347,7 @@ describe('resolveAuthMethod', () => { expect(method.type).toBe('none'); }); - it('TIGRIS_ env vars take priority over AWS_ env vars', async () => { + it('AWS_ env vars take priority over TIGRIS_ env vars', async () => { writeRawConfig({ version: 2 }); process.env.TIGRIS_STORAGE_ACCESS_KEY_ID = 'TIG-AK'; process.env.TIGRIS_STORAGE_SECRET_ACCESS_KEY = 'TIG-SK'; @@ -359,9 +359,9 @@ describe('resolveAuthMethod', () => { expect(method).toEqual({ type: 'environment', - accessKeyId: 'TIG-AK', - secretAccessKey: 'TIG-SK', - source: 'tigris', + accessKeyId: 'AWS-AK', + secretAccessKey: 'AWS-SK', + source: 'aws', }); }); }); From 81ee7fea45e1f1f94d117b178c9713694056c206 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Thu, 2 Apr 2026 05:43:51 -0400 Subject: [PATCH 8/8] fix: clear stale activeMethod in clearOAuthData to prevent broken state When clearOAuthData() is called without first updating activeMethod, resolveAuthMethod() would return oauth but no tokens exist, causing getStorageConfig() to crash on authClient.getAccessToken(). Co-Authored-By: Claude Opus 4.6 --- src/auth/storage.ts | 7 ++++++- test/auth/storage.test.ts | 21 +++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/auth/storage.ts b/src/auth/storage.ts index 9aea7fd..7fafa63 100644 --- a/src/auth/storage.ts +++ b/src/auth/storage.ts @@ -448,11 +448,16 @@ export async function clearTemporaryCredentials(): Promise { } /** - * Clear all OAuth data (tokens, organizations, selectedOrganization) + * Clear all OAuth data (tokens, organizations, selectedOrganization). + * Also clears activeMethod when it's 'oauth' to prevent broken state + * where resolveAuthMethod() returns oauth but no tokens exist. */ export async function clearOAuthData(): Promise { const config = readConfig(); delete config.oauth; + if (config.activeMethod === 'oauth') { + delete config.activeMethod; + } await writeConfig(config); } diff --git a/test/auth/storage.test.ts b/test/auth/storage.test.ts index 21d5dac..b251e83 100644 --- a/test/auth/storage.test.ts +++ b/test/auth/storage.test.ts @@ -443,7 +443,7 @@ describe('auth/storage', () => { // clearOAuthData // --------------------------------------------------------------------------- describe('clearOAuthData', () => { - it('removes all OAuth data while preserving credentials', async () => { + it('removes OAuth data and clears activeMethod when it is oauth', async () => { writeRawConfig({ version: 2, activeMethod: 'oauth', @@ -467,7 +467,24 @@ describe('auth/storage', () => { const raw = readRawConfig() as Record; expect(raw['oauth']).toBeUndefined(); expect(raw['credentials']).toBeDefined(); - expect(raw['activeMethod']).toBe('oauth'); + expect(raw['activeMethod']).toBeUndefined(); + }); + + it('preserves activeMethod when it is not oauth', async () => { + writeRawConfig({ + version: 2, + activeMethod: 'credentials', + oauth: { + tokens: { accessToken: 't', expiresAt: 1 }, + }, + }); + + const storage = await import('../../src/auth/storage.js'); + await storage.clearOAuthData(); + + const raw = readRawConfig() as Record; + expect(raw['oauth']).toBeUndefined(); + expect(raw['activeMethod']).toBe('credentials'); }); it('does nothing when no OAuth data exists', async () => {