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:
150
cli/package-lock.json
generated
150
cli/package-lock.json
generated
@@ -9,8 +9,10 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0"
|
||||
"commander": "^12.1.0",
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"bin": {
|
||||
"sentryagent": "dist/index.js"
|
||||
@@ -97,12 +99,20 @@
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unzipper": {
|
||||
"version": "0.10.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz",
|
||||
"integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -136,6 +146,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -157,6 +173,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
@@ -174,6 +196,59 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
|
||||
"integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
@@ -181,6 +256,48 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-node": {
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
@@ -243,7 +360,34 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
|
||||
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bluebird": "~3.7.2",
|
||||
"duplexer2": "~0.1.4",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graceful-fs": "^4.2.2",
|
||||
"node-int64": "^0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0"
|
||||
"commander": "^12.1.0",
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.7",
|
||||
"typescript": "^5.4.5",
|
||||
"ts-node": "^10.9.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user