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:
SentryAgent.ai Developer
2026-04-03 02:50:32 +00:00
parent 16497706d3
commit 662879f0ee
42 changed files with 6176 additions and 1741 deletions

View 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;
}

View File

@@ -10,6 +10,7 @@ import { registerIssueToken } from './commands/issue-token';
import { registerRotateCredentials } from './commands/rotate-credentials';
import { registerTailAuditLog } from './commands/tail-audit-log';
import { registerCompletion } from './commands/completion';
import { registerScaffold } from './commands/scaffold';
const program = new Command();
@@ -26,6 +27,7 @@ registerIssueToken(program);
registerRotateCredentials(program);
registerTailAuditLog(program);
registerCompletion(program);
registerScaffold(program);
// Parse args — commander will display help automatically on --help
program.parse(process.argv);