This commit is contained in:
ozymandiashh 2026-05-12 03:06:39 +00:00 committed by GitHub
commit faae00fb69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 688 additions and 1 deletions

View file

@ -93,6 +93,7 @@
## 0.9.7 - 2026-05-07
### Added (CLI)
- **Redacted share bundles.** New `codeburn share` command writes a local JSON support bundle with project/session/turn structure while pseudonymizing project labels and redacting common emails, local paths, credentials, bearer tokens, and API keys. Supports period, date range, provider, project, exclude, and output-path filters.
- **MCP tool coverage detector.** New `optimize` finding flags MCP servers
whose tool inventory is largely unused. Inventory is observed from the
Claude `deferred_tools_delta` JSONL attachments (exact tool names per

View file

@ -78,6 +78,7 @@ codeburn status # compact one-liner (today + month)
codeburn status --format json
codeburn export # CSV with today, 7 days, 30 days
codeburn export -f json # JSON export
codeburn share # redacted JSON bundle for support/debugging
codeburn optimize # find waste, get copy-paste fixes
codeburn optimize -p week # scope the scan to last 7 days
codeburn compare # side-by-side model comparison
@ -123,7 +124,7 @@ Provider logos are trademarks of their respective owners. The icon set was sourc
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `status`, `export`, `optimize`, `compare`, `yield`.
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `status`, `export`, `share`, `optimize`, `compare`, `yield`.
### Provider Notes
@ -327,6 +328,19 @@ codeburn today --format json | jq '.overview.cost'
For lighter output, use `status --format json` (today and month totals only) or file exports (`export -f json`).
### Redacted Share
Create a local support bundle without posting raw prompts, project labels, absolute paths, emails, or common API tokens:
```bash
codeburn share # 7-day redacted JSON bundle
codeburn share -p 30days # last 30 days
codeburn share --provider claude # provider-specific bundle
codeburn share --project api -o api-share.json
```
The bundle keeps enough structure to debug provider parsing and cost attribution: pseudonymous projects, sessions, turns, models, token usage, tools, activity categories, and costs. Redaction is best-effort; review the generated file before posting it publicly.
## Menu Bar
```bash

View file

@ -16,6 +16,7 @@ import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import { buildRedactedShare, writeRedactedShare } from './share.js'
import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
@ -643,6 +644,61 @@ program
console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
})
program
.command('share')
.description('Export a redacted local JSON bundle for debugging or support')
.option('-p, --period <period>', 'Share period: today, week, 30days, month, all', 'week')
.option('--from <date>', 'Start date (YYYY-MM-DD). Overrides --period when set')
.option('--to <date>', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('-o, --output <path>', 'Output file path')
.option('--provider <provider>', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
.option('--project <name>', 'Include only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.action(async (opts) => {
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Error: ${message}\n`)
process.exit(1)
}
await loadPricing()
await hydrateCache()
const period = toPeriod(opts.period)
const rangeInfo = customRange
? { range: customRange, label: `${opts.from ?? 'all'} to ${opts.to ?? 'today'}` }
: getDateRange(period)
const projects = filterProjectsByName(
await parseAllSessions(rangeInfo.range, opts.provider),
opts.project,
opts.exclude,
)
if (projects.length === 0) {
console.log('\n No usage data found.\n')
return
}
const now = new Date()
const timeSuffix = `${now.toTimeString().slice(0, 8).replace(/:/g, '')}${String(now.getMilliseconds()).padStart(3, '0')}`
const defaultName = `codeburn-share-${toDateString(now)}-${timeSuffix}.json`
const outputPath = opts.output ?? defaultName
const share = buildRedactedShare(projects, {
label: rangeInfo.label,
range: rangeInfo.range,
provider: opts.provider,
project: opts.project,
exclude: opts.exclude,
})
const savedPath = await writeRedactedShare(share, outputPath)
console.log(`\n Redacted share exported to: ${savedPath}`)
console.log(' Review before posting publicly; redaction is best-effort.\n')
})
program
.command('menubar')
.description('Install and launch the macOS menubar app (one command, no clone)')

332
src/share.ts Normal file
View file

