diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..ef676bc --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,21 @@ +name: Pull Request + +on: + pull_request: + branches: + - main + - next + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - run: npm run build + - run: npm run test diff --git a/package-lock.json b/package-lock.json index ff53ae6..9155088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,14 @@ "": { "name": "@tigrisdata/cli", "version": "0.0.1", + "hasInstallScript": true, "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.908.0", "@aws-sdk/credential-providers": "^3.981.0", "@smithy/shared-ini-file-loader": "^4.4.3", - "@tigrisdata/iam": "^1.1.0", - "@tigrisdata/storage": "^2.12.2", + "@tigrisdata/iam": "^1.2.0", + "@tigrisdata/storage": "^2.13.0", "axios": "^1.12.2", "commander": "^11.0.0", "enquirer": "^2.4.1", @@ -4084,18 +4085,18 @@ "license": "MIT" }, "node_modules/@tigrisdata/iam": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@tigrisdata/iam/-/iam-1.1.0.tgz", - "integrity": "sha512-5cwjN5G2ygnbL0Fcwrtxujjxlo4W7wJFHXVhTT9kWlItj2kWNy/XPgbuoteUeQXDKiIr9c/+C03mA3pKWVfdmQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/iam/-/iam-1.2.0.tgz", + "integrity": "sha512-8PP5L4Z8WASeRwivHP8bc1ls3Y9EKtmhhcMZ6CvgGkuBdGiLeYZsE7XHMbXhqs0Pely/08zTkg+ssZ3u8nCEAA==", "license": "MIT", "dependencies": { "dotenv": "^17.2.1" } }, "node_modules/@tigrisdata/storage": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-2.12.2.tgz", - "integrity": "sha512-LgK9947J7aGibFMwct6li7Er65RAx6ziCHZ0xqGiE+vHBMcoMowBK9n33h97ChP/Si5g5lE9tpOl/0TESr2chg==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-2.13.0.tgz", + "integrity": "sha512-IqQhZfuqoSwXcKpEqOlVRBd2JZzs+BER1gCp5ukPQBklsv2O0f5Bq8c/qLxNRKl2KDqq1cJ5OXm7VYYBhdg9tA==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", diff --git a/package.json b/package.json index 665131c..8d44d04 100644 --- a/package.json +++ b/package.json @@ -82,8 +82,8 @@ "@aws-sdk/client-s3": "^3.908.0", "@aws-sdk/credential-providers": "^3.981.0", "@smithy/shared-ini-file-loader": "^4.4.3", - "@tigrisdata/iam": "^1.1.0", - "@tigrisdata/storage": "^2.12.2", + "@tigrisdata/iam": "^1.2.0", + "@tigrisdata/storage": "^2.13.0", "axios": "^1.12.2", "commander": "^11.0.0", "enquirer": "^2.4.1", diff --git a/src/auth/s3-client.ts b/src/auth/s3-client.ts index db46261..6f3ffd5 100644 --- a/src/auth/s3-client.ts +++ b/src/auth/s3-client.ts @@ -92,7 +92,7 @@ export async function getStorageConfig(): Promise { accessKeyId: '', secretAccessKey: '', endpoint: tigrisConfig.endpoint, - organizationId: getSelectedOrganization() ?? undefined, + organizationId: selectedOrg, iamEndpoint: tigrisConfig.iamEndpoint, authDomain: auth0Config.domain, }; diff --git a/src/cli.ts b/src/cli.ts index b0e9c51..c0d091f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { Command as CommanderCommand } from 'commander'; import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import type { Argument, OperationSpec, CommandSpec } from './types.js'; +import type { Argument, CommandSpec } from './types.js'; import { loadSpecs } from './utils/specs.js'; import { checkForUpdates } from './utils/update-check.js'; import { version } from '../package.json'; @@ -32,23 +32,29 @@ const __dirname = dirname(__filename); const specs = loadSpecs(); /** - * Check if a command/operation has an implementation + * Validate command name to prevent path traversal attacks + * Only allows alphanumeric, hyphens, and underscores */ -function hasImplementation( - commandName: string, - operationName?: string -): boolean { - const paths = operationName - ? [ - join(__dirname, 'lib', commandName, `${operationName}.js`), - join(__dirname, 'lib', commandName, operationName, 'index.js'), - ] - : [ - join(__dirname, 'lib', `${commandName}.js`), - join(__dirname, 'lib', commandName, 'index.js'), - ]; - - return paths.some((p) => existsSync(p)); +function isValidCommandName(name: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(name); +} + +/** + * Check if a command path has an implementation + * @param pathParts - Array of command path parts, e.g., ['iam', 'policies', 'get'] + */ +function hasImplementation(pathParts: string[]): boolean { + if (pathParts.length === 0) return false; + + // Try direct file: lib/iam/policies/get.js + const directPath = join(__dirname, 'lib', ...pathParts) + '.js'; + if (existsSync(directPath)) return true; + + // Try index file: lib/iam/policies/get/index.js + const indexPath = join(__dirname, 'lib', ...pathParts, 'index.js'); + if (existsSync(indexPath)) return true; + + return false; } function formatArgumentHelp(arg: Argument): string { @@ -59,7 +65,7 @@ function formatArgumentHelp(arg: Argument): string { } else { optionPart = ` --${arg.name}`; // Only show short option if it's a single character (Commander requirement) - if (arg.alias && arg.alias.length === 1) { + if (arg.alias && typeof arg.alias === 'string' && arg.alias.length === 1) { optionPart += `, -${arg.alias}`; } } @@ -107,24 +113,28 @@ function formatArgumentHelp(arg: Argument): string { return `${paddedOptionPart}${description}`; } -function showCommandHelp(command: CommandSpec) { - console.log(`\n${specs.name} ${command.name} - ${command.description}\n`); +/** + * Show help for a command at any nesting level + */ +function showCommandHelp(command: CommandSpec, pathParts: string[]) { + const fullPath = pathParts.join(' '); + console.log(`\n${specs.name} ${fullPath} - ${command.description}\n`); - if (command.operations && command.operations.length > 0) { - const availableOps = command.operations.filter((op) => - hasImplementation(command.name, op.name) + if (command.commands && command.commands.length > 0) { + const availableCmds = command.commands.filter((cmd) => + commandHasAnyImplementation(cmd, [...pathParts, cmd.name]) ); - if (availableOps.length > 0) { - console.log('Operations:'); - availableOps.forEach((op) => { - let operationPart = ` ${op.name}`; - if (op.alias) { - const aliases = Array.isArray(op.alias) ? op.alias : [op.alias]; - operationPart += ` (${aliases.join(', ')})`; + if (availableCmds.length > 0) { + console.log('Commands:'); + availableCmds.forEach((cmd) => { + let cmdPart = ` ${cmd.name}`; + if (cmd.alias) { + const aliases = Array.isArray(cmd.alias) ? cmd.alias : [cmd.alias]; + cmdPart += ` (${aliases.join(', ')})`; } - const paddedOperationPart = operationPart.padEnd(24); - console.log(`${paddedOperationPart}${op.description}`); + const paddedCmdPart = cmdPart.padEnd(24); + console.log(`${paddedCmdPart}${cmd.description}`); }); console.log(); } @@ -146,43 +156,29 @@ function showCommandHelp(command: CommandSpec) { console.log(); } - console.log( - `Use "${specs.name} ${command.name} help" for more information about an operation.` - ); -} - -function showOperationHelp(command: CommandSpec, operation: OperationSpec) { - console.log( - `\n${specs.name} ${command.name} ${operation.name} - ${operation.description}\n` - ); - - if (operation.arguments && operation.arguments.length > 0) { - console.log('Arguments:'); - operation.arguments.forEach((arg) => { - console.log(formatArgumentHelp(arg)); - }); - console.log(); - } - - if (operation.examples && operation.examples.length > 0) { - console.log('Examples:'); - operation.examples.forEach((ex) => { - console.log(` ${ex}`); - }); - console.log(); + if (command.commands && command.commands.length > 0) { + console.log( + `Use "${specs.name} ${fullPath} help" for more information about a command.` + ); } } -function commandHasAnyImplementation(command: CommandSpec): boolean { - // Check if command itself has implementation - if (hasImplementation(command.name)) { +/** + * Recursively check if a command or any of its children have implementations + */ +function commandHasAnyImplementation( + command: CommandSpec, + pathParts: string[] +): boolean { + // Check if this command itself has implementation (leaf node) + if (hasImplementation(pathParts)) { return true; } - // Check if any operation has implementation - if (command.operations) { - return command.operations.some((op) => - hasImplementation(command.name, op.name) + // Check if any child command has implementation + if (command.commands) { + return command.commands.some((child) => + commandHasAnyImplementation(child, [...pathParts, child.name]) ); } @@ -194,12 +190,17 @@ function showMainHelp() { console.log('Usage: tigris [command] [options]\n'); console.log('Commands:'); - const availableCommands = specs.commands.filter(commandHasAnyImplementation); + const availableCommands = specs.commands.filter((cmd) => + commandHasAnyImplementation(cmd, [cmd.name]) + ); availableCommands.forEach((command: CommandSpec) => { let commandPart = ` ${command.name}`; if (command.alias) { - commandPart += ` (${command.alias})`; + const aliases = Array.isArray(command.alias) + ? command.alias + : [command.alias]; + commandPart += ` (${aliases.join(', ')})`; } const paddedCommandPart = commandPart.padEnd(24); console.log(`${paddedCommandPart}${command.description}`); @@ -219,7 +220,8 @@ function addArgumentsToCommand(cmd: CommanderCommand, args: Argument[] = []) { // Handle regular flag/option arguments // Commander expects single-character short options: -p, --prefix // Multi-character aliases are not supported by Commander - const hasValidShortOption = arg.alias && arg.alias.length === 1; + const hasValidShortOption = + arg.alias && typeof arg.alias === 'string' && arg.alias.length === 1; let optionString = hasValidShortOption ? `-${arg.alias}, --${arg.name}` : `--${arg.name}`; @@ -275,7 +277,7 @@ function getOptionValue( ): unknown { if (args) { const argDef = args.find((a) => a.name === argName); - if (argDef && argDef.alias) { + if (argDef && argDef.alias && typeof argDef.alias === 'string') { const aliasKey = argDef.alias.charAt(0).toUpperCase() + argDef.alias.slice(1); if (options[aliasKey] !== undefined) { @@ -304,16 +306,17 @@ function camelCase(str: string): string { return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); } +/** + * Load module from path parts + * @param pathParts - Array of command path parts, e.g., ['iam', 'policies', 'get'] + */ async function loadModule( - commandName: string, - operationName?: string + pathParts: string[] ): Promise<{ module: Record | null; error: string | null }> { - const paths = operationName - ? [ - `./lib/${commandName}/${operationName}.js`, - `./lib/${commandName}/${operationName}/index.js`, - ] - : [`./lib/${commandName}.js`, `./lib/${commandName}/index.js`]; + const paths = [ + `./lib/${pathParts.join('/')}.js`, + `./lib/${pathParts.join('/')}/index.js`, + ]; for (const path of paths) { const module = await import(path).catch(() => null); @@ -322,15 +325,16 @@ async function loadModule( } } - const cmdDisplay = operationName - ? `${commandName} ${operationName}` - : commandName; + const cmdDisplay = pathParts.join(' '); return { module: null, error: `Command not found: ${cmdDisplay}` }; } +/** + * Load and execute a command + * @param pathParts - Array of command path parts + */ async function loadAndExecuteCommand( - commandName: string, - operationName?: string, + pathParts: string[], positionalArgs: string[] = [], options: Record = {}, message?: string @@ -342,22 +346,19 @@ async function loadAndExecuteCommand( } // Load module - const { module, error: loadError } = await loadModule( - commandName, - operationName - ); + const { module, error: loadError } = await loadModule(pathParts); if (loadError || !module) { console.error(loadError); process.exit(1); } - // Get command function - const functionName = operationName || commandName; + // Get command function - use default export or named export matching last path part + const functionName = pathParts[pathParts.length - 1]; const commandFunction = module.default || module[functionName]; if (typeof commandFunction !== 'function') { - console.error(`Command not implemented: ${functionName}`); + console.error(`Command not implemented: ${pathParts.join(' ')}`); process.exit(1); } @@ -365,10 +366,6 @@ async function loadAndExecuteCommand( await commandFunction({ ...options, _positional: positionalArgs }); } -const program = new CommanderCommand(); - -program.name(specs.name).description(specs.description).version(specs.version); - function extractArgumentValues( args: Argument[], positionalArgs: string[], @@ -426,148 +423,129 @@ function extractArgumentValues( return result; } -specs.commands.forEach((command: CommandSpec) => { - const commandCmd = program - .command(command.name) - .description(command.description); +/** + * Recursively register commands from spec + */ +function registerCommands( + parent: CommanderCommand, + commandSpecs: CommandSpec[], + pathParts: string[] = [] +) { + for (const spec of commandSpecs) { + // Validate command name to prevent path traversal + if (!isValidCommandName(spec.name)) { + console.error( + `Invalid command name "${spec.name}": only alphanumeric, hyphens, and underscores allowed` + ); + process.exit(1); + } + + const currentPath = [...pathParts, spec.name]; + const cmd = parent.command(spec.name).description(spec.description); - if (command.alias) { - commandCmd.alias(command.alias); - } + // Handle aliases + if (spec.alias) { + const aliases = Array.isArray(spec.alias) ? spec.alias : [spec.alias]; + aliases.forEach((alias) => cmd.alias(alias)); + } - if (command.operations && command.operations.length > 0) { - command.operations.forEach((operationSpec: OperationSpec) => { - const subCmd = commandCmd - .command(operationSpec.name) - .description(operationSpec.description); - - if (operationSpec.alias) { - const aliases = Array.isArray(operationSpec.alias) - ? operationSpec.alias - : [operationSpec.alias]; - aliases.forEach((alias: string) => subCmd.alias(alias)); + // Check if this command has children + if (spec.commands && spec.commands.length > 0) { + // Has children - recurse + registerCommands(cmd, spec.commands, currentPath); + + // Check for default command + if (spec.default) { + const defaultCmd = spec.commands.find((c) => c.name === spec.default); + if (defaultCmd) { + // Add arguments from both parent and default child + addArgumentsToCommand(cmd, spec.arguments); + addArgumentsToCommand(cmd, defaultCmd.arguments); + + const allArguments = [ + ...(spec.arguments || []), + ...(defaultCmd.arguments || []), + ]; + + cmd.action(async (...args) => { + const options = args.pop(); + const positionalArgs = args; + + if ( + allArguments.length > 0 && + !validateRequiredWhen( + allArguments, + extractArgumentValues(allArguments, positionalArgs, options) + ) + ) { + return; + } + + await loadAndExecuteCommand( + [...currentPath, defaultCmd.name], + positionalArgs, + extractArgumentValues(allArguments, positionalArgs, options), + spec.message || defaultCmd.message + ); + }); + } + } else { + // No default - show help when command is called without subcommand + cmd.action(() => { + showCommandHelp(spec, currentPath); + }); } - addArgumentsToCommand(subCmd, operationSpec.arguments); + // Add help subcommand + cmd + .command('help') + .description('Show help for this command') + .action(() => { + showCommandHelp(spec, currentPath); + }); + } else { + // Leaf node - this is an executable command + addArgumentsToCommand(cmd, spec.arguments); - subCmd.action(async (...args) => { - // Handle both positional and option arguments - const options = args.pop(); // Last argument is always options - const positionalArgs = args; // Remaining are positional arguments + cmd.action(async (...args) => { + const options = args.pop(); + const positionalArgs = args; if ( - operationSpec.arguments && + spec.arguments && !validateRequiredWhen( - operationSpec.arguments, - extractArgumentValues( - operationSpec.arguments, - positionalArgs, - options - ) + spec.arguments, + extractArgumentValues(spec.arguments, positionalArgs, options) ) ) { return; } await loadAndExecuteCommand( - command.name, - operationSpec.name, + currentPath, positionalArgs, - extractArgumentValues( - operationSpec.arguments || [], - positionalArgs, - options - ), - operationSpec.message + extractArgumentValues(spec.arguments || [], positionalArgs, options), + spec.message ); }); - subCmd + // Add help for leaf commands too + cmd .command('help') - .description('Show help for this operation') + .description('Show help for this command') .action(() => { - showOperationHelp(command, operationSpec); + showCommandHelp(spec, currentPath); }); - }); - - if (command.default) { - const defaultOperation = command.operations?.find( - (c: OperationSpec) => c.name === command.default - ); - - if (defaultOperation) { - // Add both command-level and default operation arguments - addArgumentsToCommand(commandCmd, command.arguments); - addArgumentsToCommand(commandCmd, defaultOperation.arguments); - - // Merge command and operation arguments for validation/extraction - const allArguments = [ - ...(command.arguments || []), - ...(defaultOperation.arguments || []), - ]; - - commandCmd.action(async (...args) => { - const options = args.pop(); - const positionalArgs = args; - - if ( - allArguments.length > 0 && - !validateRequiredWhen( - allArguments, - extractArgumentValues(allArguments, positionalArgs, options) - ) - ) { - return; - } - - await loadAndExecuteCommand( - command.name, - defaultOperation.name, - positionalArgs, - extractArgumentValues(allArguments, positionalArgs, options), - command.message || defaultOperation.message - ); - }); - } - } else { - commandCmd.action(() => { - showCommandHelp(command); - }); } - } else { - addArgumentsToCommand(commandCmd, command.arguments); - - commandCmd.action(async (...args) => { - const options = args.pop(); - const positionalArgs = args; - - if ( - command.arguments && - !validateRequiredWhen( - command.arguments, - extractArgumentValues(command.arguments, positionalArgs, options) - ) - ) { - return; - } - - await loadAndExecuteCommand( - command.name, - undefined, - positionalArgs, - extractArgumentValues(command.arguments || [], positionalArgs, options), - command.message - ); - }); } +} - commandCmd - .command('help') - .description('Show help for this command') - .action(() => { - showCommandHelp(command); - }); -}); +const program = new CommanderCommand(); + +program.name(specs.name).description(specs.description).version(specs.version); + +// Register all commands recursively +registerCommands(program, specs.commands); program .command('help') diff --git a/src/lib/_stat.ts b/src/lib/_stat.ts deleted file mode 100644 index 6d4d58f..0000000 --- a/src/lib/_stat.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { parseAnyPath } from '../utils/path.js'; - -export default async function stat(options: { - path?: string; - _positional?: string[]; -}) { - const pathString = options.path || options._positional?.[0]; - - if (!pathString) { - console.error('path argument is required'); - process.exit(1); - } - - const { bucket } = parseAnyPath(pathString); - - if (!bucket) { - console.error('Invalid path'); - process.exit(1); - } - - // TODO: Implement stat logic - console.error('stat command not yet implemented'); - process.exit(1); -} diff --git a/src/lib/iam/policies/create.ts b/src/lib/iam/policies/create.ts new file mode 100644 index 0000000..f6df64a --- /dev/null +++ b/src/lib/iam/policies/create.ts @@ -0,0 +1,112 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { getOption } from '../../../utils/options.js'; +import { getLoginMethod } from '../../../auth/s3-client.js'; +import { getAuthClient } from '../../../auth/client.js'; +import { getSelectedOrganization } from '../../../auth/storage.js'; +import { getTigrisConfig } from '../../../auth/config.js'; +import { addPolicy, type PolicyDocument } from '@tigrisdata/iam'; +import { + printStart, + printSuccess, + printFailure, + msg, +} from '../../../utils/messages.js'; +import { readStdin, parseDocument } from './utils.js'; + +const context = msg('iam policies', 'create'); + +export default async function create(options: Record) { + printStart(context); + + const name = getOption(options, ['name']); + const documentArg = getOption(options, ['document', 'd']); + const description = getOption(options, ['description']) ?? ''; + + if (!name) { + printFailure(context, 'Policy name is required'); + process.exit(1); + } + + // Validate policy name: only alphanumeric and =,.@_- allowed + const validNamePattern = /^[a-zA-Z0-9=,.@_-]+$/; + if (!validNamePattern.test(name)) { + printFailure( + context, + 'Invalid policy name. Only alphanumeric characters and =,.@_- are allowed.' + ); + process.exit(1); + } + + const loginMethod = await getLoginMethod(); + + if (loginMethod !== 'oauth') { + printFailure( + context, + 'Policies can only be created when logged in via OAuth.\nRun "tigris login oauth" first.' + ); + process.exit(1); + } + + const authClient = getAuthClient(); + const isAuthenticated = await authClient.isAuthenticated(); + + if (!isAuthenticated) { + printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); + process.exit(1); + } + + const accessToken = await authClient.getAccessToken(); + const selectedOrg = getSelectedOrganization(); + const tigrisConfig = getTigrisConfig(); + + const iamConfig = { + sessionToken: accessToken, + organizationId: selectedOrg ?? undefined, + iamEndpoint: tigrisConfig.iamEndpoint, + }; + + // Get document content + let documentJson: string; + + if (documentArg) { + // Check if it's a file path or inline JSON + if (existsSync(documentArg)) { + documentJson = readFileSync(documentArg, 'utf-8'); + } else { + // Assume it's inline JSON + documentJson = documentArg; + } + } else if (!process.stdin.isTTY) { + // Read from stdin + documentJson = await readStdin(); + } else { + printFailure( + context, + 'Policy document is required. Provide via --document or pipe to stdin.' + ); + process.exit(1); + } + + // Parse and convert document + let document: PolicyDocument; + try { + document = parseDocument(documentJson); + } catch { + printFailure(context, 'Invalid JSON in policy document'); + process.exit(1); + } + + const { data, error } = await addPolicy(name, { + document, + description, + config: iamConfig, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + printSuccess(context, { name: data.name }); + console.log(`Resource: ${data.resource}`); +} diff --git a/src/lib/iam/policies/delete.ts b/src/lib/iam/policies/delete.ts new file mode 100644 index 0000000..f8fc766 --- /dev/null +++ b/src/lib/iam/policies/delete.ts @@ -0,0 +1,91 @@ +import enquirer from 'enquirer'; +const { prompt } = enquirer; +import { getOption } from '../../../utils/options.js'; +import { getLoginMethod } from '../../../auth/s3-client.js'; +import { getAuthClient } from '../../../auth/client.js'; +import { getSelectedOrganization } from '../../../auth/storage.js'; +import { getTigrisConfig } from '../../../auth/config.js'; +import { deletePolicy, listPolicies } from '@tigrisdata/iam'; +import { + printStart, + printSuccess, + printFailure, + printEmpty, + msg, +} from '../../../utils/messages.js'; + +const context = msg('iam policies', 'delete'); + +export default async function del(options: Record) { + printStart(context); + + let resource = getOption(options, ['resource']); + + const loginMethod = await getLoginMethod(); + + if (loginMethod !== 'oauth') { + printFailure( + context, + 'Policies can only be deleted when logged in via OAuth.\nRun "tigris login oauth" first.' + ); + process.exit(1); + } + + const authClient = getAuthClient(); + const isAuthenticated = await authClient.isAuthenticated(); + + if (!isAuthenticated) { + printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); + process.exit(1); + } + + const accessToken = await authClient.getAccessToken(); + const selectedOrg = getSelectedOrganization(); + const tigrisConfig = getTigrisConfig(); + + const iamConfig = { + sessionToken: accessToken, + organizationId: selectedOrg ?? undefined, + iamEndpoint: tigrisConfig.iamEndpoint, + }; + + // If no resource provided, list policies and let user select + if (!resource) { + const { data: listData, error: listError } = await listPolicies({ + config: iamConfig, + }); + + if (listError) { + printFailure(context, listError.message); + process.exit(1); + } + + if (!listData.policies || listData.policies.length === 0) { + printEmpty(context); + return; + } + + const { selected } = await prompt<{ selected: string }>({ + type: 'select', + name: 'selected', + message: 'Select a policy to delete:', + choices: listData.policies.map((p) => ({ + name: p.resource, + message: `${p.name} (${p.resource})`, + })), + }); + + resource = selected; + } + + const { error } = await deletePolicy(resource, { + config: iamConfig, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + printSuccess(context, { resource }); +} diff --git a/src/lib/iam/policies/edit.ts b/src/lib/iam/policies/edit.ts new file mode 100644 index 0000000..b36d6d5 --- /dev/null +++ b/src/lib/iam/policies/edit.ts @@ -0,0 +1,155 @@ +import { existsSync, readFileSync } from 'node:fs'; +import enquirer from 'enquirer'; +const { prompt } = enquirer; +import { getOption } from '../../../utils/options.js'; +import { getLoginMethod } from '../../../auth/s3-client.js'; +import { getAuthClient } from '../../../auth/client.js'; +import { getSelectedOrganization } from '../../../auth/storage.js'; +import { getTigrisConfig } from '../../../auth/config.js'; +import { + editPolicy, + getPolicy, + listPolicies, + type PolicyDocument, +} from '@tigrisdata/iam'; +import { + printStart, + printSuccess, + printFailure, + printEmpty, + msg, +} from '../../../utils/messages.js'; +import { readStdin, parseDocument } from './utils.js'; + +const context = msg('iam policies', 'edit'); + +export default async function edit(options: Record) { + printStart(context); + + let resource = getOption(options, ['resource']); + const documentArg = getOption(options, ['document', 'd']); + const description = getOption(options, ['description']); + + const loginMethod = await getLoginMethod(); + + if (loginMethod !== 'oauth') { + printFailure( + context, + 'Policies can only be edited when logged in via OAuth.\nRun "tigris login oauth" first.' + ); + process.exit(1); + } + + const authClient = getAuthClient(); + const isAuthenticated = await authClient.isAuthenticated(); + + if (!isAuthenticated) { + printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); + process.exit(1); + } + + const accessToken = await authClient.getAccessToken(); + const selectedOrg = getSelectedOrganization(); + const tigrisConfig = getTigrisConfig(); + + const iamConfig = { + sessionToken: accessToken, + organizationId: selectedOrg ?? undefined, + iamEndpoint: tigrisConfig.iamEndpoint, + }; + + // If no resource provided, list policies and let user select + // But if stdin is piped, we can't use interactive selection + if (!resource) { + if (!process.stdin.isTTY) { + printFailure( + context, + 'Policy ARN is required when piping document via stdin.' + ); + process.exit(1); + } + + const { data: listData, error: listError } = await listPolicies({ + config: iamConfig, + }); + + if (listError) { + printFailure(context, listError.message); + process.exit(1); + } + + if (!listData.policies || listData.policies.length === 0) { + printEmpty(context); + return; + } + + const { selected } = await prompt<{ selected: string }>({ + type: 'select', + name: 'selected', + message: 'Select a policy to edit:', + choices: listData.policies.map((p) => ({ + name: p.resource, + message: `${p.name} (${p.resource})`, + })), + }); + + resource = selected; + } + + // Get document content (optional if only updating description) + let newDocument: PolicyDocument | undefined; + + if (documentArg) { + // Check if it's a file path or inline JSON + let documentJson: string; + if (existsSync(documentArg)) { + documentJson = readFileSync(documentArg, 'utf-8'); + } else { + // Assume it's inline JSON + documentJson = documentArg; + } + try { + newDocument = parseDocument(documentJson); + } catch { + printFailure(context, 'Invalid JSON in policy document'); + process.exit(1); + } + } else if (!process.stdin.isTTY && !description) { + // Read from stdin only if no description provided (description-only update doesn't need stdin) + const documentJson = await readStdin(); + try { + newDocument = parseDocument(documentJson); + } catch { + printFailure(context, 'Invalid JSON in policy document'); + process.exit(1); + } + } + + if (!newDocument && !description) { + printFailure(context, 'Either --document or --description is required.'); + process.exit(1); + } + + // Fetch existing policy to fill in missing values + const { data: existingPolicy, error: getError } = await getPolicy(resource, { + config: iamConfig, + }); + + if (getError) { + printFailure(context, getError.message); + process.exit(1); + } + + const { data, error } = await editPolicy(resource, { + document: newDocument ?? existingPolicy.document, + description: description ?? existingPolicy.description, + config: iamConfig, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + printSuccess(context, { resource: data.resource }); +} diff --git a/src/lib/iam/policies/get.ts b/src/lib/iam/policies/get.ts new file mode 100644 index 0000000..a88a86f --- /dev/null +++ b/src/lib/iam/policies/get.ts @@ -0,0 +1,141 @@ +import enquirer from 'enquirer'; +const { prompt } = enquirer; +import { getOption } from '../../../utils/options.js'; +import { formatOutput } from '../../../utils/format.js'; +import { getLoginMethod } from '../../../auth/s3-client.js'; +import { getAuthClient } from '../../../auth/client.js'; +import { getSelectedOrganization } from '../../../auth/storage.js'; +import { getTigrisConfig } from '../../../auth/config.js'; +import { getPolicy, listPolicies } from '@tigrisdata/iam'; +import { + printStart, + printSuccess, + printFailure, + printEmpty, + msg, +} from '../../../utils/messages.js'; + +const context = msg('iam policies', 'get'); + +export default async function get(options: Record) { + printStart(context); + + let resource = getOption(options, ['resource']); + const format = getOption(options, ['format', 'f', 'F'], 'table'); + + const loginMethod = await getLoginMethod(); + + if (loginMethod !== 'oauth') { + printFailure( + context, + 'Policies can only be retrieved when logged in via OAuth.\nRun "tigris login oauth" first.' + ); + process.exit(1); + } + + const authClient = getAuthClient(); + const isAuthenticated = await authClient.isAuthenticated(); + + if (!isAuthenticated) { + printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); + process.exit(1); + } + + const accessToken = await authClient.getAccessToken(); + const selectedOrg = getSelectedOrganization(); + const tigrisConfig = getTigrisConfig(); + + const iamConfig = { + sessionToken: accessToken, + organizationId: selectedOrg ?? undefined, + iamEndpoint: tigrisConfig.iamEndpoint, + }; + + // If no resource provided, list policies and let user select + if (!resource) { + const { data: listData, error: listError } = await listPolicies({ + config: iamConfig, + }); + + if (listError) { + printFailure(context, listError.message); + process.exit(1); + } + + if (!listData.policies || listData.policies.length === 0) { + printEmpty(context); + return; + } + + const { selected } = await prompt<{ selected: string }>({ + type: 'select', + name: 'selected', + message: 'Select a policy:', + choices: listData.policies.map((p) => ({ + name: p.resource, + message: `${p.name} (${p.resource})`, + })), + }); + + resource = selected; + } + + const { data, error } = await getPolicy(resource, { + config: iamConfig, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + if (format === 'json') { + console.log(JSON.stringify(data, null, 2)); + printSuccess(context); + return; + } + + // Display policy info + const info = [ + { field: 'Name', value: data.name }, + { field: 'ID', value: data.id }, + { field: 'Resource', value: data.resource }, + { field: 'Description', value: data.description || '-' }, + { field: 'Path', value: data.path }, + { field: 'Version', value: data.defaultVersionId }, + { field: 'Attachments', value: String(data.attachmentCount) }, + { field: 'Created', value: data.createDate.toISOString() }, + { field: 'Updated', value: data.updateDate.toISOString() }, + ]; + + const infoOutput = formatOutput(info, format!, 'policy', 'field', [ + { key: 'field', header: 'Field' }, + { key: 'value', header: 'Value' }, + ]); + console.log(infoOutput); + + // Display attached users + if (data.users && data.users.length > 0) { + console.log('Attached Users:'); + for (const user of data.users) { + console.log(` - ${user}`); + } + console.log(); + } + + // Display policy document + console.log('Policy Document:'); + console.log(` Version: ${data.document.version}`); + console.log(' Statements:'); + for (const stmt of data.document.statements) { + console.log(` - Effect: ${stmt.effect}`); + console.log( + ` Action: ${Array.isArray(stmt.action) ? stmt.action.join(', ') : stmt.action}` + ); + console.log( + ` Resource: ${Array.isArray(stmt.resource) ? stmt.resource.join(', ') : stmt.resource}` + ); + } + + printSuccess(context); +} diff --git a/src/lib/iam/policies/list.ts b/src/lib/iam/policies/list.ts new file mode 100644 index 0000000..da5afed --- /dev/null +++ b/src/lib/iam/policies/list.ts @@ -0,0 +1,85 @@ +import { getOption } from '../../../utils/options.js'; +import { formatOutput } from '../../../utils/format.js'; +import { getLoginMethod } from '../../../auth/s3-client.js'; +import { getAuthClient } from '../../../auth/client.js'; +import { getSelectedOrganization } from '../../../auth/storage.js'; +import { getTigrisConfig } from '../../../auth/config.js'; +import { listPolicies } from '@tigrisdata/iam'; +import { + printStart, + printSuccess, + printFailure, + printEmpty, + msg, +} from '../../../utils/messages.js'; + +const context = msg('iam policies', 'list'); + +export default async function list(options: Record) { + printStart(context); + + const format = getOption(options, ['format', 'f', 'F'], 'table'); + + const loginMethod = await getLoginMethod(); + + if (loginMethod !== 'oauth') { + printFailure( + context, + 'Policies can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.' + ); + process.exit(1); + } + + const authClient = getAuthClient(); + const isAuthenticated = await authClient.isAuthenticated(); + + if (!isAuthenticated) { + printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); + process.exit(1); + } + + const accessToken = await authClient.getAccessToken(); + const selectedOrg = getSelectedOrganization(); + const tigrisConfig = getTigrisConfig(); + + const { data, error } = await listPolicies({ + config: { + sessionToken: accessToken, + organizationId: selectedOrg ?? undefined, + iamEndpoint: tigrisConfig.iamEndpoint, + }, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + if (!data.policies || data.policies.length === 0) { + printEmpty(context); + return; + } + + const policies = data.policies.map((policy) => ({ + name: policy.name, + id: policy.id, + resource: policy.resource, + description: policy.description || '-', + attachments: policy.attachmentCount, + created: policy.createDate, + updated: policy.updateDate, + })); + + const output = formatOutput(policies, format!, 'policies', 'policy', [ + { key: 'id', header: 'ID' }, + { key: 'resource', header: 'Resource' }, + { key: 'name', header: 'Name' }, + { key: 'description', header: 'Description' }, + { key: 'attachments', header: 'Attachments' }, + { key: 'created', header: 'Created' }, + { key: 'updated', header: 'Updated' }, + ]); + + console.log(output); + printSuccess(context, { count: policies.length }); +} diff --git a/src/lib/iam/policies/utils.ts b/src/lib/iam/policies/utils.ts new file mode 100644 index 0000000..0e206b7 --- /dev/null +++ b/src/lib/iam/policies/utils.ts @@ -0,0 +1,30 @@ +import type { PolicyDocument } from '@tigrisdata/iam'; + +export async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf-8'); +} + +export function parseDocument(jsonString: string): PolicyDocument { + const raw = JSON.parse(jsonString); + return { + version: raw.Version, + statements: (Array.isArray(raw.Statement) + ? raw.Statement + : [raw.Statement] + ).map( + (s: { + Effect: string; + Action: string | string[]; + Resource: string | string[]; + }) => ({ + effect: s.Effect, + action: s.Action, + resource: s.Resource, + }) + ), + }; +} diff --git a/src/lib/objects/set.ts b/src/lib/objects/set.ts new file mode 100644 index 0000000..84df854 --- /dev/null +++ b/src/lib/objects/set.ts @@ -0,0 +1,53 @@ +import { getStorageConfig } from '../../auth/s3-client.js'; +import { getOption } from '../../utils/options.js'; +import { + printStart, + printSuccess, + printFailure, + msg, +} from '../../utils/messages.js'; +import { updateObject } from '@tigrisdata/storage'; + +const context = msg('objects', 'set'); + +export default async function setObject(options: Record) { + printStart(context); + + const bucket = getOption(options, ['bucket']); + const key = getOption(options, ['key']); + const access = getOption(options, ['access', 'a', 'A']); + const newKey = getOption(options, ['new-key', 'n', 'newKey']); + + if (!bucket) { + printFailure(context, 'Bucket name is required'); + process.exit(1); + } + + if (!key) { + printFailure(context, 'Object key is required'); + process.exit(1); + } + + if (!access) { + printFailure(context, 'Access level is required (--access public|private)'); + process.exit(1); + } + + const config = await getStorageConfig(); + + const { error } = await updateObject(key, { + access: access === 'public' ? 'public' : 'private', + ...(newKey && { key: newKey }), + config: { + ...config, + bucket, + }, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + printSuccess(context, { key, bucket }); +} diff --git a/src/lib/stat.ts b/src/lib/stat.ts new file mode 100644 index 0000000..4f4a959 --- /dev/null +++ b/src/lib/stat.ts @@ -0,0 +1,156 @@ +import { parseAnyPath } from '../utils/path.js'; +import { formatOutput, formatSize } from '../utils/format.js'; +import { getStorageConfig } from '../auth/s3-client.js'; +import { getStats, getBucketInfo, head } from '@tigrisdata/storage'; +import { + printStart, + printSuccess, + printFailure, + msg, +} from '../utils/messages.js'; + +const context = msg('stat'); + +export default async function stat(options: { + path?: string; + format?: string; + _positional?: string[]; +}) { + printStart(context); + + const pathString = options.path || options._positional?.[0]; + const format = options.format || 'table'; + const config = await getStorageConfig(); + + // No path: show overall stats + if (!pathString) { + const { data, error } = await getStats({ config }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + const stats = [ + { metric: 'Active Buckets', value: String(data.stats.activeBuckets) }, + { metric: 'Total Objects', value: String(data.stats.totalObjects) }, + { + metric: 'Total Unique Objects', + value: String(data.stats.totalUniqueObjects), + }, + { + metric: 'Total Storage', + value: formatSize(data.stats.totalStorageBytes), + }, + ]; + + const output = formatOutput(stats, format, 'stats', 'stat', [ + { key: 'metric', header: 'Metric' }, + { key: 'value', header: 'Value' }, + ]); + + console.log(output); + printSuccess(context); + process.exit(0); + } + + const { bucket, path } = parseAnyPath(pathString); + + if (!bucket) { + printFailure(context, 'Invalid path'); + process.exit(1); + } + + // Bucket only (no path or just trailing slash): show bucket info + if (!path || path === '/') { + const { data, error } = await getBucketInfo(bucket, { config }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + const info = [ + { + metric: 'Number of Objects', + value: data.sizeInfo.numberOfObjects?.toString() ?? 'N/A', + }, + { + metric: 'Total Size', + value: + data.sizeInfo.size !== undefined + ? formatSize(data.sizeInfo.size) + : 'N/A', + }, + { + metric: 'All Versions Count', + value: data.sizeInfo.numberOfObjectsAllVersions?.toString() ?? 'N/A', + }, + { + metric: 'Snapshots Enabled', + value: data.isSnapshotEnabled ? 'Yes' : 'No', + }, + { metric: 'Default Tier', value: data.settings.defaultTier }, + { + metric: 'Allow Object ACL', + value: data.settings.allowObjectAcl ? 'Yes' : 'No', + }, + { metric: 'Has Forks', value: data.forkInfo?.hasChildren ? 'Yes' : 'No' }, + ]; + + if (data.forkInfo?.parents?.length) { + info.push({ + metric: 'Forked From', + value: data.forkInfo.parents[0].bucketName, + }); + info.push({ + metric: 'Fork Snapshot', + value: data.forkInfo.parents[0].snapshot, + }); + } + + const output = formatOutput(info, format, 'bucket-info', 'info', [ + { key: 'metric', header: 'Metric' }, + { key: 'value', header: 'Value' }, + ]); + + console.log(output); + printSuccess(context, { bucket }); + process.exit(0); + } + + // Object path: show object metadata + const { data, error } = await head(path, { + config: { + ...config, + bucket, + }, + }); + + if (error) { + printFailure(context, error.message); + process.exit(1); + } + + if (!data) { + printFailure(context, 'Object not found'); + process.exit(1); + } + + const info = [ + { metric: 'Path', value: data.path }, + { metric: 'Size', value: formatSize(data.size) }, + { metric: 'Content-Type', value: data.contentType || 'N/A' }, + { metric: 'Content-Disposition', value: data.contentDisposition || 'N/A' }, + { metric: 'Modified', value: data.modified.toISOString() }, + ]; + + const output = formatOutput(info, format, 'object-info', 'info', [ + { key: 'metric', header: 'Metric' }, + { key: 'value', header: 'Value' }, + ]); + + console.log(output); + printSuccess(context, { bucket, path }); + process.exit(0); +} diff --git a/src/specs.yaml b/src/specs.yaml index 5507308..39d1be0 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -123,7 +123,7 @@ commands: description: Secret for temporary credentials alias: secret required: false - operations: + commands: # login (main interactive operation) - name: select description: Choose how to login - OAuth (browser) or credentials (access key) @@ -194,7 +194,7 @@ commands: examples: - "tigris credentials test" - "tigris credentials test --bucket my-bucket" - operations: + commands: - name: test description: Verify that current credentials are valid. Optionally checks access to a specific bucket alias: t @@ -277,18 +277,29 @@ commands: # stat - name: stat - description: Show metadata (size, last-modified, content-type, storage tier) for a bucket, folder, or object + description: Show storage stats (no args), bucket info, or object metadata + examples: + - "tigris stat" + - "tigris stat t3://my-bucket" + - "tigris stat t3://my-bucket/my-object.json" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to get stats' arguments: - name: path - required: true + required: false type: positional - description: A bucket name, folder path, or object key. Supports t3:// and tigris:// prefixes + description: Optional bucket or object path. Without it, shows overall storage stats examples: - - my-bucket - - my-bucket/my-path - t3://my-bucket - t3://my-bucket/my-path - - my-bucket/my-path/my-object.json + - t3://my-bucket/my-object.json + - name: format + description: Output format + alias: f + options: [json, table, xml] + default: table # cp - name: cp @@ -401,7 +412,7 @@ commands: - "tigris orgs list" - "tigris orgs create my-org" - "tigris orgs select my-org" - operations: + commands: # list - name: list description: List all organizations you belong to and interactively select one as active @@ -469,7 +480,7 @@ commands: - "tigris buckets create my-bucket" - "tigris buckets get my-bucket" - "tigris buckets delete my-bucket" - operations: + commands: # list - name: list description: List all buckets in the current organization @@ -617,7 +628,7 @@ commands: examples: - "tigris forks list my-bucket" - "tigris forks create my-bucket my-fork" - operations: + commands: # list - name: list description: List all forks created from the given source bucket @@ -679,7 +690,7 @@ commands: examples: - "tigris snapshots list my-bucket" - "tigris snapshots take my-bucket" - operations: + commands: # list - name: list description: List all snapshots for the given bucket, ordered by creation time @@ -740,7 +751,7 @@ commands: - "tigris objects get my-bucket report.pdf --output ./report.pdf" - "tigris objects put my-bucket report.pdf ./report.pdf" - "tigris objects delete my-bucket report.pdf" - operations: + commands: # list - name: list description: List objects in a bucket, optionally filtered by a key prefix @@ -867,6 +878,34 @@ commands: multiple: true examples: - my-file.txt + # set + - name: set + description: Update settings on an existing object such as access level + alias: s + examples: + - "tigris objects set my-bucket my-file.txt --access public" + - "tigris objects set my-bucket my-file.txt --access private" + messages: + onStart: 'Updating object...' + onSuccess: "Object '{{key}}' updated successfully" + onFailure: 'Failed to update object' + arguments: + - name: bucket + description: Name of the bucket + type: positional + required: true + - name: key + description: Key of the object + type: positional + required: true + - name: access + description: Access level + alias: a + options: *access_options + required: true + - name: new-key + description: Rename the object to a new key + alias: n ######################### # Manage access keys @@ -878,7 +917,7 @@ commands: - "tigris access-keys list" - "tigris access-keys create my-ci-key" - "tigris access-keys assign tid_AaBb --bucket my-bucket --role Editor" - operations: + commands: - name: list description: List all access keys in the current organization alias: l @@ -973,3 +1012,128 @@ commands: - name: revoke-roles description: Revoke all bucket roles from the access key type: flag + + ######################### + # IAM - Identity and Access Management + ######################### + - name: iam + description: Identity and Access Management - manage policies, roles, and permissions + examples: + - "tigris iam policies list" + - "tigris iam policies get arn:aws:iam::org_id:policy/my-policy" + - "tigris iam policies create my-policy --document policy.json" + commands: + - name: policies + description: Manage IAM policies. Policies define permissions for access keys + alias: p + examples: + - "tigris iam policies list" + - "tigris iam policies get" + commands: + - name: list + description: List all policies in the current organization + alias: l + examples: + - "tigris iam policies list" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to list policies' + onEmpty: 'No policies found' + arguments: + - name: format + description: Output format + alias: f + options: [json, table, xml] + default: table + - name: get + description: Show details for a policy including its document and attached users. If no ARN provided, shows interactive selection + alias: g + examples: + - "tigris iam policies get" + - "tigris iam policies get arn:aws:iam::org_id:policy/my-policy" + messages: + onStart: '' + onSuccess: '' + onFailure: 'Failed to get policy' + onEmpty: 'No policies found' + arguments: + - name: resource + description: Policy ARN. If omitted, shows interactive selection + type: positional + required: false + examples: + - arn:aws:iam::org_id:policy/my-policy + - name: format + description: Output format + alias: f + options: [json, table, xml] + default: table + - name: create + description: Create a new policy with the given name and policy document. Document can be provided via file, inline JSON, or stdin + alias: c + examples: + - "tigris iam policies create my-policy --document policy.json" + - "tigris iam policies create my-policy --document '{\"Version\":\"2012-10-17\",\"Statement\":[...]}'" + - "cat policy.json | tigris iam policies create my-policy" + messages: + onStart: 'Creating policy...' + onSuccess: "Policy '{{name}}' created" + onFailure: 'Failed to create policy' + arguments: + - name: name + description: Policy name + type: positional + required: true + examples: + - my-policy + - name: document + description: Policy document (JSON file path or inline JSON). If omitted, reads from stdin + alias: d + required: false + - name: description + description: Policy description + - name: edit + description: Update an existing policy's document. Document can be provided via file, inline JSON, or stdin. If no ARN provided, shows interactive selection + alias: e + examples: + - "tigris iam policies edit --document policy.json" + - "tigris iam policies edit arn:aws:iam::org_id:policy/my-policy --document policy.json" + - "cat policy.json | tigris iam policies edit arn:aws:iam::org_id:policy/my-policy" + messages: + onStart: 'Updating policy...' + onSuccess: "Policy '{{resource}}' updated" + onFailure: 'Failed to update policy' + onEmpty: 'No policies found' + arguments: + - name: resource + description: Policy ARN. If omitted, shows interactive selection + type: positional + required: false + examples: + - arn:aws:iam::org_id:policy/my-policy + - name: document + description: New policy document (JSON file path or inline JSON). If omitted, reads from stdin + alias: d + required: false + - name: description + description: Update policy description + required: false + - name: delete + description: Delete a policy. If no ARN provided, shows interactive selection + alias: d + examples: + - "tigris iam policies delete" + - "tigris iam policies delete arn:aws:iam::org_id:policy/my-policy" + messages: + onStart: 'Deleting policy...' + onSuccess: "Policy '{{resource}}' deleted" + onFailure: 'Failed to delete policy' + onEmpty: 'No policies found' + arguments: + - name: resource + description: Policy ARN. If omitted, shows interactive selection + type: positional + required: false + examples: + - arn:aws:iam::org_id:policy/my-policy diff --git a/src/types.ts b/src/types.ts index ee2fcb5..040851b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,28 +22,22 @@ export interface Messages { hint?: string; } -export interface OperationSpec { - name: string; - description: string; - alias?: string | string[]; - arguments?: Argument[]; - examples?: string[]; - message?: string; - messages?: Messages; -} - +// Recursive command structure - supports nth level nesting export interface CommandSpec { name: string; description: string; - alias?: string; + alias?: string | string[]; arguments?: Argument[]; examples?: string[]; - operations?: OperationSpec[]; + commands?: CommandSpec[]; // recursive - can nest infinitely default?: string; message?: string; messages?: Messages; } +// Backwards compatibility alias +export type OperationSpec = CommandSpec; + export interface Specs { name: string; description: string; diff --git a/src/utils/format.ts b/src/utils/format.ts index e45ac16..a230828 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -66,9 +66,18 @@ export interface TableColumn { key: string; header: string; width?: number; + maxWidth?: number; align?: 'left' | 'right'; } +/** + * Truncate string with ellipsis if it exceeds maxLength + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength - 1) + '…'; +} + /** * Format a date value to a readable string */ @@ -103,13 +112,19 @@ function formatDate(date: Date): string { } /** - * Calculate column widths based on content + * Calculate column widths based on content and terminal size + * Prioritizes shrinking wider columns first to preserve readability of shorter ones */ function calculateColumnWidths>( items: T[], columns: TableColumn[] ): number[] { - return columns.map((col) => { + const terminalWidth = process.stdout.columns || 120; + // Account for borders: '│ ' + cells.join(' │ ') + ' │' = 2 + 3(n-1) + 2 = 3n + 1 + const borderOverhead = columns.length * 3 + 1; + const availableWidth = terminalWidth - borderOverhead; + + const rawWidths = columns.map((col) => { // If width is explicitly set, use it if (col.width) { return col.width; @@ -122,8 +137,45 @@ function calculateColumnWidths>( return Math.max(max, value.length); }, 0); - return Math.max(headerWidth, maxValueWidth); + let width = Math.max(headerWidth, maxValueWidth); + + // Apply maxWidth constraint + if (col.maxWidth && width > col.maxWidth) { + width = col.maxWidth; + } + + return width; }); + + // If total width exceeds terminal, shrink widest columns first + let totalWidth = rawWidths.reduce((sum, w) => sum + w, 0); + if (totalWidth <= availableWidth) { + return rawWidths; + } + + const finalWidths = [...rawWidths]; + const minWidths = columns.map((col) => Math.max(col.header.length, 8)); + + // Keep shrinking until we fit + while (totalWidth > availableWidth) { + // Find the widest column that can still be shrunk + let maxIdx = -1; + let maxWidth = 0; + for (let i = 0; i < finalWidths.length; i++) { + if (finalWidths[i] > minWidths[i] && finalWidths[i] > maxWidth) { + maxWidth = finalWidths[i]; + maxIdx = i; + } + } + + if (maxIdx === -1) break; // Can't shrink anymore + + // Shrink the widest column by 1 + finalWidths[maxIdx]--; + totalWidth--; + } + + return finalWidths; } /** @@ -151,7 +203,9 @@ export function formatTable>( // Header row const headerRow = '│ ' + - columns.map((col, i) => col.header.padEnd(widths[i])).join(' │ ') + + columns + .map((col, i) => truncate(col.header, widths[i]).padEnd(widths[i])) + .join(' │ ') + ' │'; lines.push(headerRow); @@ -161,7 +215,7 @@ export function formatTable>( // Data rows items.forEach((item) => { const cells = columns.map((col, i) => { - const value = formatCellValue(item[col.key]); + const value = truncate(formatCellValue(item[col.key]), widths[i]); return col.align === 'right' ? value.padStart(widths[i]) : value.padEnd(widths[i]); diff --git a/src/utils/specs.ts b/src/utils/specs.ts index 643bce5..16d8ac0 100644 --- a/src/utils/specs.ts +++ b/src/utils/specs.ts @@ -20,27 +20,39 @@ export function loadSpecs(): Specs { } export function getCommandSpec( - commandName: string, + commandPath: string, operationName?: string ): OperationSpec | CommandSpec | null { const specs = loadSpecs(); - const command = specs.commands.find( - (cmd: CommandSpec) => cmd.name === commandName - ); - if (!command) { + // Split command path for nested commands (e.g., "iam policies" -> ["iam", "policies"]) + const pathParts = commandPath.split(' ').filter(Boolean); + + // Traverse the command hierarchy + let current: CommandSpec | undefined; + let commands: CommandSpec[] = specs.commands; + + for (const part of pathParts) { + current = commands.find((cmd: CommandSpec) => cmd.name === part); + if (!current) { + return null; + } + commands = current.commands || []; + } + + if (!current) { return null; } - if (operationName && command.operations) { + // If operation specified, find it in the current command's children + if (operationName && current.commands) { return ( - command.operations.find( - (op: OperationSpec) => op.name === operationName - ) || null + current.commands.find((cmd: CommandSpec) => cmd.name === operationName) || + null ); } - return command; + return current; } export function getArgumentSpec( diff --git a/test/cli.test.ts b/test/cli.test.ts index 6ac7933..ae32053 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -101,10 +101,51 @@ describe('CLI Help Commands', () => { expect(result.stdout).toContain('--force'); }); + it('should show mk help', () => { + const result = runCli('mk help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('mk'); + expect(result.stdout).toContain('path'); + }); + + it('should show touch help', () => { + const result = runCli('touch help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('touch'); + expect(result.stdout).toContain('path'); + }); + + it('should show stat help', () => { + const result = runCli('stat help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('stat'); + expect(result.stdout).toContain('path'); + }); + + it('should show configure help', () => { + const result = runCli('configure help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('configure'); + expect(result.stdout).toContain('--access-key'); + }); + + it('should show login help', () => { + const result = runCli('login help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('login'); + expect(result.stdout).toContain('Commands:'); + }); + + it('should show whoami help', () => { + const result = runCli('whoami help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('whoami'); + }); + it('should show buckets help', () => { const result = runCli('buckets help'); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Operations:'); + expect(result.stdout).toContain('Commands:'); expect(result.stdout).toContain('list'); expect(result.stdout).toContain('create'); }); @@ -112,11 +153,83 @@ describe('CLI Help Commands', () => { it('should show objects help', () => { const result = runCli('objects help'); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('Operations:'); + expect(result.stdout).toContain('Commands:'); expect(result.stdout).toContain('list'); expect(result.stdout).toContain('get'); expect(result.stdout).toContain('put'); }); + + it('should show organizations help', () => { + const result = runCli('organizations help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('create'); + }); + + it('should show orgs alias help', () => { + const result = runCli('orgs help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + }); + + it('should show forks help', () => { + const result = runCli('forks help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('create'); + }); + + it('should show snapshots help', () => { + const result = runCli('snapshots help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('take'); + }); + + it('should show access-keys help', () => { + const result = runCli('access-keys help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('create'); + expect(result.stdout).toContain('delete'); + }); + + // Nested command tests (iam policies) + it('should show iam help', () => { + const result = runCli('iam help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('policies'); + }); + + it('should show iam policies help', () => { + const result = runCli('iam policies help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('get'); + expect(result.stdout).toContain('create'); + expect(result.stdout).toContain('edit'); + expect(result.stdout).toContain('delete'); + }); + + it('should show iam policies list help', () => { + const result = runCli('iam policies list help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('list'); + expect(result.stdout).toContain('--format'); + }); + + it('should support iam alias', () => { + const result = runCli('iam p help'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Commands:'); + expect(result.stdout).toContain('list'); + }); }); describe.skipIf(skipTests)('CLI Integration Tests', () => { diff --git a/test/utils/specs.test.ts b/test/utils/specs.test.ts new file mode 100644 index 0000000..fbfa961 --- /dev/null +++ b/test/utils/specs.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { getCommandSpec, getArgumentSpec } from '../../dist/utils/specs.js'; + +describe('getCommandSpec', () => { + describe('top-level commands', () => { + it('should find top-level command', () => { + const spec = getCommandSpec('buckets'); + expect(spec).not.toBeNull(); + expect(spec?.name).toBe('buckets'); + }); + + it('should find operation within top-level command', () => { + const spec = getCommandSpec('buckets', 'list'); + expect(spec).not.toBeNull(); + expect(spec?.name).toBe('list'); + expect(spec?.messages).toBeDefined(); + }); + + it('should return null for non-existent command', () => { + const spec = getCommandSpec('nonexistent'); + expect(spec).toBeNull(); + }); + + it('should return null for non-existent operation', () => { + const spec = getCommandSpec('buckets', 'nonexistent'); + expect(spec).toBeNull(); + }); + }); + + describe('nested commands (space-separated path)', () => { + it('should find nested command via space-separated path', () => { + const spec = getCommandSpec('iam policies'); + expect(spec).not.toBeNull(); + expect(spec?.name).toBe('policies'); + }); + + it('should find operation within nested command', () => { + const spec = getCommandSpec('iam policies', 'list'); + expect(spec).not.toBeNull(); + expect(spec?.name).toBe('list'); + expect(spec?.messages).toBeDefined(); + expect(spec?.messages?.onFailure).toBe('Failed to list policies'); + }); + + it('should find all iam policies operations', () => { + const operations = ['list', 'get', 'create', 'edit', 'delete']; + for (const op of operations) { + const spec = getCommandSpec('iam policies', op); + expect(spec, `${op} should exist`).not.toBeNull(); + expect(spec?.name).toBe(op); + } + }); + + it('should return null for invalid nested path', () => { + const spec = getCommandSpec('iam nonexistent'); + expect(spec).toBeNull(); + }); + + it('should return null for non-existent operation in nested command', () => { + const spec = getCommandSpec('iam policies', 'nonexistent'); + expect(spec).toBeNull(); + }); + }); + + describe('message resolution', () => { + it('should resolve messages for top-level command operations', () => { + const spec = getCommandSpec('buckets', 'create'); + expect(spec?.messages).toBeDefined(); + expect(spec?.messages?.onStart).toBe('Creating bucket...'); + }); + + it('should resolve messages for nested command operations', () => { + const spec = getCommandSpec('iam policies', 'create'); + expect(spec?.messages).toBeDefined(); + expect(spec?.messages?.onStart).toBe('Creating policy...'); + expect(spec?.messages?.onSuccess).toBe("Policy '{{name}}' created"); + }); + + it('should resolve messages for nested command delete', () => { + const spec = getCommandSpec('iam policies', 'delete'); + expect(spec?.messages).toBeDefined(); + expect(spec?.messages?.onSuccess).toBe("Policy '{{resource}}' deleted"); + }); + }); +}); + +describe('getArgumentSpec', () => { + it('should find argument in top-level command', () => { + const arg = getArgumentSpec('buckets', 'format', 'list'); + expect(arg).not.toBeNull(); + expect(arg?.name).toBe('format'); + }); + + it('should return null for non-existent argument', () => { + const arg = getArgumentSpec('buckets', 'nonexistent', 'list'); + expect(arg).toBeNull(); + }); +}); + +console.log('Tests completed');