feat(phase-4): WS2 + WS3 — Developer Portal (Next.js 14) and CLI tool (sentryagent)
WS2: Developer Portal (portal/) - Standalone Next.js 14 + Tailwind CSS app — independent deployment - Home page: hero, feature grid, CTA to /get-started - /pricing: free tier limits table (10 agents, 1k calls/day) + paid tier CTA - /sdks: all 4 SDKs (Node.js, Python, Go, Java) with install + code examples - /api-explorer: Swagger UI from NEXT_PUBLIC_API_URL/openapi.json, persistAuthorization - /get-started: 4-step wizard (setup → register agent → credentials → SDK snippet) - Shared Nav component with active-link highlighting - Build: 8/8 static pages, zero TypeScript errors WS3: CLI Tool (cli/ — npm package: sentryagent) - configure, register-agent, list-agents, issue-token, rotate-credentials, tail-audit-log - Auto OAuth2 token fetch + 30s-buffer cache via client_credentials flow - chalk-formatted table output, confirmation prompts, bounded audit log dedup - bash + zsh shell completion scripts - README with installation, all commands, and completion setup - Build: tsc clean, node dist/index.js --help verified Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
155
cli/src/commands/completion.ts
Normal file
155
cli/src/commands/completion.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
const BASH_COMPLETION = `
|
||||
# sentryagent bash completion
|
||||
# Add to ~/.bashrc or ~/.bash_profile:
|
||||
# source <(sentryagent completion bash)
|
||||
|
||||
_sentryagent_completion() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="configure register-agent list-agents issue-token rotate-credentials tail-audit-log completion"
|
||||
local global_opts="--help --version"
|
||||
|
||||
case "\${prev}" in
|
||||
sentryagent)
|
||||
COMPREPLY=( \$(compgen -W "\${commands} \${global_opts}" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
configure)
|
||||
COMPREPLY=( \$(compgen -W "--help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
register-agent)
|
||||
COMPREPLY=( \$(compgen -W "--name --description --help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
list-agents)
|
||||
COMPREPLY=( \$(compgen -W "--help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
issue-token)
|
||||
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
rotate-credentials)
|
||||
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
tail-audit-log)
|
||||
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
completion)
|
||||
COMPREPLY=( \$(compgen -W "bash zsh --help" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=()
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _sentryagent_completion sentryagent
|
||||
`.trim();
|
||||
|
||||
const ZSH_COMPLETION = `
|
||||
#compdef sentryagent
|
||||
|
||||
# sentryagent zsh completion
|
||||
# Add to ~/.zshrc:
|
||||
# source <(sentryagent completion zsh)
|
||||
# Or generate a file and place it in your $fpath:
|
||||
# sentryagent completion zsh > ~/.zsh/completions/_sentryagent
|
||||
|
||||
_sentryagent() {
|
||||
local state
|
||||
|
||||
_arguments \\
|
||||
'(-v --version)'{-v,--version}'[Show version]' \\
|
||||
'(-h --help)'{-h,--help}'[Show help]' \\
|
||||
'1: :->command' \\
|
||||
'*: :->args'
|
||||
|
||||
case \$state in
|
||||
command)
|
||||
local commands=(
|
||||
'configure:Configure CLI with API URL and credentials'
|
||||
'register-agent:Register a new agent'
|
||||
'list-agents:List all registered agents'
|
||||
'issue-token:Issue an OAuth2 access token for an agent'
|
||||
'rotate-credentials:Rotate credentials for an agent'
|
||||
'tail-audit-log:Poll and stream audit log events'
|
||||
'completion:Output shell completion script'
|
||||
)
|
||||
_describe 'command' commands
|
||||
;;
|
||||
args)
|
||||
case \${words[2]} in
|
||||
configure)
|
||||
_arguments \\
|
||||
'(-h --help)'{-h,--help}'[Show help]'
|
||||
;;
|
||||
register-agent)
|
||||
_arguments \\
|
||||
'--name[Agent name]:name' \\
|
||||
'--description[Agent description]:description' \\
|
||||
'(-h --help)'{-h,--help}'[Show help]'
|
||||
;;
|
||||
list-agents)
|
||||
_arguments \\
|
||||
'(-h --help)'{-h,--help}'[Show help]'
|
||||
;;
|
||||
issue-token)
|
||||
_arguments \\
|
||||
'--agent-id[Agent ID]:agent-id' \\
|
||||
'(-h --help)'{-h,--help}'[Show help]'
|
||||
;;
|
||||
rotate-credentials)
|
||||
_arguments \\
|
||||
'--agent-id[Agent ID]:agent-id' \\
|
||||
'(-h --help)'{-h,--help}'[Show help]'
|
||||
;;
|
||||
tail-audit-log)
|
||||
_arguments \\
|
||||
'--agent-id[Filter by agent ID]:agent-id' \\
|
||||
'(-h --help)'{-h,--help}'[Show help]'
|
||||
;;
|
||||
completion)
|
||||
local shells=('bash:Generate bash completion script' 'zsh:Generate zsh completion script')
|
||||
_describe 'shell' shells
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_sentryagent "\$@"
|
||||
`.trim();
|
||||
|
||||
export function registerCompletion(program: Command): void {
|
||||
const completion = program
|
||||
.command('completion')
|
||||
.description('Output shell completion scripts');
|
||||
|
||||
completion
|
||||
.command('bash')
|
||||
.description('Output bash completion script')
|
||||
.action(() => {
|
||||
console.log(BASH_COMPLETION);
|
||||
});
|
||||
|
||||
completion
|
||||
.command('zsh')
|
||||
.description('Output zsh completion script')
|
||||
.action(() => {
|
||||
console.log(ZSH_COMPLETION);
|
||||
});
|
||||
|
||||
completion.addHelpText(
|
||||
'after',
|
||||
'\nSupported shells: bash, zsh',
|
||||
);
|
||||
}
|
||||
63
cli/src/commands/configure.ts
Normal file
63
cli/src/commands/configure.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as readline from 'readline';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { writeConfig } from '../config';
|
||||
|
||||
function prompt(rl: readline.Interface, question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerConfigure(program: Command): void {
|
||||
program
|
||||
.command('configure')
|
||||
.description('Configure the CLI with API URL and credentials')
|
||||
.action(async () => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(chalk.bold('SentryAgent CLI Configuration'));
|
||||
console.log(chalk.dim('─'.repeat(40)));
|
||||
|
||||
const apiUrl = await prompt(
|
||||
rl,
|
||||
chalk.cyan('API URL') + ' (e.g. https://api.sentryagent.ai): ',
|
||||
);
|
||||
if (apiUrl === '') {
|
||||
console.error(chalk.red('API URL cannot be empty.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clientId = await prompt(rl, chalk.cyan('Client ID') + ': ');
|
||||
if (clientId === '') {
|
||||
console.error(chalk.red('Client ID cannot be empty.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clientSecret = await prompt(
|
||||
rl,
|
||||
chalk.cyan('Client Secret') + ': ',
|
||||
);
|
||||
if (clientSecret === '') {
|
||||
console.error(chalk.red('Client Secret cannot be empty.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeConfig({ apiUrl, clientId, clientSecret });
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.green('✓') +
|
||||
' Configuration saved to ~/.sentryagent/config.json',
|
||||
);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
70
cli/src/commands/issue-token.ts
Normal file
70
cli/src/commands/issue-token.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { requireConfig } from '../config';
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function registerIssueToken(program: Command): void {
|
||||
program
|
||||
.command('issue-token')
|
||||
.description('Issue an OAuth2 access token for an agent')
|
||||
.requiredOption('--agent-id <id>', 'Agent ID to issue a token for')
|
||||
.action(async (options: { agentId: string }) => {
|
||||
const config = requireConfig();
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
agent_id: options.agentId,
|
||||
});
|
||||
|
||||
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(`Token issuance failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TokenResponse;
|
||||
const expiresAt = new Date(
|
||||
Date.now() + data.expires_in * 1000,
|
||||
).toISOString();
|
||||
|
||||
console.log(chalk.green('✓') + ' Token issued successfully');
|
||||
console.log();
|
||||
console.log(chalk.bold('Access Token:'));
|
||||
console.log(chalk.cyan(data.access_token));
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.bold('Token Type: ') + data.token_type,
|
||||
);
|
||||
console.log(
|
||||
chalk.bold('Expires In: ') + `${data.expires_in}s`,
|
||||
);
|
||||
console.log(
|
||||
chalk.bold('Expires At: ') + chalk.dim(expiresAt),
|
||||
);
|
||||
if (data.scope !== undefined) {
|
||||
console.log(chalk.bold('Scope: ') + data.scope);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
105
cli/src/commands/list-agents.ts
Normal file
105
cli/src/commands/list-agents.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { requireConfig } from '../config';
|
||||
import { apiRequest } from '../api';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
agents: Agent[];
|
||||
total?: number;
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
function padEnd(str: string, len: number): string {
|
||||
return str.padEnd(len, ' ');
|
||||
}
|
||||
|
||||
export function registerListAgents(program: Command): void {
|
||||
program
|
||||
.command('list-agents')
|
||||
.description('List all registered agents')
|
||||
.action(async () => {
|
||||
const config = requireConfig();
|
||||
|
||||
try {
|
||||
const data = await apiRequest<AgentsResponse | Agent[]>(
|
||||
config,
|
||||
'/agents',
|
||||
);
|
||||
|
||||
const agents: Agent[] = Array.isArray(data)
|
||||
? data
|
||||
: (data as AgentsResponse).agents ?? [];
|
||||
|
||||
if (agents.length === 0) {
|
||||
console.log(chalk.yellow('No agents found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ID_W = 26;
|
||||
const NAME_W = 24;
|
||||
const STATUS_W = 10;
|
||||
const DATE_W = 20;
|
||||
|
||||
const header =
|
||||
chalk.bold(padEnd('AGENT ID', ID_W)) +
|
||||
' ' +
|
||||
chalk.bold(padEnd('NAME', NAME_W)) +
|
||||
' ' +
|
||||
chalk.bold(padEnd('STATUS', STATUS_W)) +
|
||||
' ' +
|
||||
chalk.bold('CREATED AT');
|
||||
|
||||
const divider = chalk.dim(
|
||||
'─'.repeat(ID_W + NAME_W + STATUS_W + DATE_W + 6),
|
||||
);
|
||||
|
||||
console.log(header);
|
||||
console.log(divider);
|
||||
|
||||
for (const agent of agents) {
|
||||
const statusColor =
|
||||
agent.status === 'active'
|
||||
? chalk.green
|
||||
: agent.status === 'inactive'
|
||||
? chalk.yellow
|
||||
: chalk.red;
|
||||
|
||||
const createdAt = new Date(agent.createdAt).toLocaleString();
|
||||
|
||||
console.log(
|
||||
chalk.cyan(padEnd(truncate(agent.id, ID_W), ID_W)) +
|
||||
' ' +
|
||||
padEnd(truncate(agent.name, NAME_W), NAME_W) +
|
||||
' ' +
|
||||
statusColor(padEnd(truncate(agent.status, STATUS_W), STATUS_W)) +
|
||||
' ' +
|
||||
chalk.dim(truncate(createdAt, DATE_W)),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(divider);
|
||||
const total = Array.isArray(data)
|
||||
? agents.length
|
||||
: ((data as AgentsResponse).total ?? agents.length);
|
||||
console.log(chalk.dim(`Total: ${total}`));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
54
cli/src/commands/register-agent.ts
Normal file
54
cli/src/commands/register-agent.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { requireConfig } from '../config';
|
||||
import { apiRequest } from '../api';
|
||||
|
||||
interface AgentResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function registerRegisterAgent(program: Command): void {
|
||||
program
|
||||
.command('register-agent')
|
||||
.description('Register a new agent')
|
||||
.requiredOption('--name <name>', 'Agent name')
|
||||
.option('--description <desc>', 'Agent description')
|
||||
.action(async (options: { name: string; description?: string }) => {
|
||||
const config = requireConfig();
|
||||
|
||||
try {
|
||||
const body: { name: string; description?: string } = {
|
||||
name: options.name,
|
||||
};
|
||||
if (options.description !== undefined) {
|
||||
body.description = options.description;
|
||||
}
|
||||
|
||||
const agent = await apiRequest<AgentResponse>(config, '/agents', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
console.log(chalk.green('✓') + ' Agent registered successfully');
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.bold('Agent ID: ') + chalk.cyan(agent.id),
|
||||
);
|
||||
console.log(chalk.bold('Name: ') + agent.name);
|
||||
if (agent.description !== undefined) {
|
||||
console.log(chalk.bold('Description:') + ' ' + agent.description);
|
||||
}
|
||||
console.log(chalk.bold('Status: ') + agent.status);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
85
cli/src/commands/rotate-credentials.ts
Normal file
85
cli/src/commands/rotate-credentials.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as readline from 'readline';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { requireConfig } from '../config';
|
||||
import { apiRequest } from '../api';
|
||||
|
||||
interface RotateResponse {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
rotatedAt?: string;
|
||||
}
|
||||
|
||||
function prompt(rl: readline.Interface, question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerRotateCredentials(program: Command): void {
|
||||
program
|
||||
.command('rotate-credentials')
|
||||
.description('Rotate credentials for an agent (invalidates current secret)')
|
||||
.requiredOption('--agent-id <id>', 'Agent ID whose credentials to rotate')
|
||||
.action(async (options: { agentId: string }) => {
|
||||
const config = requireConfig();
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(
|
||||
chalk.yellow('⚠') +
|
||||
' This will invalidate the current secret for agent ' +
|
||||
chalk.cyan(options.agentId),
|
||||
);
|
||||
|
||||
const answer = await prompt(
|
||||
rl,
|
||||
chalk.bold('This will invalidate the current secret. Continue? [y/N] '),
|
||||
);
|
||||
|
||||
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
||||
console.log(chalk.dim('Aborted.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await apiRequest<RotateResponse>(
|
||||
config,
|
||||
`/agents/${options.agentId}/credentials/rotate`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
console.log();
|
||||
console.log(chalk.green('✓') + ' Credentials rotated successfully');
|
||||
console.log();
|
||||
console.log(chalk.bold('Client ID: ') + chalk.cyan(data.clientId));
|
||||
console.log(
|
||||
chalk.bold('Client Secret: ') + chalk.yellow(data.clientSecret),
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.dim(
|
||||
'Store the new client secret securely — it will not be shown again.',
|
||||
),
|
||||
);
|
||||
if (data.rotatedAt !== undefined) {
|
||||
console.log(
|
||||
chalk.dim('Rotated at: ') + chalk.dim(data.rotatedAt),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.red('Error:'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
122
cli/src/commands/tail-audit-log.ts
Normal file
122
cli/src/commands/tail-audit-log.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { requireConfig } from '../config';
|
||||
import { apiRequest } from '../api';
|
||||
|
||||
interface AuditEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
agentId?: string;
|
||||
tenantId?: string;
|
||||
outcome: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AuditLogsResponse {
|
||||
events: AuditEvent[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
function formatEvent(event: AuditEvent): string {
|
||||
const ts = chalk.dim(new Date(event.timestamp).toLocaleString());
|
||||
const outcome =
|
||||
event.outcome === 'success'
|
||||
? chalk.green(event.outcome)
|
||||
: chalk.red(event.outcome);
|
||||
const action = chalk.cyan(event.action);
|
||||
const agentPart =
|
||||
event.agentId !== undefined
|
||||
? ' ' + chalk.dim('agent=' + event.agentId)
|
||||
: '';
|
||||
|
||||
return `${ts} ${action} outcome=${outcome}${agentPart} id=${chalk.dim(event.id)}`;
|
||||
}
|
||||
|
||||
export function registerTailAuditLog(program: Command): void {
|
||||
program
|
||||
.command('tail-audit-log')
|
||||
.description(
|
||||
'Poll and stream audit log events every 5 seconds (Ctrl+C to stop)',
|
||||
)
|
||||
.option('--agent-id <id>', 'Filter events for a specific agent ID')
|
||||
.action(async (options: { agentId?: string }) => {
|
||||
const config = requireConfig();
|
||||
|
||||
console.log(
|
||||
chalk.bold('Tailing audit log') +
|
||||
(options.agentId !== undefined
|
||||
? chalk.dim(` (agent: ${options.agentId})`)
|
||||
: '') +
|
||||
chalk.dim(' — press Ctrl+C to stop'),
|
||||
);
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
let running = true;
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
running = false;
|
||||
console.log();
|
||||
console.log(chalk.dim('Stopped.'));
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
while (running) {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (options.agentId !== undefined) {
|
||||
params['agentId'] = options.agentId;
|
||||
}
|
||||
if (cursor !== undefined) {
|
||||
params['cursor'] = cursor;
|
||||
}
|
||||
// Request events from the last poll window
|
||||
params['limit'] = '50';
|
||||
|
||||
const data = await apiRequest<AuditLogsResponse | AuditEvent[]>(
|
||||
config,
|
||||
'/audit/logs',
|
||||
{ params },
|
||||
);
|
||||
|
||||
const events: AuditEvent[] = Array.isArray(data)
|
||||
? data
|
||||
: (data as AuditLogsResponse).events ?? [];
|
||||
|
||||
if (!Array.isArray(data) && (data as AuditLogsResponse).nextCursor !== undefined) {
|
||||
cursor = (data as AuditLogsResponse).nextCursor;
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id);
|
||||
console.log(formatEvent(event));
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the seenIds set bounded to avoid unbounded memory growth
|
||||
if (seenIds.size > 10_000) {
|
||||
const arr = Array.from(seenIds);
|
||||
const keep = arr.slice(arr.length - 5_000);
|
||||
seenIds.clear();
|
||||
for (const id of keep) seenIds.add(id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
chalk.yellow('⚠') +
|
||||
' Poll error: ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
|
||||
// Wait 5 seconds between polls
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
// Allow the timer to be garbage-collected if process exits
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user