Skip to content

Commit eda0f5d

Browse files
aidandaly24claude
andauthored
feat(gateway): add agentcore fetch access command (#627)
* feat(gateway): add `agentcore fetch access` command Add CLI + TUI for fetching gateway access info and tokens. Supports NONE (URL only), AWS_IAM (SigV4 guidance), and CUSTOM_JWT (OAuth client_credentials token fetch with OIDC discovery). - CLI: `agentcore fetch access --name <gw> [--target] [--json]` - TUI: interactive gateway picker with auth type badges - Token copy to clipboard (C key) with visual feedback - 3-legged OAuth detection with clear unsupported error - Token expiry tracking with refresh (R key) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(gateway): address PR review feedback for fetch access - Extract business logic from command.tsx into action.ts + types.ts - Slim command.tsx to registration-only, delegating to handleFetchAccess - Fix export ordering in operations/index.ts (alphabetical) - Add spawn error handler to prevent unhandled ENOENT crashes - Cap setTimeout to 2^31-1ms to avoid immediate-fire on large expiresIn - Clean up copiedTimerRef on unmount to prevent timer leak Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(gateway): move process.exit outside try/catch in fetch command Fixes test failures where mocked process.exit throws, cascading into the catch block and causing console.log to be called twice. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 30b781f commit eda0f5d

File tree

20 files changed

+1785
-0
lines changed

20 files changed

+1785
-0
lines changed

src/cli/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { registerCreate } from './commands/create';
33
import { registerDeploy } from './commands/deploy';
44
import { registerDev } from './commands/dev';
55
import { registerEval } from './commands/eval';
6+
import { registerFetch } from './commands/fetch';
67
import { registerHelp } from './commands/help';
78
import { registerInvoke } from './commands/invoke';
89
import { registerLogs } from './commands/logs';
@@ -135,6 +136,7 @@ export function registerCommands(program: Command) {
135136
registerDeploy(program);
136137
registerCreate(program);
137138
registerEval(program);
139+
registerFetch(program);
138140
registerHelp(program);
139141
registerInvoke(program);
140142
registerLogs(program);
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { registerFetch } from '../command';
2+
import { Command } from '@commander-js/extra-typings';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
const mockFetchGatewayToken = vi.fn();
6+
const mockListGateways = vi.fn();
7+
const mockRequireProject = vi.fn();
8+
const mockRender = vi.fn();
9+
10+
vi.mock('../../../operations/fetch-access', () => ({
11+
fetchGatewayToken: (...args: unknown[]) => mockFetchGatewayToken(...args),
12+
listGateways: (...args: unknown[]) => mockListGateways(...args),
13+
}));
14+
15+
vi.mock('../../../tui/guards', () => ({
16+
requireProject: (...args: unknown[]) => mockRequireProject(...args),
17+
}));
18+
19+
vi.mock('ink', () => ({
20+
render: (...args: unknown[]) => mockRender(...args),
21+
Box: 'Box',
22+
Text: 'Text',
23+
}));
24+
25+
const jwtResult = {
26+
url: 'https://gw.example.com',
27+
authType: 'CUSTOM_JWT',
28+
token: 'test-token',
29+
expiresIn: 3600,
30+
};
31+
32+
const noneResult = {
33+
url: 'https://gw.example.com',
34+
authType: 'NONE',
35+
message: 'No authentication required.',
36+
};
37+
38+
describe('registerFetch', () => {
39+
let program: Command;
40+
let mockExit: ReturnType<typeof vi.spyOn>;
41+
let mockLog: ReturnType<typeof vi.spyOn>;
42+
43+
beforeEach(() => {
44+
program = new Command();
45+
program.exitOverride();
46+
registerFetch(program);
47+
48+
mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
49+
throw new Error('process.exit');
50+
});
51+
mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
52+
});
53+
54+
afterEach(() => {
55+
mockExit.mockRestore();
56+
mockLog.mockRestore();
57+
vi.clearAllMocks();
58+
});
59+
60+
it('registers fetch command with access subcommand', () => {
61+
const fetchCmd = program.commands.find(c => c.name() === 'fetch');
62+
expect(fetchCmd).toBeDefined();
63+
64+
const accessCmd = fetchCmd!.commands.find(c => c.name() === 'access');
65+
expect(accessCmd).toBeDefined();
66+
});
67+
68+
it('outputs valid JSON for CUSTOM_JWT result when --json flag is used', async () => {
69+
mockFetchGatewayToken.mockResolvedValue(jwtResult);
70+
71+
await program.parseAsync(['fetch', 'access', '--name', 'myGateway', '--json'], { from: 'user' });
72+
73+
expect(mockLog).toHaveBeenCalledTimes(1);
74+
const output = JSON.parse(mockLog.mock.calls[0][0]);
75+
expect(output.success).toBe(true);
76+
expect(output.url).toBe('https://gw.example.com');
77+
expect(output.authType).toBe('CUSTOM_JWT');
78+
expect(output.token).toBe('test-token');
79+
expect(output.expiresIn).toBe(3600);
80+
});
81+
82+
it('outputs valid JSON with no token field for NONE gateway when --json flag is used', async () => {
83+
mockFetchGatewayToken.mockResolvedValue(noneResult);
84+
85+
await program.parseAsync(['fetch', 'access', '--name', 'myGateway', '--json'], { from: 'user' });
86+
87+
expect(mockLog).toHaveBeenCalledTimes(1);
88+
const output = JSON.parse(mockLog.mock.calls[0][0]);
89+
expect(output.success).toBe(true);
90+
expect(output.url).toBe('https://gw.example.com');
91+
expect(output.authType).toBe('NONE');
92+
expect(output.token).toBeUndefined();
93+
expect(output.message).toBe('No authentication required.');
94+
});
95+
96+
it('shows error with available gateways when --name is missing and gateways exist', async () => {
97+
mockListGateways.mockResolvedValue([
98+
{ name: 'gateway-one', authType: 'CUSTOM_JWT' },
99+
{ name: 'gateway-two', authType: 'NONE' },
100+
]);
101+
102+
await expect(program.parseAsync(['fetch', 'access'], { from: 'user' })).rejects.toThrow('process.exit');
103+
104+
expect(mockRender).toHaveBeenCalled();
105+
const renderArg = mockRender.mock.calls[0]![0];
106+
expect(JSON.stringify(renderArg)).toContain('Missing required option');
107+
});
108+
109+
it('shows deploy message when --name is missing and no gateways are deployed', async () => {
110+
mockListGateways.mockResolvedValue([]);
111+
112+
await expect(program.parseAsync(['fetch', 'access'], { from: 'user' })).rejects.toThrow('process.exit');
113+
114+
expect(mockRender).toHaveBeenCalled();
115+
const renderArg = mockRender.mock.calls[0]![0];
116+
expect(JSON.stringify(renderArg)).toContain('agentcore deploy');
117+
});
118+
119+
it('outputs JSON error with available gateways when --name is missing and --json flag is used', async () => {
120+
mockListGateways.mockResolvedValue([
121+
{ name: 'gateway-one', authType: 'CUSTOM_JWT' },
122+
{ name: 'gateway-two', authType: 'NONE' },
123+
]);
124+
125+
await expect(program.parseAsync(['fetch', 'access', '--json'], { from: 'user' })).rejects.toThrow('process.exit');
126+
127+
expect(mockLog).toHaveBeenCalledTimes(1);
128+
const output = JSON.parse(mockLog.mock.calls[0][0]);
129+
expect(output.success).toBe(false);
130+
expect(output.error).toBe('Missing required option: --name');
131+
expect(output.availableGateways).toEqual([
132+
{ name: 'gateway-one', authType: 'CUSTOM_JWT' },
133+
{ name: 'gateway-two', authType: 'NONE' },
134+
]);
135+
expect(mockRender).not.toHaveBeenCalled();
136+
});
137+
138+
it('outputs JSON deploy message when --name is missing, --json flag is used, and no gateways deployed', async () => {
139+
mockListGateways.mockResolvedValue([]);
140+
141+
await expect(program.parseAsync(['fetch', 'access', '--json'], { from: 'user' })).rejects.toThrow('process.exit');
142+
143+
expect(mockLog).toHaveBeenCalledTimes(1);
144+
const output = JSON.parse(mockLog.mock.calls[0][0]);
145+
expect(output.success).toBe(false);
146+
expect(output.error).toContain('agentcore deploy');
147+
expect(output.availableGateways).toBeUndefined();
148+
expect(mockRender).not.toHaveBeenCalled();
149+
});
150+
151+
it('outputs JSON error when fetchGatewayToken throws and --json flag is used', async () => {
152+
mockFetchGatewayToken.mockRejectedValue(new Error('Token fetch failed'));
153+
154+
await expect(
155+
program.parseAsync(['fetch', 'access', '--name', 'myGateway', '--json'], { from: 'user' })
156+
).rejects.toThrow('process.exit');
157+
158+
expect(mockLog).toHaveBeenCalledTimes(1);
159+
const output = JSON.parse(mockLog.mock.calls[0][0]);
160+
expect(output.success).toBe(false);
161+
expect(output.error).toBe('Token fetch failed');
162+
expect(mockRender).not.toHaveBeenCalled();
163+
});
164+
165+
it('shows error message when fetchGatewayToken throws', async () => {
166+
mockFetchGatewayToken.mockRejectedValue(new Error('Token fetch failed'));
167+
168+
await expect(program.parseAsync(['fetch', 'access', '--name', 'myGateway'], { from: 'user' })).rejects.toThrow(
169+
'process.exit'
170+
);
171+
172+
expect(mockRender).toHaveBeenCalled();
173+
const renderArg = mockRender.mock.calls[0]![0];
174+
expect(JSON.stringify(renderArg)).toContain('Token fetch failed');
175+
});
176+
});

