Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,12 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
},
overrides: [
{
files: ['**/*.test.ts'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
],
};
387 changes: 39 additions & 348 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"dependencies": {
"@types/pg": "^8.11.10",
"axios": "^1.8.4",
"axios-retry": "^4.5.0",
"chalk": "^4.1.2",
"cli-progress": "^3.12.0",
"cli-table3": "^0.6.5",
Expand Down
14 changes: 9 additions & 5 deletions src/commands/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import { GlobalOptions, getGlobalOptionsHelp } from '../lib/globalOptions';
import { spawn } from 'child_process';
import Table from 'cli-table3';
import { handleDatabaseError, forceRelogin } from '../lib/errorHandling';
import { validateDatabaseName, validateTableName, assertValid } from '../lib/validation';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { Client } from 'pg';
import { from as copyFrom } from 'pg-copy-streams';
import format from 'pg-format';
import { SingleBar, Presets } from 'cli-progress';

Expand Down Expand Up @@ -227,6 +225,9 @@ ${getGlobalOptionsHelp()}`);
.requiredOption('--region <region>', 'Database region')
.action(async (options: { name: string; region: string }) => {
try {
const dbNameValidation = validateDatabaseName(options.name);
assertValid(dbNameValidation);

const globalOptions = getOptions();
const configManager = new ConfigManager(globalOptions);
let token = configManager.getToken();
Expand Down Expand Up @@ -254,7 +255,7 @@ ${getGlobalOptionsHelp()}`);
'3. NILE_WORKSPACE environment variable');
}

const database = await api.createDatabase(workspaceSlug, options.name, options.region);
const database = await api.createDatabase(workspaceSlug, options.name, options.region.toUpperCase());

if (globalOptions.format === 'json') {
console.log(JSON.stringify(database, null, 2));
Expand Down Expand Up @@ -552,6 +553,9 @@ ${getGlobalOptionsHelp()}`);
.option('--delimiter <char>', 'Column delimiter character')
.action(async (cmdOptions) => {
try {
const tableValidation = validateTableName(cmdOptions.tableName);
assertValid(tableValidation);

const options = getOptions();
const configManager = new ConfigManager(options);
const workspaceSlug = configManager.getWorkspace();
Expand Down Expand Up @@ -745,4 +749,4 @@ function formatStatus(status: string): string {
default:
return theme.info(status);
}
}
}
10 changes: 5 additions & 5 deletions src/commands/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function isDockerRunning(): Promise<boolean> {
try {
await execAsync('docker info');
return true;
} catch (error: any) {
} catch {
return false;
}
}
Expand Down Expand Up @@ -182,7 +182,7 @@ ${getGlobalOptionsHelp()}`);
console.log(theme.dim('To stop it, use: docker stop nile-local'));
process.exit(1);
}
} catch (error) {
} catch {
// Ignore error, means docker ps failed which is fine
}

