/** * 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 = { 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); } }