diff --git a/src/commands/org.ts b/src/commands/org.ts new file mode 100644 index 0000000..470f6de --- /dev/null +++ b/src/commands/org.ts @@ -0,0 +1,240 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { + getCredential, + getAuthBaseUrl, + getCurrentOrg, + setCurrentOrg, +} from '@/utils/credentials'; + +function requireAuth(): { token: string; baseUrl: string } { + const token = getCredential(); + if (!token) { + console.error(chalk.red('โŒ Not authenticated. Run `envx login` first.')); + process.exit(1); + } + return { token, baseUrl: getAuthBaseUrl() }; +} + +function authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': '@leaperone/envx', + }; +} + +export function orgCommand(program: Command): void { + const org = program + .command('org') + .description('Manage organizations'); + + // envx org create + org + .command('create ') + .description('Create a new organization') + .option('-n, --name ', 'Display name for the organization') + .action(async (slug: string, opts: { name?: string }) => { + const { token, baseUrl } = requireAuth(); + const spinner = ora('Creating organization...').start(); + + try { + const res = await fetch(new URL('/api/v1/cli/orgs', baseUrl).toString(), { + method: 'POST', + headers: authHeaders(token), + body: JSON.stringify({ + slug, + name: opts.name || slug, + }), + }); + + const data = (await res.json()) as { + success: boolean; + data?: { id: string; slug: string; name: string }; + error?: string; + }; + + spinner.stop(); + + if (!res.ok || !data.success) { + console.error(chalk.red(`โŒ Failed to create organization: ${data.error || res.statusText}`)); + process.exit(1); + } + + console.log(chalk.green(`โœ… Organization "${data.data!.slug}" created successfully`)); + console.log(chalk.gray(` ID: ${data.data!.id}`)); + + // Auto-switch to the new org + setCurrentOrg(data.data!.slug); + console.log(chalk.blue(`๐Ÿ”„ Switched to organization "${data.data!.slug}"`)); + } catch (err) { + spinner.stop(); + console.error(chalk.red(`โŒ Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // envx org list + org + .command('list') + .alias('ls') + .description('List organizations you belong to') + .action(async () => { + const { token, baseUrl } = requireAuth(); + const spinner = ora('Fetching organizations...').start(); + + try { + const res = await fetch(new URL('/api/v1/cli/orgs', baseUrl).toString(), { + method: 'GET', + headers: authHeaders(token), + }); + + const data = (await res.json()) as { + success: boolean; + data?: Array<{ id: string; slug: string; name: string; role: string }>; + error?: string; + }; + + spinner.stop(); + + if (!res.ok || !data.success) { + console.error(chalk.red(`โŒ Failed to list organizations: ${data.error || res.statusText}`)); + process.exit(1); + } + + const orgs = data.data || []; + if (orgs.length === 0) { + console.log(chalk.yellow('No organizations found. Create one with `envx org create `.')); + return; + } + + const currentOrg = getCurrentOrg(); + console.log(chalk.blue('Organizations:\n')); + for (const o of orgs) { + const marker = o.slug === currentOrg ? chalk.green(' โ† current') : ''; + console.log(` ${chalk.bold(o.slug)}${marker}`); + console.log(chalk.gray(` Name: ${o.name} Role: ${o.role}`)); + } + } catch (err) { + spinner.stop(); + console.error(chalk.red(`โŒ Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // envx org switch + org + .command('switch ') + .description('Switch to a different organization context') + .action(async (slug: string) => { + const { token, baseUrl } = requireAuth(); + + // Verify the org exists and user has access + const spinner = ora('Verifying organization...').start(); + + try { + const res = await fetch(new URL(`/api/v1/cli/orgs/${encodeURIComponent(slug)}`, baseUrl).toString(), { + method: 'GET', + headers: authHeaders(token), + }); + + const data = (await res.json()) as { + success: boolean; + data?: { id: string; slug: string; name: string }; + error?: string; + }; + + spinner.stop(); + + if (!res.ok || !data.success) { + if (res.status === 403) { + console.error(chalk.red(`โŒ You don't have access to organization "${slug}".`)); + } else if (res.status === 404) { + console.error(chalk.red(`โŒ Organization "${slug}" not found.`)); + } else { + console.error(chalk.red(`โŒ Failed: ${data.error || res.statusText}`)); + } + process.exit(1); + } + + setCurrentOrg(slug); + console.log(chalk.green(`โœ… Switched to organization "${slug}"`)); + } catch (err) { + spinner.stop(); + console.error(chalk.red(`โŒ Error: ${(err as Error).message}`)); + process.exit(1); + } + }); + + // envx org current + org + .command('current') + .description('Show current organization context') + .action(() => { + const currentOrg = getCurrentOrg(); + if (currentOrg) { + console.log(`Current organization: ${chalk.bold(currentOrg)}`); + } else { + console.log(chalk.yellow('No organization selected. Use `envx org switch ` to select one.')); + } + }); + + // envx org members [slug] + org + .command('members [slug]') + .description('List members of an organization (defaults to current org)') + .action(async (slug?: string) => { + const { token, baseUrl } = requireAuth(); + const orgSlug = slug || getCurrentOrg(); + + if (!orgSlug) { + console.error(chalk.red('โŒ No organization specified. Use `envx org switch ` first, or provide the org slug.')); + process.exit(1); + } + + const spinner = ora('Fetching members...').start(); + + try { + const res = await fetch( + new URL(`/api/v1/cli/orgs/${encodeURIComponent(orgSlug)}/members`, baseUrl).toString(), + { + method: 'GET', + headers: authHeaders(token), + } + ); + + const data = (await res.json()) as { + success: boolean; + data?: Array<{ id: string; name?: string; email?: string; role: string }>; + error?: string; + }; + + spinner.stop(); + + if (!res.ok || !data.success) { + if (res.status === 403) { + console.error(chalk.red(`โŒ You don't have permission to view members of "${orgSlug}".`)); + } else { + console.error(chalk.red(`โŒ Failed: ${data.error || res.statusText}`)); + } + process.exit(1); + } + + const members = data.data || []; + if (members.length === 0) { + console.log(chalk.yellow('No members found.')); + return; + } + + console.log(chalk.blue(`Members of "${orgSlug}":\n`)); + for (const m of members) { + console.log(` ${chalk.bold(m.name || m.email || m.id)} ${chalk.gray(`(${m.role})`)}`); + } + } catch (err) { + spinner.stop(); + console.error(chalk.red(`โŒ Error: ${(err as Error).message}`)); + process.exit(1); + } + }); +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 8f6f471..7c87fa7 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -154,6 +154,10 @@ export function pullCommand(program: Command): void { if (!response.ok) { if (response.status === 401) { console.error(chalk.red('โŒ Authentication failed. Run `envx login` to re-authenticate.')); + } else if (response.status === 403) { + console.error(chalk.red(`โŒ Permission denied: You don't have access to namespace "${parsedUrl.namespace}".`)); + console.error(chalk.yellow('๐Ÿ’ก Tip: Check that you have pull permission, or ask the namespace owner to grant access.')); + console.error(chalk.yellow('๐Ÿ’ก Tip: Use `envx org list` to see your organizations.')); } else { console.error(chalk.red(`โŒ Error: Remote server returned ${response.status}`)); console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`)); diff --git a/src/commands/push.ts b/src/commands/push.ts index abb51b1..67a7428 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -134,6 +134,10 @@ export function pushCommand(program: Command): void { if (!response.ok) { if (response.status === 401) { console.error(chalk.red('โŒ Authentication failed. Run `envx login` to re-authenticate.')); + } else if (response.status === 403) { + console.error(chalk.red(`โŒ Permission denied: You don't have access to namespace "${parsedUrl.namespace}".`)); + console.error(chalk.yellow('๐Ÿ’ก Tip: Check that you have push permission, or ask the namespace owner to grant access.')); + console.error(chalk.yellow('๐Ÿ’ก Tip: Use `envx org list` to see your organizations.')); } else { console.error(chalk.red(`โŒ Error: Remote server returned ${response.status}`)); console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`)); diff --git a/src/index.ts b/src/index.ts index d9b5ac9..f8fce99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { pullCommand } from './commands/pull.js'; import { loginCommand } from './commands/login.js'; import { logoutCommand } from './commands/logout.js'; import { whoamiCommand } from './commands/whoami.js'; +import { orgCommand } from './commands/org.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json'); @@ -44,6 +45,7 @@ pullCommand(program); loginCommand(program); logoutCommand(program); whoamiCommand(program); +orgCommand(program); // ้ป˜่ฎคๅ‘ฝไปค program diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index 39d89a3..2273028 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -5,6 +5,7 @@ import path from 'node:path'; interface Credentials { token?: string; baseUrl?: string; + currentOrg?: string; } const CREDENTIALS_DIR = path.join(os.homedir(), '.envx'); @@ -53,4 +54,18 @@ export function getAuthBaseUrl(): string { return process.env.ENVX_BASEURL || loadCredentials().baseUrl || 'https://leaper.one'; } +export function getCurrentOrg(): string | undefined { + return loadCredentials().currentOrg; +} + +export function setCurrentOrg(org: string | undefined): void { + const credentials = loadCredentials(); + if (org) { + credentials.currentOrg = org; + } else { + delete credentials.currentOrg; + } + saveCredentials(credentials); +} + export { CREDENTIALS_DIR, CREDENTIALS_FILE };