Skip to content
Merged
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
240 changes: 240 additions & 0 deletions src/commands/org.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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 <name>
org
.command('create <name>')
.description('Create a new organization')
.option('-n, --name <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 <name>`.'));
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 <slug>
org
.command('switch <slug>')
.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 <slug>` 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 <slug>` 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);
}
});
}
4 changes: 4 additions & 0 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`));
Expand Down
4 changes: 4 additions & 0 deletions src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`));
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -44,6 +45,7 @@ pullCommand(program);
loginCommand(program);
logoutCommand(program);
whoamiCommand(program);
orgCommand(program);

// 默认命令
program
Expand Down
15 changes: 15 additions & 0 deletions src/utils/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 };
Loading