From da48c0a6c4630c4774691e1518c4ffce25d67b2a Mon Sep 17 00:00:00 2001 From: cunoe Date: Wed, 1 Apr 2026 22:02:33 +0800 Subject: [PATCH 1/2] feat: add organization support and namespace permission handling (#6, #5) Organization support (#6): - Add `envx org create/list/switch/current/members` commands - Store current org context in ~/.envx/credentials.json - Auto-switch to newly created org Namespace permission isolation (#5): - Handle 403 Forbidden in push/pull with clear error messages - Guide users to check permissions and org membership --- src/commands/org.ts | 240 +++++++++++++++++++++++++++++++++++++++ src/commands/pull.ts | 4 + src/commands/push.ts | 4 + src/index.ts | 2 + src/utils/credentials.ts | 15 +++ 5 files changed, 265 insertions(+) create mode 100644 src/commands/org.ts diff --git a/src/commands/org.ts b/src/commands/org.ts new file mode 100644 index 0000000..e2e40d7 --- /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('-d, --display-name ', 'Display name for the organization') + .action(async (name: string, opts: { displayName?: 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, + displayName: opts.displayName || name, + }), + }); + + const data = (await res.json()) as { + success: boolean; + data?: { id: string; slug: string; displayName: 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; displayName: 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.displayName} 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; displayName: 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 }; From 2be65deb97a495790122d2311ff04ede6e59536e Mon Sep 17 00:00:00 2001 From: cunoe Date: Sat, 4 Apr 2026 12:08:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20align=20org=20API=20with=20Bett?= =?UTF-8?q?er=20Auth=20naming=20(displayName=20=E2=86=92=20name)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/org.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commands/org.ts b/src/commands/org.ts index e2e40d7..470f6de 100644 --- a/src/commands/org.ts +++ b/src/commands/org.ts @@ -34,8 +34,8 @@ export function orgCommand(program: Command): void { org .command('create ') .description('Create a new organization') - .option('-d, --display-name ', 'Display name for the organization') - .action(async (name: string, opts: { displayName?: string }) => { + .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(); @@ -44,14 +44,14 @@ export function orgCommand(program: Command): void { method: 'POST', headers: authHeaders(token), body: JSON.stringify({ - slug: name, - displayName: opts.displayName || name, + slug, + name: opts.name || slug, }), }); const data = (await res.json()) as { success: boolean; - data?: { id: string; slug: string; displayName: string }; + data?: { id: string; slug: string; name: string }; error?: string; }; @@ -92,7 +92,7 @@ export function orgCommand(program: Command): void { const data = (await res.json()) as { success: boolean; - data?: Array<{ id: string; slug: string; displayName: string; role: string }>; + data?: Array<{ id: string; slug: string; name: string; role: string }>; error?: string; }; @@ -114,7 +114,7 @@ export function orgCommand(program: Command): void { 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.displayName} Role: ${o.role}`)); + console.log(chalk.gray(` Name: ${o.name} Role: ${o.role}`)); } } catch (err) { spinner.stop(); @@ -141,7 +141,7 @@ export function orgCommand(program: Command): void { const data = (await res.json()) as { success: boolean; - data?: { id: string; slug: string; displayName: string }; + data?: { id: string; slug: string; name: string }; error?: string; };