mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-30 16:09:39 +00:00
Introduces mac/ with a native SwiftUI menubar app that replaces the previous SwiftBar plugin entirely. Install via `npx codeburn menubar`, which downloads the .app from GitHub Releases, strips Gatekeeper quarantine, and drops it into ~/Applications. Highlights - mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity + model breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub banner, live 60s refresh, instant currency switching with offline FX cache. - Security: CodeburnCLI argv-based spawn (no shell interpretation), SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to [0.0001, 1_000_000], keychain filtered to account == "default", removed byte-window credential log, in-flight refresh guard, POSIX flock on config.json writes, TerminalLauncher validates argv before AppleScript interpolation. - Performance: shared static NumberFormatter (thousands of allocations per popover redraw eliminated), concurrent pipe drain with 20 MB cap + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min payload cache keyed on (period, provider). - CLI: new `codeburn menubar` subcommand that downloads + installs + launches the .app (no clone, no build). New `status --format menubar-json` payload builder. `export` rewritten to produce a folder of one-table-per-file CSVs with a `.codeburn-export` marker so arbitrary -o paths cannot be silently deleted. - Removed: src/menubar.ts (SwiftBar plugin generator), install-menubar / uninstall-menubar subcommands, `status --format menubar` directive output, tests/menubar.test.ts, tests/security/menubar-injection.test.ts. - Release: .github/workflows/release-menubar.yml builds universal binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag push. Runs on the free macos-latest runner. Tests - 230 TypeScript tests pass - 10 Swift CapacityEstimator tests pass - TypeScript typecheck clean - Swift release build clean
137 lines
4.7 KiB
TypeScript
137 lines
4.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { mkdtemp, readFile, rm } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
import { exportCsv, type PeriodExport } from '../src/export.js'
|
|
import type { ProjectSummary } from '../src/types.js'
|
|
|
|
let tmpDir: string
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'export-test-'))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function makeProject(projectPath: string): ProjectSummary {
|
|
return {
|
|
project: projectPath,
|
|
projectPath,
|
|
sessions: [
|
|
{
|
|
sessionId: 'sess-001',
|
|
project: projectPath,
|
|
firstTimestamp: '2026-04-14T10:00:00Z',
|
|
lastTimestamp: '2026-04-14T10:01:00Z',
|
|
totalCostUSD: 1.23,
|
|
totalInputTokens: 100,
|
|
totalOutputTokens: 50,
|
|
totalCacheReadTokens: 0,
|
|
totalCacheWriteTokens: 0,
|
|
apiCalls: 1,
|
|
turns: [
|
|
{
|
|
userMessage: '=SUM(1,2)',
|
|
timestamp: '2026-04-14T10:00:00Z',
|
|
sessionId: 'sess-001',
|
|
category: 'coding',
|
|
retries: 0,
|
|
hasEdits: true,
|
|
assistantCalls: [
|
|
{
|
|
provider: 'claude',
|
|
model: '+danger-model',
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
},
|
|
costUSD: 1.23,
|
|
tools: ['Read'],
|
|
mcpTools: [],
|
|
hasAgentSpawn: false,
|
|
hasPlanMode: false,
|
|
speed: 'standard',
|
|
timestamp: '2026-04-14T10:00:00Z',
|
|
bashCommands: ['@malicious'],
|
|
deduplicationKey: 'dedup-1',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
modelBreakdown: {
|
|
'+danger-model': {
|
|
calls: 1,
|
|
costUSD: 1.23,
|
|
tokens: {
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheCreationInputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
reasoningTokens: 0,
|
|
webSearchRequests: 0,
|
|
},
|
|
},
|
|
},
|
|
toolBreakdown: {
|
|
Read: { calls: 1 },
|
|
},
|
|
mcpBreakdown: {},
|
|
bashBreakdown: {
|
|
'@malicious': { calls: 1 },
|
|
},
|
|
categoryBreakdown: {
|
|
coding: { turns: 1, costUSD: 1.23, retries: 0, editTurns: 1, oneShotTurns: 1 },
|
|
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
|
},
|
|
},
|
|
],
|
|
totalCostUSD: 1.23,
|
|
totalApiCalls: 1,
|
|
}
|
|
}
|
|
|
|
describe('exportCsv', () => {
|
|
it('prefixes formula-like cells to prevent CSV injection', async () => {
|
|
const periods: PeriodExport[] = [
|
|
{
|
|
label: '30 Days',
|
|
projects: [makeProject('=cmd,calc')],
|
|
},
|
|
]
|
|
|
|
const outputPath = join(tmpDir, 'report.csv')
|
|
const folder = await exportCsv(periods, outputPath)
|
|
// exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix
|
|
// guard is scattered across files. Concatenate them for the assertion surface.
|
|
const [projects, models, shell] = await Promise.all([
|
|
readFile(join(folder, 'projects.csv'), 'utf-8'),
|
|
readFile(join(folder, 'models.csv'), 'utf-8'),
|
|
readFile(join(folder, 'shell-commands.csv'), 'utf-8'),
|
|
])
|
|
const content = projects + models + shell
|
|
|
|
expect(content).toContain("\"'=cmd,calc\"")
|
|
expect(content).toContain("'+danger-model")
|
|
expect(content).toContain("'@malicious")
|
|
})
|
|
})
|