src/cli/commands/fetch/action.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { fetchGatewayToken, listGateways } from '../../operations/fetch-access';
2+
import type { TokenFetchResult } from '../../operations/fetch-access';
3+
import type { FetchAccessOptions } from './types';
4+
5+
export interface FetchAccessResult {
6+
success: boolean;
7+
result?: TokenFetchResult;
8+
availableGateways?: { name: string; authType: string }[];
9+
error?: string;
10+
}
11+
12+
export async function handleFetchAccess(options: FetchAccessOptions): Promise<FetchAccessResult> {
13+
if (!options.name) {
14+
const gateways = await listGateways({ deployTarget: options.target });
15+
if (gateways.length === 0) {
16+
return { success: false, error: 'No deployed gateways found. Run `agentcore deploy` first.' };
17+
}
18+
return {
19+
success: false,
20+
error: 'Missing required option: --name',
21+
availableGateways: gateways,
22+
};
23+
}
24+
25+
const result = await fetchGatewayToken(options.name, { deployTarget: options.target });
26+
return { success: true, result };
27+
}

src/cli/commands/fetch/command.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { getErrorMessage } from '../../errors';
2+
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
3+
import { requireProject } from '../../tui/guards';
4+
import { handleFetchAccess } from './action';
5+
import type { FetchAccessResult } from './action';
6+
import type { FetchAccessOptions } from './types';
7+
import type { Command } from '@commander-js/extra-typings';
8+
import { Box, Text, render } from 'ink';
9+
10+
export const registerFetch = (program: Command) => {
11+
const fetchCmd = program.command('fetch').description(COMMAND_DESCRIPTIONS.fetch);
12+
13+
fetchCmd
14+
.command('access')
15+
.description('Fetch access info (URL, token, auth guidance) for a deployed gateway.')
16+
.option('--name <resource>', 'Gateway name')
17+
.option('--target <target>', 'Deployment target')
18+
.option('--json', 'Output as JSON')
19+
.action(async (cliOptions: FetchAccessOptions) => {
20+
requireProject();
21+
22+
let result: FetchAccessResult;
23+
try {
24+
result = await handleFetchAccess(cliOptions);
25+
} catch (error) {
26+
if (cliOptions.json) {
27+
console.log(JSON.stringify({ success: false, error: getErrorMessage(error) }));
28+
} else {
29+
render(<Text color="red">Error: {getErrorMessage(error)}</Text>);
30+
}
31+
process.exit(1);
32+
return;
33+
}
34+
35+
if (!result.success) {
36+
if (cliOptions.json) {
37+
console.log(
38+
JSON.stringify({
39+
success: false,
40+
error: result.error,
41+
...(result.availableGateways && { availableGateways: result.availableGateways }),
42+
})
43+
);
44+
} else if (!result.availableGateways) {
45+
render(<Text color="red">{result.error}</Text>);
46+
} else {
47+
render(
48+
<Box flexDirection="column">
49+
<Text color="red">{result.error}</Text>
50+
<Text>Available gateways:</Text>
51+
{result.availableGateways.map(gw => (
52+
<Text key={gw.name}>
53+
{' '}
54+
{gw.name} [{gw.authType}]
55+
</Text>
56+
))}
57+
</Box>
58+
);
59+
}
60+
process.exit(1);
61+
return;
62+
}
63+
64+
if (cliOptions.json) {
65+
console.log(JSON.stringify({ success: true, ...result.result }, null, 2));
66+
return;
67+
}
68+
69+
const r = result.result!;
70+
render(
71+
<Box flexDirection="column">
72+
<Text>
73+
<Text bold>URL:</Text>
74+
<Text color="green"> {r.url}</Text>
75+
</Text>
76+
<Text>
77+
<Text bold>Auth:</Text> {r.authType}
78+
</Text>
79+
{r.message && <Text>{r.message}</Text>}
80+
{r.token && (
81+
<Text>
82+
<Text bold>Token:</Text> {r.token}
83+
</Text>
84+
)}
85+
{r.expiresIn !== undefined && (
86+
<Text>
87+
<Text bold>Expires in:</Text> {r.expiresIn}s
88+
</Text>
89+
)}
90+
</Box>
91+
);
92+
});
93+
};

src/cli/commands/fetch/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { registerFetch } from './command';

src/cli/commands/fetch/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface FetchAccessOptions {
2+
name?: string;
3+
target?: string;
4+
json?: boolean;
5+
}

src/cli/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { registerDeploy } from './deploy';
44
export { registerDev } from './dev';
55
export { registerCreate } from './create';
66
export { registerEval } from './eval';
7+
export { registerFetch } from './fetch';
78
export { registerInvoke } from './invoke';
89
export { registerPackage } from './package';
910
export { registerPause } from './pause';

0 commit comments

Comments
 (0)