feat(phase-5): WS5 — Developer Experience
Implements scaffold ZIP generator, Stoplight Elements API explorer, and CLI scaffold command: Scaffold API: - 25 template files for TypeScript/Python/Go/Java/Rust in src/templates/scaffold/ - ScaffoldService: in-memory ZIP via archiver, variable injection (AGENT_ID/NAME/CLIENT_ID/API_URL) - ScaffoldController: tenant ownership check (403), language validation (400), ZIP stream response - Route GET /sdk/scaffold/:agentId with rate limiter (10 req/min per tenant) - Prometheus: scaffold_generated_total + scaffold_generation_duration_ms histogram Portal: - Replaced swagger-ui-react with @stoplight/elements API component - Dynamic import (ssr: false) for browser-only DOM dependency - Type declarations for @stoplight/elements and CSS module CLI: - sentryagent scaffold --agent-id <id> [--language typescript] [--out .] - Raw fetch for binary ZIP stream → unzipper.Extract() → prints next steps - Human-readable 400/403/404 error messages Tests: 19 tests (unit + integration), ScaffoldService 80%+ branch coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
173
cli/src/commands/scaffold.ts
Normal file
173
cli/src/commands/scaffold.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import unzipper from 'unzipper';
|
||||
import { requireConfig } from '../config';
|
||||
|
||||
const VALID_LANGUAGES = ['typescript', 'python', 'go', 'java', 'rust'] as const;
|
||||
type ScaffoldLanguage = (typeof VALID_LANGUAGES)[number];
|
||||
|
||||
function isValidLanguage(lang: string): lang is ScaffoldLanguage {
|
||||
return (VALID_LANGUAGES as readonly string[]).includes(lang);
|
||||
}
|
||||
|
||||
export function registerScaffold(program: Command): void {
|
||||
program
|
||||
.command('scaffold')
|
||||
.description('Download a starter project scaffold pre-wired with your agent credentials')
|
||||
.requiredOption('--agent-id <id>', 'Agent ID to scaffold for')
|
||||
.option(
|
||||
'--language <lang>',
|
||||
`SDK language (${VALID_LANGUAGES.join(', ')})`,
|
||||
'typescript',
|
||||
)
|
||||
.option('--out <directory>', 'Output directory for the extracted scaffold', '.')
|
||||
.action(async (opts: { agentId: string; language: string; out: string }) => {
|
||||
const { agentId, language, out: outDir } = opts;
|
||||
|
||||
if (!isValidLanguage(language)) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
`Unsupported language '${language}'. Choose: ${VALID_LANGUAGES.join(', ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = requireConfig();
|
||||
|
||||
// Resolve and create output directory
|
||||
const resolvedOut = path.resolve(outDir);
|
||||
if (!fs.existsSync(resolvedOut)) {
|
||||
fs.mkdirSync(resolvedOut, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.dim(`Downloading ${language} scaffold for agent ${agentId}...`),
|
||||
);
|
||||
|
||||
try {
|
||||
// We need a raw binary response — fetch the token via apiRequest pattern
|
||||
// then make a raw fetch for the ZIP stream.
|
||||
const token = await getToken(config);
|
||||
|
||||
const url = `${config.apiUrl}/sdk/scaffold/${encodeURIComponent(agentId)}?language=${encodeURIComponent(language)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
handleHttpError(res.status, text);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (res.body === null) {
|
||||
console.error(chalk.red('Error:'), 'Empty response body from server.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pipe the response body through unzipper into the output directory
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const nodeStream = streamFromWeb(res.body!);
|
||||
nodeStream
|
||||
.pipe(unzipper.Extract({ path: resolvedOut }))
|
||||
.on('close', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
console.log(chalk.green('Scaffold extracted to:'), chalk.bold(resolvedOut));
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(
|
||||
` 1. ${chalk.cyan('cd')} ${resolvedOut}`,
|
||||
);
|
||||
if (language === 'typescript') {
|
||||
console.log(` 2. ${chalk.cyan('npm install')}`);
|
||||
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
|
||||
console.log(` 4. ${chalk.cyan('npm run dev')}`);
|
||||
} else if (language === 'python') {
|
||||
console.log(` 2. ${chalk.cyan('pip install -r requirements.txt')}`);
|
||||
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
|
||||
console.log(` 4. ${chalk.cyan('python main.py')}`);
|
||||
} else if (language === 'go') {
|
||||
console.log(` 2. ${chalk.cyan('go mod download')}`);
|
||||
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
|
||||
console.log(` 4. ${chalk.cyan('go run main.go')}`);
|
||||
} else if (language === 'java') {
|
||||
console.log(` 2. ${chalk.cyan('mvn install')}`);
|
||||
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
|
||||
console.log(` 4. ${chalk.cyan('mvn exec:java')}`);
|
||||
} else if (language === 'rust') {
|
||||
console.log(` 2. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
|
||||
console.log(` 3. ${chalk.cyan('cargo run')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Obtain a bearer token by making a dummy apiRequest that uses the token cache. */
|
||||
async function getToken(config: import('../config').Config): Promise<string> {
|
||||
// apiRequest internally calls fetchToken which caches tokens.
|
||||
// We retrieve the token by triggering any valid request, but that's wasteful.
|
||||
// Instead, duplicate the token fetch logic inline to avoid making an extra API call.
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
});
|
||||
|
||||
const res = await fetch(`${config.apiUrl}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Authentication failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { access_token: string };
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
function handleHttpError(status: number, body: string): void {
|
||||
if (status === 400) {
|
||||
console.error(chalk.red('Error:'), `Invalid request: ${body}`);
|
||||
} else if (status === 401) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
'Authentication failed. Run `sentryagent configure` to update credentials.',
|
||||
);
|
||||
} else if (status === 403) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
'Access denied. You do not own this agent.',
|
||||
);
|
||||
} else if (status === 404) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
'Agent not found. Check the agent ID with `sentryagent list-agents`.',
|
||||
);
|
||||
} else {
|
||||
console.error(chalk.red('Error:'), `Server error (${status}): ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a WHATWG ReadableStream (from fetch) to a Node.js Readable stream.
|
||||
* Node 18+ supports ReadableStream natively via stream.Readable.fromWeb().
|
||||
*/
|
||||
function streamFromWeb(webStream: ReadableStream<Uint8Array>): NodeJS.ReadableStream {
|
||||
// Node.js 18+ has stream.Readable.fromWeb
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Readable } = require('stream') as typeof import('stream');
|
||||
return Readable.fromWeb(webStream as Parameters<typeof Readable.fromWeb>[0]) as NodeJS.ReadableStream;
|
||||
}
|
||||
Reference in New Issue
Block a user