diff --git a/package-lock.json b/package-lock.json index 0a0359a..9cdc9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cli-table3": "^0.6.5", "commander": "^11.0.0", "open": "^8.4.2", + "ora": "^5.4.1", "pg": "^8.13.1" }, "bin": { @@ -2679,6 +2680,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", @@ -2686,6 +2707,31 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -2772,6 +2818,30 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2879,6 +2949,18 @@ "node": ">=6" } }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -2942,6 +3024,18 @@ "node": ">=10" } }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-table3": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", @@ -2972,6 +3066,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3308,6 +3411,18 @@ "node": ">=0.10.0" } }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -4570,6 +4685,26 @@ "node": ">=10.17.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4691,7 +4826,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -4798,6 +4932,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5928,6 +6071,34 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6120,7 +6291,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -9064,7 +9234,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -9111,6 +9280,41 @@ "node": ">= 0.8.0" } }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-each-series": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-3.0.0.tgz", @@ -10113,6 +10317,19 @@ "node": ">=10" } }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -10169,7 +10386,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/semantic-release": { @@ -10526,7 +10742,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC" }, "node_modules/signale": { @@ -10783,7 +10998,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -11421,7 +11635,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -11467,6 +11680,15 @@ "makeerror": "1.0.12" } }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 7ac39aa..e8b0241 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "cli-table3": "^0.6.5", "commander": "^11.0.0", "open": "^8.4.2", + "ora": "^5.4.1", "pg": "^8.13.1" }, "devDependencies": { diff --git a/src/commands/local.ts b/src/commands/local.ts new file mode 100644 index 0000000..46ba4ca --- /dev/null +++ b/src/commands/local.ts @@ -0,0 +1,318 @@ +import { Command } from 'commander'; +import { theme, formatCommand } from '../lib/colors'; +import { GlobalOptions, getGlobalOptionsHelp } from '../lib/globalOptions'; +import { spawn, exec } from 'child_process'; +import readline from 'readline'; +import ora from 'ora'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +type GetOptions = () => GlobalOptions; + +async function waitForPostgres(getOptions: GetOptions, retries = 30, interval = 1000): Promise { + const command = 'docker exec nile-local pg_isready -U 00000000-0000-0000-0000-000000000000 -p 5432 -d test -h localhost'; + + for (let i = 0; i < retries; i++) { + try { + const { stdout } = await execAsync(command); + if (stdout.includes('accepting connections')) { + return true; + } + } catch (error: any) { + const options = getOptions(); + if (options.debug) { + console.log(theme.dim(`Waiting for PostgreSQL (attempt ${i + 1}/${retries})...`)); + if (error.stdout) console.log(theme.dim('stdout:'), error.stdout); + if (error.stderr) console.log(theme.dim('stderr:'), error.stderr); + } + // Wait before next attempt + await new Promise(resolve => setTimeout(resolve, interval)); + continue; + } + } + return false; +} + +export function createLocalCommand(getOptions: GetOptions): Command { + const local = new Command('local') + .description('Manage local development environment') + .addHelpText('after', ` +Examples: + ${formatCommand('nile local start')} Start local development environment + ${formatCommand('nile local start', '--no-prompt')} Start without prompting for psql connection + ${formatCommand('nile local stop')} Stop local development environment + ${formatCommand('nile local info')} Show connection information + +${getGlobalOptionsHelp()}`); + + local + .command('info') + .description('Show connection information for local environment') + .action(async () => { + try { + // Check if container is running + const { stdout } = await execAsync('docker ps --filter name=nile-local --format {{.Names}}'); + if (!stdout.includes('nile-local')) { + console.error(theme.error('\nNo Nile local environment is currently running.')); + console.log(theme.dim('Start it with: nile local start')); + process.exit(1); + } + + // Display connection information + console.log('\nConnection Information:'); + console.log(theme.info('Host: ') + 'localhost'); + console.log(theme.info('Port: ') + '5432'); + console.log(theme.info('Database: ') + 'test'); + console.log(theme.info('Username: ') + '00000000-0000-0000-0000-000000000000'); + console.log(theme.info('Password: ') + 'password'); + + // Show direct connection command in debug mode + const options = getOptions(); + if (options.debug) { + console.log(theme.dim('\nDirect connection command:')); + console.log(theme.dim('PGPASSWORD=password psql -h localhost -p 5432 -U 00000000-0000-0000-0000-000000000000 -d test')); + } + } catch (error: any) { + const options = getOptions(); + if (options.debug) { + console.error(theme.error('\nFailed to check environment status:'), error); + } else { + console.error(theme.error('\nFailed to check environment status:'), error.message || 'Unknown error'); + } + process.exit(1); + } + }); + + local + .command('stop') + .description('Stop local development environment') + .action(async () => { + try { + // Check if container is running + const { stdout } = await execAsync('docker ps --filter name=nile-local --format {{.Names}}'); + if (!stdout.includes('nile-local')) { + console.error(theme.error('\nNo Nile local environment is currently running.')); + process.exit(1); + } + + // Start spinner for stopping container + const stopSpinner = ora({ + text: 'Stopping local development environment...', + color: 'cyan' + }).start(); + + try { + await execAsync('docker stop nile-local && docker rm nile-local'); + stopSpinner.succeed('Local environment stopped successfully'); + } catch (error: any) { + stopSpinner.fail('Failed to stop local environment'); + if (getOptions().debug) { + console.error(theme.error('Error details:'), error.message); + } + process.exit(1); + } + } catch (error: any) { + const options = getOptions(); + if (options.debug) { + console.error(theme.error('Failed to check container status:'), error); + } else { + console.error(theme.error('Failed to check container status:'), error.message || 'Unknown error'); + } + process.exit(1); + } + }); + + local + .command('start') + .description('Start local development environment') + .option('--no-prompt', 'Start without prompting for psql connection') + .action(async (cmdOptions) => { + try { + // Check if container is already running + try { + const { stdout } = await execAsync('docker ps --filter name=nile-local --format {{.Names}}'); + if (stdout.includes('nile-local')) { + console.error(theme.error('\nA Nile local environment is already running.')); + console.log(theme.dim('To stop it, use: docker stop nile-local')); + process.exit(1); + } + } catch (error) { + // Ignore error, means docker ps failed which is fine + } + + // Start spinner for Docker pull + const pullSpinner = ora({ + text: 'Pulling latest Nile testing container...', + color: 'cyan' + }).start(); + + try { + await execAsync('docker pull ghcr.io/niledatabase/testingcontainer:latest'); + pullSpinner.succeed('Latest Nile testing container pulled successfully'); + } catch (error: any) { + pullSpinner.fail('Failed to pull latest container'); + if (getOptions().debug) { + console.error(theme.error('Error details:'), error.message); + } + process.exit(1); + } + + // Start spinner for container launch + const startSpinner = ora({ + text: 'Starting local development environment...', + color: 'cyan' + }).start(); + + // Start Docker container in background + const docker = spawn('docker', [ + 'run', + '--name', 'nile-local', + '-d', // Run in background + '-p', '5432:5432', + 'ghcr.io/niledatabase/testingcontainer:latest' + ]); + + // Collect any error output + let errorOutput = ''; + docker.stderr?.on('data', (data) => { + errorOutput += data.toString(); + }); + + // Wait for container to start + await new Promise((resolve, reject) => { + docker.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Docker failed to start: ${errorOutput}`)); + } else { + resolve(undefined); + } + }); + }); + + // Wait for PostgreSQL to be ready + const readySpinner = ora({ + text: 'Waiting for database to be ready...', + color: 'cyan' + }).start(); + + const isReady = await waitForPostgres(getOptions); + if (!isReady) { + readySpinner.fail('Database failed to start within timeout period'); + console.log(theme.dim('\nStopping container...')); + try { + await execAsync('docker stop nile-local && docker rm nile-local'); + } catch (error) { + // Ignore cleanup errors + } + process.exit(1); + } + + readySpinner.succeed('Database is ready'); + startSpinner.succeed('Local development environment started successfully'); + + // Display connection information + console.log('\nConnection Information:'); + console.log(theme.info('Host: ') + 'localhost'); + console.log(theme.info('Port: ') + '5432'); + console.log(theme.info('Database: ') + 'test'); + console.log(theme.info('Username: ') + '00000000-0000-0000-0000-000000000000'); + console.log(theme.info('Password: ') + 'password'); + + if (cmdOptions.prompt) { + // Create readline interface + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + // Ask if user wants to connect with psql + rl.question('\nWould you like to connect using psql? (y/N) ', async (answer) => { + rl.close(); + if (answer.toLowerCase() === 'y') { + const connectSpinner = ora({ + text: 'Connecting...', + color: 'cyan' + }).start(); + + // Add a longer delay to ensure the database is fully ready for connections + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Connect using psql with individual parameters + const psql = spawn('psql', [ + '-h', 'localhost', + '-p', '5432', + '-U', '00000000-0000-0000-0000-000000000000', + '-d', 'test', + '-w' // Never prompt for password + ], { + stdio: 'inherit', + env: { + ...process.env, + PGPASSWORD: 'password' // Set password via environment variable + } + }); + + // Stop the spinner immediately as psql will take over the terminal + connectSpinner.stop(); + + // Handle psql exit + psql.on('exit', (code) => { + if (code !== 0) { + console.error(theme.error('\nFailed to connect using psql. Please check if psql is installed.')); + if (getOptions().debug) { + console.error(theme.dim('Try connecting directly with:')); + console.error(theme.dim('PGPASSWORD=password psql -h localhost -p 5432 -U 00000000-0000-0000-0000-000000000000 -d test')); + } + } + // Note: Don't exit here as the Docker container should keep running + }); + + // Handle psql error + psql.on('error', (error) => { + connectSpinner.fail('Failed to launch psql'); + console.error(theme.error('\nError launching psql:'), error.message); + if (error.message.includes('ENOENT')) { + console.error(theme.error('Please make sure psql is installed and available in your PATH')); + } + }); + } else { + console.log(theme.dim('\nYou can connect to the database using your preferred client with the connection information above.')); + console.log(theme.dim('To stop the environment, use: nile local stop')); + } + }); + } else { + console.log(theme.dim('\nTo stop the environment, use: nile local stop')); + } + + // Handle process termination + process.on('SIGINT', async () => { + console.log(theme.dim('\nStopping local development environment...')); + try { + await execAsync('docker stop nile-local && docker rm nile-local'); + console.log(theme.success('Local environment stopped successfully')); + } catch (error) { + console.error(theme.error('Failed to stop local environment cleanly')); + } + process.exit(0); + }); + + } catch (error: any) { + const options = getOptions(); + if (options.debug) { + console.error(theme.error('Failed to start local environment:'), error); + } else { + console.error(theme.error('Failed to start local environment:'), error.message || 'Unknown error'); + } + // Cleanup on error + try { + await execAsync('docker stop nile-local && docker rm nile-local'); + } catch (cleanupError) { + // Ignore cleanup errors + } + process.exit(1); + } + }); + + return local; +} \ No newline at end of file diff --git a/src/commands/user.ts b/src/commands/user.ts new file mode 100644 index 0000000..daade46 --- /dev/null +++ b/src/commands/user.ts @@ -0,0 +1,495 @@ +import { Command } from 'commander'; +import { Client } from 'pg'; +import { ConfigManager } from '../lib/config'; +import { NileAPI } from '../lib/api'; +import { theme, formatCommand } from '../lib/colors'; +import { GlobalOptions, getGlobalOptionsHelp } from '../lib/globalOptions'; +import Table from 'cli-table3'; + +async function getWorkspaceAndDatabase(options: GlobalOptions): Promise<{ workspaceSlug: string; databaseName: string }> { + const configManager = new ConfigManager(options); + const workspaceSlug = configManager.getWorkspace(); + if (!workspaceSlug) { + throw new Error('No workspace specified. Use one of:\n' + + '1. --workspace flag\n' + + '2. nile config --workspace \n' + + '3. NILE_WORKSPACE environment variable'); + } + + const databaseName = configManager.getDatabase(); + if (!databaseName) { + throw new Error('No database specified. Use one of:\n' + + '1. --db flag\n' + + '2. nile config --db \n' + + '3. NILE_DB environment variable'); + } + + return { workspaceSlug, databaseName }; +} + +async function getPostgresClient(api: NileAPI, workspaceSlug: string, databaseName: string, options: GlobalOptions): Promise { + // Get database credentials from control plane + console.log(theme.dim('\nFetching database credentials...')); + const credentials = await api.createDatabaseCredentials(workspaceSlug, databaseName); + + if (!credentials.id || !credentials.password) { + throw new Error('Invalid credentials received from server'); + } + + // Create postgres connection URL + const region = credentials.database.region.toLowerCase(); + const regionParts = region.split('_'); + const regionPrefix = `${regionParts[1]}-${regionParts[2]}-${regionParts[3]}`; // e.g., us-west-2 + + // Use custom host if provided, otherwise use default with region prefix + const dbHost = options.dbHost ? + `${regionPrefix}.${options.dbHost}` : + `${regionPrefix}.db.thenile.dev`; + + // Create postgres client + const client = new Client({ + host: dbHost, + port: 5432, + database: databaseName, + user: credentials.id, + password: credentials.password, + ssl: { + rejectUnauthorized: false + } + }); + + if (options.debug) { + console.log(theme.dim('\nConnecting to PostgreSQL:')); + console.log(theme.dim('Host:'), dbHost); + console.log(theme.dim('Database:'), databaseName); + console.log(theme.dim('User:'), credentials.id); + console.log(); + } + + // Connect to the database + await client.connect(); + return client; +} + +export class UsersCommand { + constructor(program: Command, getGlobalOptions: () => GlobalOptions) { + const users = program + .command('users') + .description('Manage users in your database') + .addHelpText('after', ` +Examples: + # Create users + ${formatCommand('nile users create', '--email "user@example.com" --password "securepass123"')} Create a basic user + ${formatCommand('nile users create', '--email "user@example.com" --password "pass123" --name "John Doe"')} Create user with name + ${formatCommand('nile users create', '--email "user@example.com" --password "pass123" --given-name "John" --family-name "Doe"')} Create user with full name + ${formatCommand('nile users create', '--email "user@example.com" --password "pass123" --tenant-id "tenant123"')} Create user in specific tenant + ${formatCommand('nile users create', '--email "user@example.com" --password "pass123" --new-tenant-name "New Corp"')} Create user with new tenant + + # List user tenants + ${formatCommand('nile users tenants', '--user-id user123')} List all tenants for a user + + # Update users + ${formatCommand('nile users update', '--user-id user123 --name "Updated Name"')} Update user name + ${formatCommand('nile users update', '--user-id user123 --given-name "John" --family-name "Doe"')} Update user full name + + # Remove user from tenant + ${formatCommand('nile users remove-tenant', '--user-id user123 --tenant-id tenant123')} Remove user from tenant + +${getGlobalOptionsHelp()}`); + + const createCmd = new Command('create') + .description('Create a new user') + .requiredOption('--email ', 'Email address for the user') + .requiredOption('--password ', 'Password for the user') + .option('--name ', 'Full name of the user') + .option('--given-name ', 'Given (first) name of the user') + .option('--family-name ', 'Family (last) name of the user') + .option('--picture ', 'URL of the user\'s profile picture') + .option('--tenant-id ', 'ID of the tenant to add the user to') + .option('--new-tenant-name ', 'Name of a new tenant to create and add the user to') + .option('--roles ', 'Roles to assign to the user in the tenant') + .addHelpText('after', ` +Examples: + ${formatCommand('nile users create', '--email "user@example.com" --password "securepass123"')} + ${formatCommand('nile users create', '--email "user@example.com" --password "pass123" --name "John Doe"')} + ${formatCommand('nile users create', '--email "user@example.com" --password "pass123" --tenant-id "tenant123"')} + `) + .action(async (cmdOptions) => { + let client: Client | undefined; + try { + const options = getGlobalOptions(); + const configManager = new ConfigManager(options); + const api = new NileAPI({ + token: configManager.getToken(), + dbHost: configManager.getDbHost(), + controlPlaneUrl: configManager.getGlobalHost(), + debug: options.debug + }); + + const { workspaceSlug, databaseName } = await getWorkspaceAndDatabase(options); + client = await getPostgresClient(api, workspaceSlug, databaseName, options); + + // Begin transaction + await client.query('BEGIN'); + + try { + // Insert into users schema + console.log(theme.dim('\nCreating user...')); + const result = await client.query( + `INSERT INTO users.users ( + email, + name, + given_name, + family_name, + picture + ) VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [ + cmdOptions.email, + cmdOptions.name || null, + cmdOptions.givenName || null, + cmdOptions.familyName || null, + cmdOptions.picture || null + ] + ); + + const user = result.rows[0]; + + // Create auth credentials (this would typically be handled by auth service) + await client.query( + `INSERT INTO auth.credentials ( + user_id, + identifier, + password, + type + ) VALUES ($1, $2, crypt($3, gen_salt('bf')), 'password')`, + [user.id, cmdOptions.email, cmdOptions.password] + ); + + // If tenant ID is provided, create user-tenant relationship + if (cmdOptions.tenantId) { + await client.query( + `INSERT INTO users.tenant_users ( + tenant_id, + user_id, + roles, + email + ) VALUES ($1, $2, $3, $4)`, + [cmdOptions.tenantId, user.id, cmdOptions.roles || null, cmdOptions.email] + ); + } + + // If new tenant name is provided, create tenant and relationship + if (cmdOptions.newTenantName) { + const tenantResult = await client.query( + 'INSERT INTO tenants (name) VALUES ($1) RETURNING id', + [cmdOptions.newTenantName] + ); + const tenantId = tenantResult.rows[0].id; + await client.query( + `INSERT INTO users.tenant_users ( + tenant_id, + user_id, + roles, + email + ) VALUES ($1, $2, $3, $4)`, + [tenantId, user.id, cmdOptions.roles || null, cmdOptions.email] + ); + } + + // Commit transaction + await client.query('COMMIT'); + + if (options.format === 'json') { + console.log(JSON.stringify(user, null, 2)); + return; + } + + if (options.format === 'csv') { + console.log('ID,EMAIL,NAME'); + console.log(`${user.id},${user.email},${user.name || ''}`); + return; + } + + // Create a nicely formatted table + const table = new Table({ + head: ['Field', 'Value'].map(h => theme.primary(h)), + style: { head: [], border: [] }, + chars: { + 'top': '─', + 'top-mid': '┬', + 'top-left': '┌', + 'top-right': '┐', + 'bottom': '─', + 'bottom-mid': '┴', + 'bottom-left': '└', + 'bottom-right': '┘', + 'left': '│', + 'left-mid': '├', + 'mid': '─', + 'mid-mid': '┼', + 'right': '│', + 'right-mid': '┤', + 'middle': '│' + } + }); + + table.push( + ['ID', theme.info(user.id)], + ['Email', theme.info(user.email)], + ['Name', theme.info(user.name || '')], + ['Given Name', theme.info(user.given_name || '')], + ['Family Name', theme.info(user.family_name || '')], + ['Picture', theme.info(user.picture || '')] + ); + + console.log('\nUser created successfully:'); + console.log(table.toString()); + + if (cmdOptions.tenantId) { + console.log(theme.success(`\nUser added to tenant: ${cmdOptions.tenantId}`)); + } else if (cmdOptions.newTenantName) { + console.log(theme.success(`\nUser added to new tenant: ${cmdOptions.newTenantName}`)); + } + } catch (error) { + // Rollback transaction on error + await client.query('ROLLBACK'); + throw error; + } + } catch (error: any) { + const options = getGlobalOptions(); + if (options.debug) { + console.error(theme.error('Failed to create user:'), error); + } else { + console.error(theme.error('Failed to create user:'), error.message || 'Unknown error'); + } + process.exit(1); + } finally { + if (client) { + await client.end(); + } + } + }); + + const tenantsCmd = new Command('tenants') + .description('List tenants for a user') + .requiredOption('--user-id ', 'ID of the user') + .addHelpText('after', ` +Examples: + ${formatCommand('nile users tenants', '--user-id user123')} List all tenants for a user + `) + .action(async (cmdOptions) => { + try { + const options = getGlobalOptions(); + const configManager = new ConfigManager(options); + const api = new NileAPI({ + token: configManager.getToken(), + dbHost: configManager.getDbHost(), + controlPlaneUrl: configManager.getGlobalHost(), + debug: options.debug + }); + + const { workspaceSlug, databaseName } = await getWorkspaceAndDatabase(options); + const databaseId = await api.getDatabaseId(workspaceSlug, databaseName); + + const tenants = await api.getUserTenants(databaseId, cmdOptions.userId); + + if (options.format === 'json') { + console.log(JSON.stringify(tenants, null, 2)); + return; + } + + if (options.format === 'csv') { + console.log('ID,NAME'); + tenants.forEach(tenant => { + console.log(`${tenant.id},${tenant.name || ''}`); + }); + return; + } + + if (tenants.length === 0) { + console.log(theme.warning('\nNo tenants found for this user.')); + return; + } + + console.log('\nUser Tenants:'); + const table = new Table({ + head: [ + theme.header('ID'), + theme.header('NAME') + ], + style: { head: [], border: [] }, + chars: { + 'top': '─', + 'top-mid': '┬', + 'top-left': '┌', + 'top-right': '┐', + 'bottom': '─', + 'bottom-mid': '┴', + 'bottom-left': '└', + 'bottom-right': '┘', + 'left': '│', + 'left-mid': '├', + 'mid': '─', + 'mid-mid': '┼', + 'right': '│', + 'right-mid': '┤', + 'middle': '│' + } + }); + + tenants.forEach(tenant => { + table.push([ + theme.primary(tenant.id), + theme.info(tenant.name || '(unnamed)') + ]); + }); + + console.log(table.toString()); + } catch (error: any) { + const options = getGlobalOptions(); + if (options.debug) { + console.error(theme.error('Failed to list user tenants:'), error); + } else { + console.error(theme.error('Failed to list user tenants:'), error.message || 'Unknown error'); + } + process.exit(1); + } + }); + + const updateCmd = new Command('update') + .description('Update user details') + .requiredOption('--user-id ', 'ID of the user to update') + .option('--name ', 'Full name of the user') + .option('--given-name ', 'Given (first) name of the user') + .option('--family-name ', 'Family (last) name of the user') + .option('--picture ', 'URL of the user\'s profile picture') + .addHelpText('after', ` +Examples: + ${formatCommand('nile users update', '--user-id user123 --name "Updated Name"')} + ${formatCommand('nile users update', '--user-id user123 --given-name "John" --family-name "Doe"')} + `) + .action(async (cmdOptions) => { + try { + const options = getGlobalOptions(); + const configManager = new ConfigManager(options); + const api = new NileAPI({ + token: configManager.getToken(), + dbHost: configManager.getDbHost(), + controlPlaneUrl: configManager.getGlobalHost(), + debug: options.debug + }); + + const { workspaceSlug, databaseName } = await getWorkspaceAndDatabase(options); + const databaseId = await api.getDatabaseId(workspaceSlug, databaseName); + + const updates = { + name: cmdOptions.name, + givenName: cmdOptions.givenName, + familyName: cmdOptions.familyName, + picture: cmdOptions.picture + }; + + const user = await api.updateUser(databaseId, cmdOptions.userId, updates); + + if (options.format === 'json') { + console.log(JSON.stringify(user, null, 2)); + return; + } + + if (options.format === 'csv') { + console.log('ID,EMAIL,NAME'); + console.log(`${user.id},${user.email},${user.name || ''}`); + return; + } + + console.log('\nUser updated successfully:'); + const table = new Table({ + head: ['Field', 'Value'].map(h => theme.primary(h)), + style: { head: [], border: [] }, + chars: { + 'top': '─', + 'top-mid': '┬', + 'top-left': '┌', + 'top-right': '┐', + 'bottom': '─', + 'bottom-mid': '┴', + 'bottom-left': '└', + 'bottom-right': '┘', + 'left': '│', + 'left-mid': '├', + 'mid': '─', + 'mid-mid': '┼', + 'right': '│', + 'right-mid': '┤', + 'middle': '│' + } + }); + + table.push( + ['ID', theme.info(user.id)], + ['Email', theme.info(user.email)], + ['Name', theme.info(user.name || '')], + ['Given Name', theme.info(user.givenName || '')], + ['Family Name', theme.info(user.familyName || '')], + ['Picture', theme.info(user.picture || '')] + ); + + console.log(table.toString()); + } catch (error: any) { + const options = getGlobalOptions(); + if (options.debug) { + console.error(theme.error('Failed to update user:'), error); + } else { + console.error(theme.error('Failed to update user:'), error.message || 'Unknown error'); + } + process.exit(1); + } + }); + + const removeTenantCmd = new Command('remove-tenant') + .description('Remove a user from a tenant') + .requiredOption('--user-id ', 'ID of the user') + .requiredOption('--tenant-id ', 'ID of the tenant') + .addHelpText('after', ` +Examples: + ${formatCommand('nile users remove-tenant', '--user-id user123 --tenant-id tenant123')} + `) + .action(async (cmdOptions) => { + try { + const options = getGlobalOptions(); + const configManager = new ConfigManager(options); + const api = new NileAPI({ + token: configManager.getToken(), + dbHost: configManager.getDbHost(), + controlPlaneUrl: configManager.getGlobalHost(), + debug: options.debug + }); + + const { workspaceSlug, databaseName } = await getWorkspaceAndDatabase(options); + const databaseId = await api.getDatabaseId(workspaceSlug, databaseName); + + await api.removeUserFromTenant(databaseId, cmdOptions.userId, cmdOptions.tenantId); + console.log(theme.success(`\nUser '${cmdOptions.userId}' removed from tenant '${cmdOptions.tenantId}'`)); + } catch (error: any) { + const options = getGlobalOptions(); + if (options.debug) { + console.error(theme.error('Failed to remove user from tenant:'), error); + } else { + console.error(theme.error('Failed to remove user from tenant:'), error.message || 'Unknown error'); + } + process.exit(1); + } + }); + + users.addCommand(createCmd); + users.addCommand(tenantsCmd); + users.addCommand(updateCmd); + users.addCommand(removeTenantCmd); + } +} + +export function createUsersCommand(getGlobalOptions: () => GlobalOptions): Command { + const program = new Command(); + new UsersCommand(program, getGlobalOptions); + return program.commands[0]; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index cbfe341..483ae39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,10 +6,17 @@ import { createWorkspaceCommand } from './commands/workspace'; import { createDbCommand } from './commands/db'; import { createTenantsCommand } from './commands/tenants'; import { configCommand } from './commands/config'; +import { createUsersCommand } from './commands/user'; +import { createLocalCommand } from './commands/local'; import { addGlobalOptions, updateChalkConfig } from './lib/globalOptions'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +// Read version from package.json +const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')); const cli = new Command() - .version('0.1.0') + .version(packageJson.version) .description('Nile CLI') .addHelpText('after', ` Examples: @@ -17,6 +24,8 @@ Examples: $ nile --no-color db show my-database $ nile tenants list List tenants in selected database $ nile config --api-key Set API key in config + $ nile users create Create a new user + $ nile local start Start local development environment `); // Add global options @@ -34,5 +43,7 @@ cli.addCommand(createWorkspaceCommand(() => cli.opts())); cli.addCommand(createDbCommand(() => cli.opts())); cli.addCommand(createTenantsCommand(() => cli.opts())); cli.addCommand(configCommand()); +cli.addCommand(createUsersCommand(() => cli.opts())); +cli.addCommand(createLocalCommand(() => cli.opts())); cli.parse(process.argv); \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 1ed87e1..c89cc2f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -34,12 +34,44 @@ export interface Workspace { created?: string; } +export interface CreateUserRequest { + email: string; + password: string; + name?: string; + givenName?: string; + familyName?: string; + picture?: string; +} + +export interface User { + id: string; + email: string; + name?: string; + givenName?: string; + familyName?: string; + picture?: string; + emailVerified?: string; + created: string; + updated: string; + tenants?: string[]; +} + +export interface UpdateUserRequest { + name?: string; + givenName?: string; + familyName?: string; + picture?: string; +} + export class NileAPI { private controlPlaneClient: AxiosInstance; + private userClient: AxiosInstance; private static DEFAULT_CONTROL_PLANE_URL = 'https://global.thenile.dev'; + private static DEFAULT_USER_URL = 'https://us-west-2.api.thenile.dev'; private debug: boolean; private token: string; private controlPlaneUrl: string; + private userUrl: string; private dbHost?: string; constructor(options: NileAPIOptions) { @@ -59,9 +91,13 @@ export class NileAPI { this.controlPlaneUrl = NileAPI.DEFAULT_CONTROL_PLANE_URL; } + // Set user URL + this.userUrl = NileAPI.DEFAULT_USER_URL; + if (this.debug) { console.log(theme.dim('\nAPI Configuration:')); console.log(theme.dim('Control Plane URL:'), this.controlPlaneUrl); + console.log(theme.dim('User API URL:'), this.userUrl); console.log(); } @@ -74,8 +110,18 @@ export class NileAPI { }, }); + // Create user client for user operations + this.userClient = axios.create({ + baseURL: this.userUrl, + headers: { + Authorization: `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + }); + // Add debug logging this.addDebugLogging(this.controlPlaneClient); + this.addDebugLogging(this.userClient); this.dbHost = options.dbHost; } @@ -207,4 +253,45 @@ export class NileAPI { const response = await this.controlPlaneClient.get(`/workspaces/${workspaceSlug}`); return response.data; } + + async getDatabaseId(workspaceSlug: string, databaseName: string): Promise { + const database = await this.getDatabase(workspaceSlug, databaseName); + if (!database.id) { + throw new Error(`Could not find database ID for database '${databaseName}'`); + } + return database.id; + } + + async createUser(databaseId: string, user: CreateUserRequest, options?: { tenantId?: string; newTenantName?: string }): Promise { + const response = await this.userClient.post( + `/v2/databases/${databaseId}/users`, + user, + { + params: { + tenantId: options?.tenantId, + newTenantName: options?.newTenantName + } + } + ); + return response.data; + } + + async getUserTenants(databaseId: string, userId: string): Promise { + const response = await this.userClient.get(`/v2/databases/${databaseId}/users/${userId}/tenants`); + return response.data; + } + + async updateUser(databaseId: string, userId: string, updates: UpdateUserRequest): Promise { + const response = await this.userClient.patch( + `/v2/databases/${databaseId}/users/${userId}`, + updates + ); + return response.data; + } + + async removeUserFromTenant(databaseId: string, userId: string, tenantId: string): Promise { + await this.userClient.delete( + `/v2/databases/${databaseId}/users/${userId}/tenants/${tenantId}` + ); + } } \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index ecbbf52..a9dc761 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -6,6 +6,7 @@ export interface Workspace { } export interface Database { + id: string; name: string; region: string; status: string;