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>
154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|