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,153 @@
/**
* Scaffold Service for SentryAgent.ai AgentIdP.
* Generates in-memory ZIP archives containing language-specific starter projects
* pre-wired with the requesting agent's credentials.
*/
import archiver from 'archiver';
import { PassThrough } from 'stream';
import { readFileSync } from 'fs';
import { join } from 'path';
import { ScaffoldLanguage, ScaffoldOptions } from '../types/scaffold.js';
import { ValidationError } from '../utils/errors.js';
import {
scaffoldGeneratedTotal,
scaffoldGenerationDurationMs,
} from '../metrics/registry.js';
/** Map of language → list of template file paths (relative to the language directory). */
const TEMPLATE_FILES: Record<ScaffoldLanguage, string[]> = {
typescript: [
'package.json.tmpl',
'tsconfig.json.tmpl',
'src/index.ts.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
python: [
'requirements.txt.tmpl',
'main.py.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
go: [
'go.mod.tmpl',
'main.go.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
java: [
'pom.xml.tmpl',
'src/main/java/Main.java.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
rust: [
'Cargo.toml.tmpl',
'src/main.rs.tmpl',
'.env.example.tmpl',
'.gitignore.tmpl',
'README.md.tmpl',
],
};
/** Valid scaffold language values for input validation. */
export const SCAFFOLD_LANGUAGES: ScaffoldLanguage[] = [
'typescript',
'python',
'go',
'java',
'rust',
];
/** Interface contract for the scaffold service. */
export interface IScaffoldService {
/**
* Generate an in-memory ZIP archive for the given agent and language.
* Template variables injected: {{AGENT_ID}}, {{AGENT_NAME}}, {{CLIENT_ID}}, {{API_URL}}
*
* @param options - Scaffold generation options.
* @returns Readable stream of the ZIP binary and the filename for Content-Disposition.
*/
generateScaffold(options: ScaffoldOptions): Promise<{ stream: NodeJS.ReadableStream; filename: string }>;
}
/**
* Implementation of IScaffoldService.
* Generates scaffold ZIPs in memory using the `archiver` library — no disk writes.
*/
export class ScaffoldService implements IScaffoldService {
private readonly templatesDir: string;
constructor() {
// __dirname is available because the project compiles to CommonJS
this.templatesDir = join(__dirname, '..', 'templates', 'scaffold');
}
/**
* Generates an in-memory ZIP scaffold for the given agent.
*
* @param options - Agent metadata and target language.
* @returns Object with a readable stream of the ZIP and the ZIP filename.
* @throws ValidationError if the language is not supported.
*/
async generateScaffold(
options: ScaffoldOptions,
): Promise<{ stream: NodeJS.ReadableStream; filename: string }> {
const { agentId, agentName, clientId, language, apiUrl } = options;
if (!SCAFFOLD_LANGUAGES.includes(language)) {
throw new ValidationError(
`Unsupported language '${language}'. Choose: ${SCAFFOLD_LANGUAGES.join(', ')}`,
{ code: 'INVALID_LANGUAGE' },
);
}
const startMs = Date.now();
const safeAgentName = agentName.replace(/[^a-zA-Z0-9-_]/g, '-').toLowerCase();
const projectDir = `sentryagent-scaffold-${safeAgentName}-${language}`;
const filename = `${projectDir}.zip`;
const archive = archiver('zip', { zlib: { level: 6 } });
const passThrough = new PassThrough();
archive.pipe(passThrough);
const templateFiles = TEMPLATE_FILES[language];
for (const templateFile of templateFiles) {
const templatePath = join(this.templatesDir, language, templateFile);
const rawContent = readFileSync(templatePath, 'utf-8');
const content = this.injectVariables(rawContent, { agentId, agentName, clientId, apiUrl });
// Strip .tmpl extension for the archive entry
const archiveEntry = templateFile.endsWith('.tmpl')
? templateFile.slice(0, -5)
: templateFile;
archive.append(content, { name: `${projectDir}/${archiveEntry}` });
}
archive.finalize().catch((err: unknown) => {
passThrough.destroy(err instanceof Error ? err : new Error(String(err)));
});
const durationMs = Date.now() - startMs;
scaffoldGeneratedTotal.labels(language).inc();
scaffoldGenerationDurationMs.labels(language).observe(durationMs);
return { stream: passThrough, filename };
}
/** Replaces all template variable placeholders in a template string. */
private injectVariables(
template: string,
vars: { agentId: string; agentName: string; clientId: string; apiUrl: string },
): string {
return template
.replace(/\{\{AGENT_ID\}\}/g, vars.agentId)
.replace(/\{\{AGENT_NAME\}\}/g, vars.agentName)
.replace(/\{\{CLIENT_ID\}\}/g, vars.clientId)
.replace(/\{\{API_URL\}\}/g, vars.apiUrl);
}
}