@ -0,0 +1,332 @@
import { mkdir, writeFile } from 'fs/promises'
import { dirname, resolve } from 'path'
import type { ClassifiedTurn, DateRange, ParsedApiCall, ProjectSummary, SessionSummary, TokenUsage } from './types.js'
type RedactionKind = 'email' | 'path' | 'project' | 'secret'
type RedactionStats = Record<RedactionKind, number>
export type RedactedShareOptions = {
label: string
range: DateRange
provider: string
project: string[]
exclude: string[]
}
type RedactedCall = {
provider: string
model: string
usage: TokenUsage
costUSD: number
tools: string[]
mcpTools: string[]
skills: string[]
hasAgentSpawn: boolean
hasPlanMode: boolean
speed: 'standard' | 'fast'
timestamp: string
bashCommands: string[]
}
type RedactedTurn = {
timestamp: string
sessionId: string
category: string
subCategory?: string
retries: number
hasEdits: boolean
userMessage: string
assistantCalls: RedactedCall[]
}
type RedactedSession = {
sessionId: string
firstTimestamp: string
lastTimestamp: string
totalCostUSD: number
totalInputTokens: number
totalOutputTokens: number
totalCacheReadTokens: number
totalCacheWriteTokens: number
apiCalls: number
turns: RedactedTurn[]
}
type RedactedProject = {
project: string
projectPath: string
totalCostUSD: number
totalApiCalls: number
sessions: RedactedSession[]
}
export type RedactedShare = {
schema: 'codeburn.share.v1'
generated: string
period: {
label: string
start: string
end: string
}
filters: {
provider: string
project: string[]
exclude: string[]
}
redaction: {
applied: true
placeholders: Record<RedactionKind, string>
uniqueReplacements: RedactionStats
}
summary: {
projects: number
sessions: number
turns: number
apiCalls: number
totalCostUSD: number
totalInputTokens: number
totalOutputTokens: number
}
projects: RedactedProject[]
}
function roundCost(n: number): number {
return Math.round(n * 10000) / 10000
}
const POSIX_PATH_PREFIXES = [
'Applications',
'Library',
'Users',
'app',
'builds',
'code',
'data',
'etc',
'home',
'mnt',
'opt',
'private',
'repo',
'repos',
'git',
'projects',
'scratch',
'srv',
'tmp',
'usr',
'var',
'Volumes',
'work',
'workspace',
'workspaces',
]
const posixPathPattern = new RegExp(
`/(?:${POSIX_PATH_PREFIXES.map(escapeRegex).join('|')})/[^\\s"'\\\`<>|]+(?:/[^\\s"'\\\`<>|]+)*`,
'gi',
)
class StableRedactor {
private readonly values: Record<RedactionKind, Map<string, string>> = {
email: new Map(),
path: new Map(),
project: new Map(),
secret: new Map(),
}
stats(): RedactionStats {
return {
email: this.values.email.size,
path: this.values.path.size,
project: this.values.project.size,
secret: this.values.secret.size,
}
}
addProjectLabel(value: string): void {
const trimmed = value.trim()
if (!trimmed) return
this.placeholder('project', trimmed)
}
redactProjectLabel(value: string): string {
const trimmed = value.trim()
if (!trimmed) return value
this.addProjectLabel(trimmed)
return this.placeholder('project', trimmed)
}
redactText(value: string): string {
let out = value
out = out.replace(/\b[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/\s:@]+):([^@\s]+)@/g, (match: string, user: string, password: string) =>
match.replace(`${user}:${password}@`, `${this.placeholder('secret', `${user}:${password}`)}@`),
)
out = out.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, email => this.placeholder('email', email))
out = out.replace(
/\b(authorization\s*[:=]\s*)(bearer|basic)\s+([A-Za-z0-9._~+/=-]{12,})/gi,
(_match, prefix: string, scheme: string, token: string) => `${prefix}${scheme} ${this.placeholder('secret', token)}`,
)
out = out.replace(
/\b["']?(api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|password|passwd|pwd)["']?\s*[:=]\s*["']?([A-Za-z0-9._~+/=-]{8,})["']?/gi,
(_match, key: string, secret: string) => `${key}=${this.placeholder('secret', secret)}`,
)
out = out.replace(/\b(sk-proj-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,})\b/g, token => this.placeholder('secret', token))
out = out.replace(/\b(gh[opusr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,})\b/g, token => this.placeholder('secret', token))
out = out.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, token => this.placeholder('secret', token))
out = out.replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, token => this.placeholder('secret', token))
out = out.replace(/\b[psr]k_(?:live|test)_[A-Za-z0-9]{10,}\b/g, token => this.placeholder('secret', token))
out = out.replace(/\bnpm_[A-Za-z0-9_]{10,}\b/g, token => this.placeholder('secret', token))
out = out.replace(/\b(AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})\b/g, token => this.placeholder('secret', token))
out = out.replace(/\\\\[^\\\s"'`<>|]+\\[^\\\s"'`<>|]+(?:\\[^\\\s"'`<>|]+)*/g, path => this.placeholder('path', path))
out = out.replace(/\b[A-Za-z]:\\[^\\\s"'`<>|]+(?:\\[^\\\s"'`<>|]+)*/g, path => this.placeholder('path', path))
out = out.replace(/~[/\\][^\s"'`<>|]+/g, path => this.placeholder('path', path))
out = out.replace(posixPathPattern, path => this.placeholder('path', path))
out = out.replace(/(^|[\s"'`(=:{\[,;<>|])(\.{1,2}\/[^\s"'`<>|]+(?:\/[^\s"'`<>|]+)*)/g, (_match, prefix: string, path: string) =>
`${prefix}${this.placeholder('path', path)}`,
)
for (const [term, placeholder] of Array.from(this.values.project.entries()).sort((a, b) => b[0].length - a[0].length)) {
const re = new RegExp(`(^|[^A-Za-z0-9_-])(${escapeRegex(term)})(?=$|[^A-Za-z0-9_-])`, 'gi')
out = out.replace(re, (match: string, prefix: string, label: string, offset: number, source: string) => {
const labelStart = offset + prefix.length
const labelEnd = labelStart + label.length
if (prefix === '[' && source[labelEnd] === ':') return match
return `${prefix}${placeholder}`
})
}
return out
}
private placeholder(kind: RedactionKind, value: string): string {
const bucket = this.values[kind]
const existing = bucket.get(value)
if (existing) return existing
const next = `[${kind}:${bucket.size + 1}]`
bucket.set(value, next)
return next
}
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function redactForShare(value: string): string {
return new StableRedactor().redactText(value)
}
function redactCall(call: ParsedApiCall, redactor: StableRedactor): RedactedCall {
return {
provider: call.provider,
model: redactor.redactText(call.model),
usage: call.usage,
costUSD: roundCost(call.costUSD),
tools: call.tools.map(t => redactor.redactText(t)),
mcpTools: call.mcpTools.map(t => redactor.redactText(t)),
skills: call.skills.map(skill => redactor.redactText(skill)),
hasAgentSpawn: call.hasAgentSpawn,
hasPlanMode: call.hasPlanMode,
speed: call.speed,
timestamp: call.timestamp,
bashCommands: call.bashCommands.map(cmd => redactor.redactText(cmd)),
}
}
function redactTurn(turn: ClassifiedTurn, redactor: StableRedactor): RedactedTurn {
return {
timestamp: turn.timestamp,
sessionId: redactor.redactText(turn.sessionId),
category: turn.category,
...(turn.subCategory ? { subCategory: redactor.redactText(turn.subCategory) } : {}),
retries: turn.retries,
hasEdits: turn.hasEdits,
userMessage: redactor.redactText(turn.userMessage),
assistantCalls: turn.assistantCalls.map(call => redactCall(call, redactor)),
}
}
function redactSession(session: SessionSummary, redactor: StableRedactor): RedactedSession {
return {
sessionId: redactor.redactText(session.sessionId),
firstTimestamp: session.firstTimestamp,
lastTimestamp: session.lastTimestamp,
totalCostUSD: roundCost(session.totalCostUSD),
totalInputTokens: session.totalInputTokens,
totalOutputTokens: session.totalOutputTokens,
totalCacheReadTokens: session.totalCacheReadTokens,
totalCacheWriteTokens: session.totalCacheWriteTokens,
apiCalls: session.apiCalls,
turns: session.turns.map(turn => redactTurn(turn, redactor)),
}
}
export function buildRedactedShare(projects: ProjectSummary[], options: RedactedShareOptions): RedactedShare {
const redactor = new StableRedactor()
const sessions = projects.flatMap(project => project.sessions)
const turns = sessions.flatMap(session => session.turns)
for (const project of projects) redactor.addProjectLabel(project.project)
for (const project of options.project) redactor.addProjectLabel(project)
for (const project of options.exclude) redactor.addProjectLabel(project)
const redactedProjects: RedactedProject[] = projects.map(project => ({
project: redactor.redactProjectLabel(project.project),
projectPath: redactor.redactText(project.projectPath),
totalCostUSD: roundCost(project.totalCostUSD),
totalApiCalls: project.totalApiCalls,
sessions: project.sessions.map(session => redactSession(session, redactor)),
}))
const uniqueReplacements = redactor.stats()
return {
schema: 'codeburn.share.v1',
generated: new Date().toISOString(),
period: {
label: options.label,
start: options.range.start.toISOString(),
end: options.range.end.toISOString(),
},
filters: {
provider: options.provider,
project: options.project.map(project => redactor.redactProjectLabel(project)),
exclude: options.exclude.map(project => redactor.redactProjectLabel(project)),
},
redaction: {
applied: true,
placeholders: {
email: '[email:<index>]',
path: '[path:<index>]',
project: '[project:<index>]',
secret: '[secret:<index>]',
},
uniqueReplacements,
},
summary: {
projects: projects.length,
sessions: sessions.length,
turns: turns.length,
apiCalls: projects.reduce((sum, project) => sum + project.totalApiCalls, 0),
totalCostUSD: roundCost(projects.reduce((sum, project) => sum + project.totalCostUSD, 0)),
totalInputTokens: sessions.reduce((sum, session) => sum + session.totalInputTokens, 0),
totalOutputTokens: sessions.reduce((sum, session) => sum + session.totalOutputTokens, 0),
},
projects: redactedProjects,
}
}
export async function writeRedactedShare(share: RedactedShare, outputPath: string): Promise<string> {
const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, JSON.stringify(share, null, 2), 'utf-8')
return target
}

284
tests/share.test.ts Normal file
View file

@ -0,0 +1,284 @@
import { mkdtemp, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect } from 'vitest'
import { buildRedactedShare, redactForShare, writeRedactedShare } from '../src/share.js'
import type { ProjectSummary } from '../src/types.js'
describe('redacted share', () => {
it('redacts common secrets, emails, and local paths', () => {
const openAiProjectKey = `sk-proj-${'abcdefghijklmnopqrstuvwxyz123456'}`
const githubPat = `ghp_${'abcdefghijklmnopqrstuvwxyz123456'}`
const githubServerToken = `ghs_${'abcdefghijklmnopqrstuvwxyz123456'}`
const jwt = `eyJ${'abcdefghijklmnopqrstuvwxyz'}.eyJ${'abcdefghijklmnopqrstuvwxyz'}.signatureabcdefghijkl`
const slackToken = `xox${'b'}-123456789012-abcdefghijklmnop`
const stripeKey = `sk_${'live'}_abcdefghijklmnopqrstuvwxyz`
const npmToken = `npm_${'abcdefghijklmnopqrstuvwxyz123456'}`
const googleApiKey = `AI${'za'}abcdefghijklmnopqrstuvwxyz1234567890`
const raw = [
'email husam@example.com',
'path /Users/husam/client-a/src/app.ts',
'linux /srv/client-a/src/app.ts',
'workspace /workspace/client-a/src/app.ts',
'usr /usr/local/client-a/src/app.ts',
'relative ../client-a/src/app.ts and ./src/private.ts <./angle/path.ts|./pipe/path.ts',
'windows C:\\Users\\husam\\client-a\\src\\app.ts',
'unc \\\\server\\share\\client-a\\notes.txt',
`token ${openAiProjectKey}`,
`Authorization: Bearer ${githubPat}`,
`github server token ${githubServerToken}`,
`jwt ${jwt}`,
`slack ${slackToken}`,
`stripe ${stripeKey}`,
`npm ${npmToken}`,
`api_key=${googleApiKey}`,
'json {"password": "hunter2hunter2"}',
'url https://alice:supersecretpass@example.com/repo',
].join('\n')
const redacted = redactForShare(raw)
expect(redacted).not.toContain('husam@example.com')
expect(redacted).not.toContain('/Users/husam')
expect(redacted).not.toContain('/srv/client-a')
expect(redacted).not.toContain('/workspace/client-a')
expect(redacted).not.toContain('/usr/local/client-a')
expect(redacted).not.toContain('../client-a')
expect(redacted).not.toContain('./src/private.ts')
expect(redacted).not.toContain('./angle/path.ts')
expect(redacted).not.toContain('./pipe/path.ts')
expect(redacted).not.toContain('C:\\Users\\husam')
expect(redacted).not.toContain('\\\\server\\share')
expect(redacted).not.toContain(openAiProjectKey)
expect(redacted).not.toContain(githubPat)
expect(redacted).not.toContain(githubServerToken)
expect(redacted).not.toContain(jwt)
expect(redacted).not.toContain(slackToken)
expect(redacted).not.toContain(stripeKey)
expect(redacted).not.toContain(npmToken)
expect(redacted).not.toContain(googleApiKey)
expect(redacted).not.toContain('hunter2hunter2')
expect(redacted).not.toContain('alice:supersecretpass')
expect(redacted).toContain('[email:1]')
expect(redacted).toContain('[path:1]')
expect(redacted).toContain('[path:2]')
expect(redacted).toContain('[path:3]')
expect(redacted).toContain('[path:4]')
expect(redacted).toContain('[path:5]')
expect(redacted).toContain('[path:6]')
expect(redacted).toContain('[path:7]')
expect(redacted).toContain('[path:8]')
expect(redacted).toContain('[path:9]')
expect(redacted).toContain('[path:10]')
for (let i = 1; i <= 10; i++) {
expect(redacted).toContain(`[secret:${i}]`)
}
})
it('builds a useful redacted support bundle', () => {
const projects: ProjectSummary[] = [{
project: 'client-a',
projectPath: '/Users/husam/work/client-a',
totalCostUSD: 1.23456,
totalApiCalls: 1,
sessions: [{
sessionId: 'session-1',
project: 'client-a',
firstTimestamp: '2026-05-05T10:00:00.000Z',
lastTimestamp: '2026-05-05T10:01:00.000Z',
totalCostUSD: 1.23456,
totalInputTokens: 1000,
totalOutputTokens: 200,
totalCacheReadTokens: 50,
totalCacheWriteTokens: 25,
apiCalls: 1,
turns: [{
userMessage: 'fix client-a at /Users/husam/work/client-a for husam@example.com with token=secret-token-12345',
assistantCalls: [{
provider: 'claude',
model: 'claude-sonnet-4-5',
usage: {
inputTokens: 1000,
outputTokens: 200,
cacheCreationInputTokens: 25,
cacheReadInputTokens: 50,
cachedInputTokens: 50,
reasoningTokens: 0,
webSearchRequests: 0,
},
costUSD: 1.23456,
tools: ['Read', 'Bash'],
mcpTools: [],
skills: ['browser-use'],
hasAgentSpawn: true,
hasPlanMode: true,
speed: 'standard',
timestamp: '2026-05-05T10:01:00.000Z',
bashCommands: ['npm'],
deduplicationKey: 'dedupe',
}],
timestamp: '2026-05-05T10:00:00.000Z',
sessionId: 'session-1',
category: 'debugging',
retries: 1,
hasEdits: true,
}],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {},
skillBreakdown: {},
}],
}]
const share = buildRedactedShare(projects, {
label: '7 Days',
range: { start: new Date('2026-05-01T00:00:00.000Z'), end: new Date('2026-05-07T23:59:59.999Z') },
provider: 'all',
project: [],
exclude: [],
})
expect(share.schema).toBe('codeburn.share.v1')
expect(share.summary).toMatchObject({ projects: 1, sessions: 1, turns: 1, apiCalls: 1 })
expect(share.projects[0]!.project).toBe('[project:1]')
expect(share.projects[0]!.projectPath).toBe('[path:1]')
expect(share.projects[0]!.sessions[0]!.totalCostUSD).toBe(1.2346)
const message = share.projects[0]!.sessions[0]!.turns[0]!.userMessage
const call = share.projects[0]!.sessions[0]!.turns[0]!.assistantCalls[0]!
expect(message).not.toContain('/Users/husam')
expect(message).not.toContain('husam@example.com')
expect(message).not.toContain('secret-token-12345')
expect(message).not.toContain('client-a')
expect(message).toContain('[path:1]')
expect(message).toContain('[email:1]')
expect(message).toContain('[project:1]')
expect(message).toContain('[secret:1]')
expect(call.skills).toEqual(['browser-use'])
expect(call.hasAgentSpawn).toBe(true)
expect(call.hasPlanMode).toBe(true)
})
it('redacts project labels without mangling unrelated substrings', () => {
const projects: ProjectSummary[] = [{
project: 'api',
projectPath: '/Users/husam/work/api',
totalCostUSD: 0.01,
totalApiCalls: 1,
sessions: [{
sessionId: 'session-1',
project: 'api',
firstTimestamp: '2026-05-05T10:00:00.000Z',
lastTimestamp: '2026-05-05T10:01:00.000Z',
totalCostUSD: 0.01,
totalInputTokens: 10,
totalOutputTokens: 5,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [{
userMessage: 'API failed in api, but rapidapi and apiKey are library names',
assistantCalls: [],
timestamp: '2026-05-05T10:00:00.000Z',
sessionId: 'session-1',
category: 'debugging',
retries: 0,
hasEdits: false,
}],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {},
skillBreakdown: {},
}],
}]
const share = buildRedactedShare(projects, {
label: '7 Days',
range: { start: new Date('2026-05-01T00:00:00.000Z'), end: new Date('2026-05-07T23:59:59.999Z') },
provider: 'all',
project: ['api'],
exclude: ['client-a'],
})
const message = share.projects[0]!.sessions[0]!.turns[0]!.userMessage
expect(message).toBe('[project:1] failed in [project:1], but rapidapi and apiKey are library names')
expect(share.filters.project).toEqual(['[project:1]'])
expect(share.filters.exclude).toEqual(['[project:2]'])
})
it('keeps placeholders stable and does not rewrite them as project labels', () => {
const repeated = redactForShare('token=repeatsecret123 token=repeatsecret123')
expect((repeated.match(/\[secret:1\]/g) ?? [])).toHaveLength(2)
expect(repeated).not.toContain('[secret:2]')
const projects: ProjectSummary[] = [{
project: 'secret',
projectPath: '/Users/husam/work/secret',
totalCostUSD: 0.01,
totalApiCalls: 1,
sessions: [{
sessionId: 'session-1',
project: 'secret',
firstTimestamp: '2026-05-05T10:00:00.000Z',
lastTimestamp: '2026-05-05T10:01:00.000Z',
totalCostUSD: 0.01,
totalInputTokens: 10,
totalOutputTokens: 5,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [{
userMessage: 'secret uses token=verysecret12345',
assistantCalls: [],
timestamp: '2026-05-05T10:00:00.000Z',
sessionId: 'session-1',
category: 'debugging',
retries: 0,
hasEdits: false,
}],
modelBreakdown: {},
toolBreakdown: {},
mcpBreakdown: {},
bashBreakdown: {},
categoryBreakdown: {},
skillBreakdown: {},
}],
}]
const share = buildRedactedShare(projects, {
label: '7 Days',
range: { start: new Date('2026-05-01T00:00:00.000Z'), end: new Date('2026-05-07T23:59:59.999Z') },
provider: 'all',
project: [],
exclude: [],
})
expect(share.projects[0]!.sessions[0]!.turns[0]!.userMessage).toBe('[project:1] uses token=[secret:1]')
})
it('writes json output and appends json extension when needed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'codeburn-share-'))
try {
const share = buildRedactedShare([], {
label: 'Today',
range: { start: new Date('2026-05-05T00:00:00.000Z'), end: new Date('2026-05-05T23:59:59.999Z') },
provider: 'all',
project: [],
exclude: [],
})
const savedPath = await writeRedactedShare(share, join(dir, 'support-bundle'))
const content = JSON.parse(await readFile(savedPath, 'utf-8'))
expect(savedPath.endsWith('support-bundle.json')).toBe(true)
expect(content.schema).toBe('codeburn.share.v1')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
})