Expand Down Expand Up @@ -247,7 +247,7 @@ ${getGlobalOptionsHelp()}`);
console.log(theme.dim('\nStopping container...'));
try {
await execAsync('docker stop nile-local && docker rm nile-local');
} catch (error) {
} catch {
// Ignore cleanup errors
}
process.exit(1);
Expand Down Expand Up @@ -336,7 +336,7 @@ ${getGlobalOptionsHelp()}`);
try {
await execAsync('docker stop nile-local && docker rm nile-local');
console.log(theme.success('Local environment stopped successfully'));
} catch (error) {
} catch {
console.error(theme.error('Failed to stop local environment cleanly'));
}
process.exit(0);
Expand All @@ -352,7 +352,7 @@ ${getGlobalOptionsHelp()}`);
// Cleanup on error
try {
await execAsync('docker stop nile-local && docker rm nile-local');
} catch (cleanupError) {
} catch {
// Ignore cleanup errors
}
process.exit(1);
Expand Down
9 changes: 9 additions & 0 deletions src/commands/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NileAPI } from '../lib/api';
import { theme, formatCommand } from '../lib/colors';
import { GlobalOptions, getGlobalOptionsHelp } from '../lib/globalOptions';
import { handleTenantError, forceRelogin } from '../lib/errorHandling';
import { validateTenantName, validateTenantId, assertValid } from '../lib/validation';
import Table from 'cli-table3';
import axios from 'axios';

Expand Down Expand Up @@ -214,6 +215,14 @@ Examples:
.action(async (cmdOptions) => {
let client: Client | undefined;
try {
const nameValidation = validateTenantName(cmdOptions.name);
assertValid(nameValidation);

if (cmdOptions.id) {
const idValidation = validateTenantId(cmdOptions.id);
assertValid(idValidation);
}

const options = getGlobalOptions();
const configManager = new ConfigManager(options);
const api = new NileAPI({
Expand Down
17 changes: 17 additions & 0 deletions src/commands/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { NileAPI } from '../lib/api';
import { theme, formatCommand } from '../lib/colors';
import { GlobalOptions, getGlobalOptionsHelp } from '../lib/globalOptions';
import { handleUserError, forceRelogin } from '../lib/errorHandling';
import { validateEmail, validatePassword, assertValid } from '../lib/validation';
import Table from 'cli-table3';
import axios from 'axios';

Expand Down Expand Up @@ -213,6 +214,12 @@ Examples:
.action(async (cmdOptions) => {
let client: Client | undefined;
try {
const emailValidation = validateEmail(cmdOptions.email);
assertValid(emailValidation);

const passwordValidation = validatePassword(cmdOptions.password);
assertValid(passwordValidation);

const options = getGlobalOptions();
const configManager = new ConfigManager(options);
const api = new NileAPI({
Expand Down Expand Up @@ -336,6 +343,16 @@ Examples:
.action(async (cmdOptions) => {
let client: Client | undefined;
try {
if (cmdOptions.new_email) {
const emailValidation = validateEmail(cmdOptions.new_email);
assertValid(emailValidation);
}

if (cmdOptions.new_password) {
const passwordValidation = validatePassword(cmdOptions.new_password);
assertValid(passwordValidation);
}

const options = getGlobalOptions();
const configManager = new ConfigManager(options);
const api = new NileAPI({
Expand Down
19 changes: 17 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from 'axios';
import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry';
import { Developer, Database, Credentials } from './types';
import { theme } from './colors';

Expand Down Expand Up @@ -110,7 +111,6 @@ export class NileAPI {
},
});

// Create user client for user operations
this.userClient = axios.create({
baseURL: this.userUrl,
headers: {
Expand All @@ -119,7 +119,22 @@ export class NileAPI {
},
});

// Add debug logging
const retryConfig = (client: AxiosInstance) => {
axiosRetry(client, {
retries: 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: isNetworkOrIdempotentRequestError,
onRetry: (retryCount, error, requestConfig) => {
if (this.debug) {
console.log(theme.dim(`Retry ${retryCount} for ${requestConfig.method?.toUpperCase()} ${requestConfig.url}`));
}
}
});
};

retryConfig(this.controlPlaneClient);
retryConfig(this.userClient);

this.addDebugLogging(this.controlPlaneClient);
this.addDebugLogging(this.userClient);

Expand Down
138 changes: 40 additions & 98 deletions src/lib/errorHandling.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import axios from 'axios';
import { theme } from './colors';
import { GlobalOptions } from './globalOptions';
import { ConfigManager } from './config';
import { Auth } from './auth';
import { NileAPI } from './api';

/**
* Forces a re-login when authentication fails
*/
export async function forceRelogin(configManager: ConfigManager): Promise<void> {
configManager.removeToken(); // Clear the invalid token
configManager.removeToken();

console.log(theme.warning('\nAuthentication failed. Forcing re-login...'));
const token = await Auth.getAuthorizationToken(configManager);
Expand All @@ -25,7 +21,6 @@ export async function forceRelogin(configManager: ConfigManager): Promise<void>
}
console.log(theme.success('Successfully re-authenticated!'));

// Verify workspace access after re-authentication
const workspaceSlug = configManager.getWorkspace();
if (workspaceSlug) {
try {
Expand All @@ -50,114 +45,61 @@ export async function forceRelogin(configManager: ConfigManager): Promise<void>
}
}

/**
* Handles API errors consistently across all commands
* @param error The error object
* @param operation Description of the operation that failed
* @param configManager The ConfigManager instance to use
*/
export async function handleApiError(error: any, operation: string, configManager: ConfigManager): Promise<never> {
export type ErrorContext = 'API' | 'Database' | 'Tenant' | 'User';

function getErrorPrefix(context: ErrorContext): string {
const prefixes: Record<ErrorContext, string> = {
API: 'Failed to',
Database: 'Database operation failed:',
Tenant: 'Tenant operation failed:',
User: 'User operation failed:'
};
return prefixes[context];
}

export async function handleError(
error: unknown,
context: ErrorContext,
operation: string,
configManager: ConfigManager
): Promise<never> {
const prefix = getErrorPrefix(context);
const fullOperation = context === 'API' ? `${prefix} ${operation}` : `${prefix} ${operation}`;

if (axios.isAxiosError(error)) {
if (error.response?.status === 401 || error.message === 'Token is required') {
await forceRelogin(configManager);
// Retry the operation after re-login
const token = configManager.getToken();
if (!token) {
throw new Error('Failed to get token after re-login');
}
throw error;
} else if (error.response?.data?.errors) {
console.error(theme.error(`Failed to ${operation}:`), new Error(error.response.data.errors.join(', ')));
} else if (configManager.getDebug()) {
console.error(theme.error(`Failed to ${operation}:`), error);
} else {
console.error(theme.error(`Failed to ${operation}:`), error.message || 'Unknown error');
}
} else if (configManager.getDebug()) {
console.error(theme.error(`Failed to ${operation}:`), error);
} else {
console.error(theme.error(`Failed to ${operation}:`), error instanceof Error ? error.message : 'Unknown error');
}
process.exit(1);
}

/**
* Handles database-specific errors
* @param error The error object
* @param operation Description of the operation that failed
* @param configManager The ConfigManager instance to use
*/
export async function handleDatabaseError(error: any, operation: string, configManager: ConfigManager): Promise<never> {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401 || error.message === 'Token is required') {
await forceRelogin(configManager);
// Retry the operation after re-login
throw error;
} else if (error.response?.data?.errors) {
console.error(theme.error(`Database operation failed: ${operation}`), new Error(error.response.data.errors.join(', ')));

if (error.response?.data?.errors) {
console.error(theme.error(fullOperation), new Error(error.response.data.errors.join(', ')));
} else if (configManager.getDebug()) {
console.error(theme.error(`Database operation failed: ${operation}`), error);
console.error(theme.error(fullOperation), error);
} else {
console.error(theme.error(`Database operation failed: ${operation}`), error.message || 'Unknown error');
console.error(theme.error(fullOperation), error.message || 'Unknown error');
}
} else if (configManager.getDebug()) {
console.error(theme.error(`Database operation failed: ${operation}`), error);
console.error(theme.error(fullOperation), error);
} else {
console.error(theme.error(`Database operation failed: ${operation}`), error instanceof Error ? error.message : 'Unknown error');
console.error(theme.error(fullOperation), error instanceof Error ? error.message : 'Unknown error');
}

process.exit(1);
}

/**
* Handles tenant-specific errors
* @param error The error object
* @param operation Description of the operation that failed
* @param configManager The ConfigManager instance to use
*/
export async function handleTenantError(error: any, operation: string, configManager: ConfigManager): Promise<never> {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401 || error.message === 'Token is required') {
await forceRelogin(configManager);
// Retry the operation after re-login
throw error;
} else if (error.response?.data?.errors) {
console.error(theme.error(`Tenant operation failed: ${operation}`), new Error(error.response.data.errors.join(', ')));
} else if (configManager.getDebug()) {
console.error(theme.error(`Tenant operation failed: ${operation}`), error);
} else {
console.error(theme.error(`Tenant operation failed: ${operation}`), error.message || 'Unknown error');
}
} else if (configManager.getDebug()) {
console.error(theme.error(`Tenant operation failed: ${operation}`), error);
} else {
console.error(theme.error(`Tenant operation failed: ${operation}`), error instanceof Error ? error.message : 'Unknown error');
}
process.exit(1);
}
export const handleApiError = (error: unknown, operation: string, configManager: ConfigManager) =>
handleError(error, 'API', operation, configManager);

/**
* Handles user-specific errors
* @param error The error object
* @param operation Description of the operation that failed
* @param configManager The ConfigManager instance to use
*/
export async function handleUserError(error: any, operation: string, configManager: ConfigManager): Promise<never> {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401 || error.message === 'Token is required') {
await forceRelogin(configManager);
// Retry the operation after re-login
throw error;
} else if (error.response?.data?.errors) {
console.error(theme.error(`User operation failed: ${operation}`), new Error(error.response.data.errors.join(', ')));
} else if (configManager.getDebug()) {
console.error(theme.error(`User operation failed: ${operation}`), error);
} else {
console.error(theme.error(`User operation failed: ${operation}`), error.message || 'Unknown error');
}
} else if (configManager.getDebug()) {
console.error(theme.error(`User operation failed: ${operation}`), error);
} else {
console.error(theme.error(`User operation failed: ${operation}`), error instanceof Error ? error.message : 'Unknown error');
}
process.exit(1);
}
export const handleDatabaseError = (error: unknown, operation: string, configManager: ConfigManager) =>
handleError(error, 'Database', operation, configManager);

export const handleTenantError = (error: unknown, operation: string, configManager: ConfigManager) =>
handleError(error, 'Tenant', operation, configManager);

export const handleUserError = (error: unknown, operation: string, configManager: ConfigManager) =>
handleError(error, 'User', operation, configManager);
Loading