Skip to content

Commit f480a3f

Browse files
authored
feat: Organization 支持 + namespace 权限隔离 (#31)
* 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 * refactor: align org API with Better Auth naming (displayName → name)
1 parent 002bd12 commit f480a3f

5 files changed

Lines changed: 265 additions & 0 deletions

File tree

src/commands/org.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { Command } from 'commander';
2+
import chalk from 'chalk';
3+
import ora from 'ora';
4+
import {
5+
getCredential,
6+
getAuthBaseUrl,
7+
getCurrentOrg,
8+
setCurrentOrg,
9+
} from '@/utils/credentials';
10+
11+
function requireAuth(): { token: string; baseUrl: string } {
12+
const token = getCredential();
13+
if (!token) {
14+
console.error(chalk.red('❌ Not authenticated. Run `envx login` first.'));
15+
process.exit(1);
16+
}
17+
return { token, baseUrl: getAuthBaseUrl() };
18+
}
19+
20+
function authHeaders(token: string): Record<string, string> {
21+
return {
22+
Authorization: `Bearer ${token}`,
23+
'Content-Type': 'application/json',
24+
'User-Agent': '@leaperone/envx',
25+
};
26+
}
27+
28+
export function orgCommand(program: Command): void {
29+
const org = program
30+
.command('org')
31+
.description('Manage organizations');
32+
33+
// envx org create <name>
34+
org
35+
.command('create <name>')
36+
.description('Create a new organization')
37+
.option('-n, --name <name>', 'Display name for the organization')
38+
.action(async (slug: string, opts: { name?: string }) => {
39+
const { token, baseUrl } = requireAuth();
40+
const spinner = ora('Creating organization...').start();
41+
42+
try {
43+
const res = await fetch(new URL('/api/v1/cli/orgs', baseUrl).toString(), {
44+
method: 'POST',
45+
headers: authHeaders(token),
46+
body: JSON.stringify({
47+
slug,
48+
name: opts.name || slug,
49+
}),
50+
});
51+
52+
const data = (await res.json()) as {
53+
success: boolean;
54+
data?: { id: string; slug: string; name: string };
55+
error?: string;
56+
};
57+
58+
spinner.stop();
59+
60+
if (!res.ok || !data.success) {
61+
console.error(chalk.red(`❌ Failed to create organization: ${data.error || res.statusText}`));
62+
process.exit(1);
63+
}
64+
65+
console.log(chalk.green(`✅ Organization "${data.data!.slug}" created successfully`));
66+
console.log(chalk.gray(` ID: ${data.data!.id}`));
67+
68+
// Auto-switch to the new org
69+
setCurrentOrg(data.data!.slug);
70+
console.log(chalk.blue(`🔄 Switched to organization "${data.data!.slug}"`));
71+
} catch (err) {
72+
spinner.stop();
73+
console.error(chalk.red(`❌ Error: ${(err as Error).message}`));
74+
process.exit(1);
75+
}
76+
});
77+
78+
// envx org list
79+
org
80+
.command('list')
81+
.alias('ls')
82+
.description('List organizations you belong to')
83+
.action(async () => {
84+
const { token, baseUrl } = requireAuth();
85+
const spinner = ora('Fetching organizations...').start();
86+
87+
try {
88+
const res = await fetch(new URL('/api/v1/cli/orgs', baseUrl).toString(), {
89+
method: 'GET',
90+
headers: authHeaders(token),
91+
});
92+
93+
const data = (await res.json()) as {
94+
success: boolean;
95+
data?: Array<{ id: string; slug: string; name: string; role: string }>;
96+
error?: string;
97+
};
98+
99+
spinner.stop();
100+
101+
if (!res.ok || !data.success) {
102+
console.error(chalk.red(`❌ Failed to list organizations: ${data.error || res.statusText}`));
103+
process.exit(1);
104+
}
105+
106+
const orgs = data.data || [];
107+
if (orgs.length === 0) {
108+
console.log(chalk.yellow('No organizations found. Create one with `envx org create <name>`.'));
109+
return;
110+
}
111+
112+
const currentOrg = getCurrentOrg();
113+
console.log(chalk.blue('Organizations:\n'));
114+
for (const o of orgs) {
115+
const marker = o.slug === currentOrg ? chalk.green(' ← current') : '';
116+
console.log(` ${chalk.bold(o.slug)}${marker}`);
117+
console.log(chalk.gray(` Name: ${o.name} Role: ${o.role}`));
118+
}
119+
} catch (err) {
120+
spinner.stop();
121+
console.error(chalk.red(`❌ Error: ${(err as Error).message}`));
122+
process.exit(1);
123+
}
124+
});
125+
126+
// envx org switch <slug>
127+
org
128+
.command('switch <slug>')
129+
.description('Switch to a different organization context')
130+
.action(async (slug: string) => {
131+
const { token, baseUrl } = requireAuth();
132+
133+
// Verify the org exists and user has access
134+
const spinner = ora('Verifying organization...').start();
135+
136+
try {
137+
const res = await fetch(new URL(`/api/v1/cli/orgs/${encodeURIComponent(slug)}`, baseUrl).toString(), {
138+
method: 'GET',
139+
headers: authHeaders(token),
140+
});
141+
142+
const data = (await res.json()) as {
143+
success: boolean;
144+
data?: { id: string; slug: string; name: string };
145+
error?: string;
146+
};
147+
148+
spinner.stop();
149+
150+
if (!res.ok || !data.success) {
151+
if (res.status === 403) {
152+
console.error(chalk.red(`❌ You don't have access to organization "${slug}".`));
153+
} else if (res.status === 404) {
154+
console.error(chalk.red(`❌ Organization "${slug}" not found.`));
155+
} else {
156+
console.error(chalk.red(`❌ Failed: ${data.error || res.statusText}`));
157+
}
158+
process.exit(1);
159+
}
160+
161+
setCurrentOrg(slug);
162+
console.log(chalk.green(`✅ Switched to organization "${slug}"`));
163+
} catch (err) {
164+
spinner.stop();
165+
console.error(chalk.red(`❌ Error: ${(err as Error).message}`));
166+
process.exit(1);
167+
}
168+
});
169+
170+
// envx org current
171+
org
172+
.command('current')
173+
.description('Show current organization context')
174+
.action(() => {
175+
const currentOrg = getCurrentOrg();
176+
if (currentOrg) {
177+
console.log(`Current organization: ${chalk.bold(currentOrg)}`);
178+
} else {
179+
console.log(chalk.yellow('No organization selected. Use `envx org switch <slug>` to select one.'));
180+
}
181+
});
182+
183+
// envx org members [slug]
184+
org
185+
.command('members [slug]')
186+
.description('List members of an organization (defaults to current org)')
187+
.action(async (slug?: string) => {
188+
const { token, baseUrl } = requireAuth();
189+
const orgSlug = slug || getCurrentOrg();
190+
191+
if (!orgSlug) {
192+
console.error(chalk.red('❌ No organization specified. Use `envx org switch <slug>` first, or provide the org slug.'));
193+
process.exit(1);
194+
}
195+
196+
const spinner = ora('Fetching members...').start();
197+
198+
try {
199+
const res = await fetch(
200+
new URL(`/api/v1/cli/orgs/${encodeURIComponent(orgSlug)}/members`, baseUrl).toString(),
201+
{
202+
method: 'GET',
203+
headers: authHeaders(token),
204+
}
205+
);
206+
207+
const data = (await res.json()) as {
208+
success: boolean;
209+
data?: Array<{ id: string; name?: string; email?: string; role: string }>;
210+
error?: string;
211+
};
212+
213+
spinner.stop();
214+
215+
if (!res.ok || !data.success) {
216+
if (res.status === 403) {
217+
console.error(chalk.red(`❌ You don't have permission to view members of "${orgSlug}".`));
218+
} else {
219+
console.error(chalk.red(`❌ Failed: ${data.error || res.statusText}`));
220+
}
221+
process.exit(1);
222+
}
223+
224+
const members = data.data || [];
225+
if (members.length === 0) {
226+
console.log(chalk.yellow('No members found.'));
227+
return;
228+
}
229+
230+
console.log(chalk.blue(`Members of "${orgSlug}":\n`));
231+
for (const m of members) {
232+
console.log(` ${chalk.bold(m.name || m.email || m.id)} ${chalk.gray(`(${m.role})`)}`);
233+
}
234+
} catch (err) {
235+
spinner.stop();
236+
console.error(chalk.red(`❌ Error: ${(err as Error).message}`));
237+
process.exit(1);
238+
}
239+
});
240+
}

src/commands/pull.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ export function pullCommand(program: Command): void {
154154
if (!response.ok) {
155155
if (response.status === 401) {
156156
console.error(chalk.red('❌ Authentication failed. Run `envx login` to re-authenticate.'));
157+
} else if (response.status === 403) {
158+
console.error(chalk.red(`❌ Permission denied: You don't have access to namespace "${parsedUrl.namespace}".`));
159+
console.error(chalk.yellow('💡 Tip: Check that you have pull permission, or ask the namespace owner to grant access.'));
160+
console.error(chalk.yellow('💡 Tip: Use `envx org list` to see your organizations.'));
157161
} else {
158162
console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`));
159163
console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`));

src/commands/push.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ export function pushCommand(program: Command): void {
134134
if (!response.ok) {
135135
if (response.status === 401) {
136136
console.error(chalk.red('❌ Authentication failed. Run `envx login` to re-authenticate.'));
137+
} else if (response.status === 403) {
138+
console.error(chalk.red(`❌ Permission denied: You don't have access to namespace "${parsedUrl.namespace}".`));
139+
console.error(chalk.yellow('💡 Tip: Check that you have push permission, or ask the namespace owner to grant access.'));
140+
console.error(chalk.yellow('💡 Tip: Use `envx org list` to see your organizations.'));
137141
} else {
138142
console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`));
139143
console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`));

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { pullCommand } from './commands/pull.js';
1717
import { loginCommand } from './commands/login.js';
1818
import { logoutCommand } from './commands/logout.js';
1919
import { whoamiCommand } from './commands/whoami.js';
20+
import { orgCommand } from './commands/org.js';
2021

2122
const require = createRequire(import.meta.url);
2223
const { version } = require('../package.json');
@@ -44,6 +45,7 @@ pullCommand(program);
4445
loginCommand(program);
4546
logoutCommand(program);
4647
whoamiCommand(program);
48+
orgCommand(program);
4749

4850
// 默认命令
4951
program

src/utils/credentials.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from 'node:path';
55
interface Credentials {
66
token?: string;
77
baseUrl?: string;
8+
currentOrg?: string;
89
}
910

1011
const CREDENTIALS_DIR = path.join(os.homedir(), '.envx');
@@ -53,4 +54,18 @@ export function getAuthBaseUrl(): string {
5354
return process.env.ENVX_BASEURL || loadCredentials().baseUrl || 'https://leaper.one';
5455
}
5556

57+
export function getCurrentOrg(): string | undefined {
58+
return loadCredentials().currentOrg;
59+
}
60+
61+
export function setCurrentOrg(org: string | undefined): void {
62+
const credentials = loadCredentials();
63+
if (org) {
64+
credentials.currentOrg = org;
65+
} else {
66+
delete credentials.currentOrg;
67+
}
68+
saveCredentials(credentials);
69+
}
70+
5671
export { CREDENTIALS_DIR, CREDENTIALS_FILE };

0 commit comments

Comments
 (0)