From 2f86c2db92a0cdfd34f76287190d846f4f2c311e Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 10:04:38 +0000 Subject: [PATCH 01/13] feat(cli): add --quiet flag to submit for cron compatibility --- packages/cli/src/cli.ts | 2 ++ packages/cli/src/submit.ts | 73 ++++++++++++++++++++++---------------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 161d5df64..77069471d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -355,6 +355,7 @@ async function main() { .option("--until ", "End date (YYYY-MM-DD)") .option("--year ", "Filter to specific year") .option("--dry-run", "Show what would be submitted without actually submitting") + .option("--quiet", "Suppress output (for cron jobs)") .action(async (options) => { await submit({ opencode: options.opencode, @@ -367,6 +368,7 @@ async function main() { until: options.until, year: options.year, dryRun: options.dryRun, + quiet: options.quiet, }); }); diff --git a/packages/cli/src/submit.ts b/packages/cli/src/submit.ts index 40b7034fb..fbf3c5425 100644 --- a/packages/cli/src/submit.ts +++ b/packages/cli/src/submit.ts @@ -24,6 +24,7 @@ interface SubmitOptions { until?: string; year?: string; dryRun?: boolean; + quiet?: boolean; } interface SubmitResponse { @@ -51,24 +52,34 @@ type SourceType = "opencode" | "claude" | "codex" | "gemini" | "cursor" | "amp"; * Submit command - sends usage data to the platform */ export async function submit(options: SubmitOptions = {}): Promise { + const { quiet } = options; + + const logIfNotQuiet = (...args: Parameters) => { + if (!quiet) console.log(...args); + }; + // Step 1: Check if logged in const credentials = loadCredentials(); if (!credentials) { - console.log(pc.yellow("\n Not logged in.")); - console.log(pc.gray(" Run 'tokscale login' first.\n")); + if (!quiet) { + console.log(pc.yellow("\n Not logged in.")); + console.log(pc.gray(" Run 'tokscale login' first.\n")); + } else { + console.error("Error: Not logged in. Run 'tokscale login' first."); + } process.exit(1); } // Step 2: Log native module status (TS fallback available) if (!isNativeAvailable()) { - console.log(pc.yellow("\n Note: Using TypeScript fallback (native module not available)")); - console.log(pc.gray(" Run 'bun run build:core' for faster processing.\n")); + logIfNotQuiet(pc.yellow("\n Note: Using TypeScript fallback (native module not available)")); + logIfNotQuiet(pc.gray(" Run 'bun run build:core' for faster processing.\n")); } - console.log(pc.cyan("\n Tokscale - Submit Usage Data\n")); + logIfNotQuiet(pc.cyan("\n Tokscale - Submit Usage Data\n")); // Step 3: Generate graph data - console.log(pc.gray(" Scanning local session data...")); + logIfNotQuiet(pc.gray(" Scanning local session data...")); const fetcher = new PricingFetcher(); await fetcher.fetchPricing(); @@ -102,28 +113,28 @@ export async function submit(options: SubmitOptions = {}): Promise { } // Step 4: Show summary - console.log(pc.white(" Data to submit:")); - console.log(pc.gray(` Date range: ${data.meta.dateRange.start} to ${data.meta.dateRange.end}`)); - console.log(pc.gray(` Active days: ${data.summary.activeDays}`)); - console.log(pc.gray(` Total tokens: ${data.summary.totalTokens.toLocaleString()}`)); - console.log(pc.gray(` Total cost: ${formatCurrency(data.summary.totalCost)}`)); - console.log(pc.gray(` Sources: ${data.summary.sources.join(", ")}`)); - console.log(pc.gray(` Models: ${data.summary.models.length} models`)); - console.log(); + logIfNotQuiet(pc.white(" Data to submit:")); + logIfNotQuiet(pc.gray(` Date range: ${data.meta.dateRange.start} to ${data.meta.dateRange.end}`)); + logIfNotQuiet(pc.gray(` Active days: ${data.summary.activeDays}`)); + logIfNotQuiet(pc.gray(` Total tokens: ${data.summary.totalTokens.toLocaleString()}`)); + logIfNotQuiet(pc.gray(` Total cost: ${formatCurrency(data.summary.totalCost)}`)); + logIfNotQuiet(pc.gray(` Sources: ${data.summary.sources.join(", ")}`)); + logIfNotQuiet(pc.gray(` Models: ${data.summary.models.length} models`)); + logIfNotQuiet(); if (data.summary.totalTokens === 0) { - console.log(pc.yellow(" No usage data found to submit.\n")); + logIfNotQuiet(pc.yellow(" No usage data found to submit.\n")); return; } // Step 5: Dry run check if (options.dryRun) { - console.log(pc.yellow(" Dry run - not submitting data.\n")); + logIfNotQuiet(pc.yellow(" Dry run - not submitting data.\n")); return; } // Step 6: Submit to server - console.log(pc.gray(" Submitting to server...")); + logIfNotQuiet(pc.gray(" Submitting to server...")); const baseUrl = getApiBaseUrl(); @@ -146,28 +157,28 @@ export async function submit(options: SubmitOptions = {}): Promise { console.error(pc.gray(` - ${detail}`)); } } - console.log(); + logIfNotQuiet(); process.exit(1); } // Success! - console.log(pc.green("\n Successfully submitted!")); - console.log(); - console.log(pc.white(" Summary:")); - console.log(pc.gray(` Submission ID: ${result.submissionId}`)); - console.log(pc.gray(` Total tokens: ${result.metrics?.totalTokens?.toLocaleString()}`)); - console.log(pc.gray(` Total cost: ${formatCurrency(result.metrics?.totalCost || 0)}`)); - console.log(pc.gray(` Active days: ${result.metrics?.activeDays}`)); - console.log(); - console.log(pc.cyan(` View your profile: ${baseUrl}/u/${credentials.username}`)); - console.log(); + logIfNotQuiet(pc.green("\n Successfully submitted!")); + logIfNotQuiet(); + logIfNotQuiet(pc.white(" Summary:")); + logIfNotQuiet(pc.gray(` Submission ID: ${result.submissionId}`)); + logIfNotQuiet(pc.gray(` Total tokens: ${result.metrics?.totalTokens?.toLocaleString()}`)); + logIfNotQuiet(pc.gray(` Total cost: ${formatCurrency(result.metrics?.totalCost || 0)}`)); + logIfNotQuiet(pc.gray(` Active days: ${result.metrics?.activeDays}`)); + logIfNotQuiet(); + logIfNotQuiet(pc.cyan(` View your profile: ${baseUrl}/u/${credentials.username}`)); + logIfNotQuiet(); if (result.warnings && result.warnings.length > 0) { - console.log(pc.yellow(" Warnings:")); + logIfNotQuiet(pc.yellow(" Warnings:")); for (const warning of result.warnings) { - console.log(pc.gray(` - ${warning}`)); + logIfNotQuiet(pc.gray(` - ${warning}`)); } - console.log(); + logIfNotQuiet(); } } catch (error) { console.error(pc.red(`\n Error: Failed to connect to server.`)); From 169f4054b2bf2f5b70c171c3c9163c7d31755a3d Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 10:04:51 +0000 Subject: [PATCH 02/13] fix(frontend): track per-device contributions for proper cross-machine aggregation - Add DeviceSourceData interface for per-device contribution tracking - Add devices field to SourceBreakdownData type in helpers.ts and schema.ts - Modify mergeSourceBreakdowns() to accept deviceId parameter - Add recalculateSourceAggregate() helper to sum across devices - Migrate existing data without devices field to __legacy__ device - Update submit route to pass tokenRecord.tokenId as deviceId - Add tests for same-device replacement, cross-device aggregation, and legacy migration --- .../frontend/__tests__/api/submit.test.ts | 191 ++++++++++++++++++ packages/frontend/src/app/api/submit/route.ts | 3 +- packages/frontend/src/lib/db/helpers.ts | 122 ++++++++++- packages/frontend/src/lib/db/schema.ts | 20 ++ 4 files changed, 331 insertions(+), 5 deletions(-) diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 0a072289a..5d54765a6 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mergeSourceBreakdowns, type SourceBreakdownData } from '@/lib/db/helpers'; /** * Test suite for POST /api/submit - Source-Level Merge @@ -9,6 +10,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; * - Sources not in submission are preserved * - Totals are recalculated from dailyBreakdown * - Concurrent submissions are handled correctly + * - Device-level tracking for cross-machine aggregation */ // Mock data factories @@ -399,4 +401,193 @@ describe('POST /api/submit - Source-Level Merge', () => { expect(mockResponse.mode).toBe('merge'); }); }); + + describe('Device-Level Tracking (Cross-Machine Aggregation)', () => { + const createSourceData = (tokens: number, cost: number, modelId = 'claude-sonnet-4'): SourceBreakdownData => ({ + tokens, + cost, + input: Math.floor(tokens * 0.6), + output: Math.floor(tokens * 0.4), + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + models: { + [modelId]: { + tokens, + cost, + input: Math.floor(tokens * 0.6), + output: Math.floor(tokens * 0.4), + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + }, + }, + }); + + it('should replace data when same device re-submits (not duplicate)', () => { + const deviceA = 'device-uuid-A'; + const sources = new Set(['claude']); + + const firstSubmission: Record = { + claude: createSourceData(1000, 10), + }; + + const afterFirst = mergeSourceBreakdowns(null, firstSubmission, sources, deviceA); + expect(afterFirst.claude.tokens).toBe(1000); + expect(afterFirst.claude.devices?.[deviceA]?.tokens).toBe(1000); + + const secondSubmission: Record = { + claude: createSourceData(1500, 15), + }; + + const afterSecond = mergeSourceBreakdowns(afterFirst, secondSubmission, sources, deviceA); + + expect(afterSecond.claude.tokens).toBe(1500); + expect(afterSecond.claude.devices?.[deviceA]?.tokens).toBe(1500); + expect(Object.keys(afterSecond.claude.devices || {}).length).toBe(1); + }); + + it('should aggregate when different devices submit (cross-machine)', () => { + const deviceA = 'device-uuid-A'; + const deviceB = 'device-uuid-B'; + const sources = new Set(['claude']); + + const submissionA: Record = { + claude: createSourceData(1000, 10), + }; + + const afterA = mergeSourceBreakdowns(null, submissionA, sources, deviceA); + expect(afterA.claude.tokens).toBe(1000); + + const submissionB: Record = { + claude: createSourceData(500, 5), + }; + + const afterB = mergeSourceBreakdowns(afterA, submissionB, sources, deviceB); + + expect(afterB.claude.tokens).toBe(1500); + expect(afterB.claude.cost).toBe(15); + expect(afterB.claude.devices?.[deviceA]?.tokens).toBe(1000); + expect(afterB.claude.devices?.[deviceB]?.tokens).toBe(500); + expect(Object.keys(afterB.claude.devices || {}).length).toBe(2); + }); + + it('should migrate existing data without devices field to __legacy__ device', () => { + const newDevice = 'new-device-uuid'; + const sources = new Set(['claude']); + + const existingWithoutDevices: Record = { + claude: { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 5, + models: { + 'claude-sonnet-4': { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 5, + }, + }, + }, + }; + + const newSubmission: Record = { + claude: createSourceData(500, 5), + }; + + const merged = mergeSourceBreakdowns(existingWithoutDevices, newSubmission, sources, newDevice); + + expect(merged.claude.devices?.['__legacy__']).toBeDefined(); + expect(merged.claude.devices?.['__legacy__']?.tokens).toBe(1000); + expect(merged.claude.devices?.[newDevice]?.tokens).toBe(500); + expect(merged.claude.tokens).toBe(1500); + expect(merged.claude.cost).toBe(15); + }); + + it('should aggregate models across devices correctly', () => { + const deviceA = 'device-A'; + const deviceB = 'device-B'; + const sources = new Set(['claude']); + + const submissionA: Record = { + claude: { + tokens: 1000, + cost: 10, + input: 600, + output: 400, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 5, + models: { + 'claude-sonnet-4': { tokens: 1000, cost: 10, input: 600, output: 400, cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 5 }, + }, + }, + }; + + const afterA = mergeSourceBreakdowns(null, submissionA, sources, deviceA); + + const submissionB: Record = { + claude: { + tokens: 800, + cost: 8, + input: 480, + output: 320, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 4, + models: { + 'claude-sonnet-4': { tokens: 500, cost: 5, input: 300, output: 200, cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 2 }, + 'claude-opus-4': { tokens: 300, cost: 3, input: 180, output: 120, cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 2 }, + }, + }, + }; + + const afterB = mergeSourceBreakdowns(afterA, submissionB, sources, deviceB); + + expect(afterB.claude.tokens).toBe(1800); + expect(afterB.claude.models['claude-sonnet-4'].tokens).toBe(1500); + expect(afterB.claude.models['claude-opus-4'].tokens).toBe(300); + }); + + it('should handle source removal for specific device', () => { + const deviceA = 'device-A'; + const deviceB = 'device-B'; + + const submissionA: Record = { + claude: createSourceData(1000, 10), + cursor: createSourceData(500, 5), + }; + + const afterA = mergeSourceBreakdowns(null, submissionA, new Set(['claude', 'cursor']), deviceA); + expect(afterA.claude.tokens).toBe(1000); + expect(afterA.cursor.tokens).toBe(500); + + const submissionB: Record = { + claude: createSourceData(800, 8), + }; + + const afterB = mergeSourceBreakdowns(afterA, submissionB, new Set(['claude', 'cursor']), deviceB); + + expect(afterB.claude.tokens).toBe(1800); + expect(afterB.claude.devices?.[deviceA]).toBeDefined(); + expect(afterB.claude.devices?.[deviceB]).toBeDefined(); + expect(afterB.cursor.tokens).toBe(500); + expect(afterB.cursor.devices?.[deviceA]).toBeDefined(); + expect(afterB.cursor.devices?.[deviceB]).toBeUndefined(); + }); + }); }); diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index bbd7df9d4..e0f3826c4 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -241,7 +241,8 @@ export async function POST(request: Request) { const mergedSourceBreakdown = mergeSourceBreakdowns( existingSourceBreakdown, incomingSourceBreakdown, - submittedSources + submittedSources, + tokenRecord.tokenId ); // Recalculate day totals from merged data diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 9e997c3db..0eaeea140 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -13,6 +13,22 @@ export interface ModelBreakdownData { messages: number; } +/** + * Per-device contribution data for cross-machine aggregation. + * Each device (identified by apiTokenId) tracks its own usage. + */ +export interface DeviceSourceData { + tokens: number; + cost: number; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + reasoning: number; + messages: number; + models: Record; +} + export interface SourceBreakdownData { tokens: number; cost: number; @@ -23,6 +39,8 @@ export interface SourceBreakdownData { reasoning: number; messages: number; models: Record; + /** Per-device contributions for cross-machine aggregation */ + devices?: Record; /** @deprecated Legacy field for backward compat - use models instead */ modelId?: string; } @@ -69,18 +87,114 @@ export function recalculateDayTotals( }; } +function recalculateSourceAggregate(source: SourceBreakdownData): void { + if (!source.devices || Object.keys(source.devices).length === 0) return; + + source.tokens = 0; + source.cost = 0; + source.input = 0; + source.output = 0; + source.cacheRead = 0; + source.cacheWrite = 0; + source.reasoning = 0; + source.messages = 0; + source.models = {}; + + for (const deviceData of Object.values(source.devices)) { + source.tokens += deviceData.tokens; + source.cost += deviceData.cost; + source.input += deviceData.input; + source.output += deviceData.output; + source.cacheRead += deviceData.cacheRead; + source.cacheWrite += deviceData.cacheWrite; + source.reasoning += deviceData.reasoning || 0; + source.messages += deviceData.messages; + + for (const [modelId, modelData] of Object.entries(deviceData.models)) { + if (!source.models[modelId]) { + source.models[modelId] = { ...modelData }; + } else { + const m = source.models[modelId]; + m.tokens += modelData.tokens; + m.cost += modelData.cost; + m.input += modelData.input; + m.output += modelData.output; + m.cacheRead += modelData.cacheRead; + m.cacheWrite += modelData.cacheWrite; + m.reasoning += modelData.reasoning || 0; + m.messages += modelData.messages; + } + } + } +} + export function mergeSourceBreakdowns( existing: Record | null | undefined, incoming: Record, - incomingSources: Set + incomingSources: Set, + deviceId: string ): Record { - const merged: Record = { ...(existing || {}) }; + const merged: Record = JSON.parse( + JSON.stringify(existing || {}) + ); for (const sourceName of incomingSources) { if (incoming[sourceName]) { - merged[sourceName] = { ...incoming[sourceName] }; + const incomingSource = incoming[sourceName]; + + if (!merged[sourceName]) { + merged[sourceName] = { + tokens: 0, + cost: 0, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 0, + models: {}, + devices: {}, + }; + } + + if (!merged[sourceName].devices) { + merged[sourceName].devices = { + __legacy__: { + tokens: merged[sourceName].tokens, + cost: merged[sourceName].cost, + input: merged[sourceName].input, + output: merged[sourceName].output, + cacheRead: merged[sourceName].cacheRead, + cacheWrite: merged[sourceName].cacheWrite, + reasoning: merged[sourceName].reasoning || 0, + messages: merged[sourceName].messages, + models: { ...merged[sourceName].models }, + }, + }; + } + + merged[sourceName].devices![deviceId] = { + tokens: incomingSource.tokens, + cost: incomingSource.cost, + input: incomingSource.input, + output: incomingSource.output, + cacheRead: incomingSource.cacheRead, + cacheWrite: incomingSource.cacheWrite, + reasoning: incomingSource.reasoning || 0, + messages: incomingSource.messages, + models: { ...incomingSource.models }, + }; + + recalculateSourceAggregate(merged[sourceName]); } else { - delete merged[sourceName]; + if (merged[sourceName]?.devices?.[deviceId]) { + delete merged[sourceName].devices![deviceId]; + if (Object.keys(merged[sourceName].devices!).length === 0) { + delete merged[sourceName]; + } else { + recalculateSourceAggregate(merged[sourceName]); + } + } } } diff --git a/packages/frontend/src/lib/db/schema.ts b/packages/frontend/src/lib/db/schema.ts index 6559c5dc9..1fed9cb08 100644 --- a/packages/frontend/src/lib/db/schema.ts +++ b/packages/frontend/src/lib/db/schema.ts @@ -239,6 +239,26 @@ export const dailyBreakdown = pgTable( reasoning: number; messages: number; }>; + devices?: Record; + }>; modelId?: string; } > From a027508428767c8909aa0e56c3e3fef530f6d163 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 10:13:36 +0000 Subject: [PATCH 03/13] feat(cli): add tokscale sync command for automatic hourly submissions - Add sync.ts with setupSync(), removeSync(), syncStatus() functions - Support crontab (macOS/Linux) and Task Scheduler (Windows) - Use process.argv[1] for CLI path resolution - Crontab entry uses --quiet flag for silent execution - Log output to ~/.config/tokscale/sync.log --- packages/cli/src/cli.ts | 33 ++++- packages/cli/src/sync.ts | 257 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/sync.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 77069471d..0556f4905 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -24,6 +24,7 @@ import { getCursorCredentialsPath, syncCursorCache, } from "./cursor.js"; +import { setupSync, removeSync, syncStatus } from "./sync.js"; import { createUsageTable, formatUsageRow, @@ -430,13 +431,43 @@ async function main() { await cursorStatus(); }); + // ========================================================================= + // Automatic Sync Commands + // ========================================================================= + + const syncCommand = program + .command("sync") + .description("Automatic sync commands (crontab/Task Scheduler)"); + + syncCommand + .command("setup") + .description("Set up hourly automatic submission") + .option("--interval ", "Sync interval in minutes (default: 60)", "60") + .action(async (options) => { + await setupSync({ interval: options.interval }); + }); + + syncCommand + .command("remove") + .description("Remove automatic sync") + .action(async () => { + await removeSync(); + }); + + syncCommand + .command("status") + .description("Check sync status") + .action(async () => { + await syncStatus(); + }); + // Check if a subcommand was provided const args = process.argv.slice(2); const firstArg = args[0] || ''; // Global flags should go to main program const isGlobalFlag = ['--help', '-h', '--version', '-V'].includes(firstArg); const hasSubcommand = args.length > 0 && !firstArg.startsWith('-'); - const knownCommands = ['monthly', 'models', 'graph', 'wrapped', 'login', 'logout', 'whoami', 'submit', 'cursor', 'tui', 'help']; + const knownCommands = ['monthly', 'models', 'graph', 'wrapped', 'login', 'logout', 'whoami', 'submit', 'cursor', 'sync', 'tui', 'help']; const isKnownCommand = hasSubcommand && knownCommands.includes(firstArg); if (isKnownCommand || isGlobalFlag) { diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts new file mode 100644 index 000000000..fef90b9c6 --- /dev/null +++ b/packages/cli/src/sync.ts @@ -0,0 +1,257 @@ +/** + * Tokscale CLI Sync Command + * Manages automatic hourly sync via crontab (macOS/Linux) or Task Scheduler (Windows) + */ + +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import pc from "picocolors"; +import { loadCredentials } from "./credentials.js"; + +const CONFIG_DIR = path.join(os.homedir(), ".config", "tokscale"); +const LOG_FILE = path.join(CONFIG_DIR, "sync.log"); +const WINDOWS_TASK_NAME = "TokscaleSync"; + +function ensureConfigDir(): void { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + } +} + +function getTokscalePath(): string { + return process.argv[1]; +} + +// Escape single quotes for shell: replace ' with '\'' +function escapePathForShell(filePath: string): string { + return filePath.replace(/'/g, "'\\''"); +} + +function isWindows(): boolean { + return process.platform === "win32"; +} + +// ============================================================================= +// Crontab (macOS/Linux) +// ============================================================================= + +function buildCronEntry(): string { + const tokscalePath = escapePathForShell(getTokscalePath()); + const logPath = escapePathForShell(LOG_FILE); + return `0 * * * * '${tokscalePath}' submit --quiet >> '${logPath}' 2>&1`; +} + +function setupCrontab(): { success: boolean; error?: string } { + try { + ensureConfigDir(); + const cronEntry = buildCronEntry(); + // Remove existing tokscale entries, then add new one + // Uses || true to handle empty crontab gracefully + const command = `(crontab -l 2>/dev/null | grep -v 'tokscale' || true) | { cat; echo "${cronEntry}"; } | crontab -`; + execSync(command, { stdio: "pipe" }); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + +function removeCrontab(): { success: boolean; error?: string } { + try { + const command = `(crontab -l 2>/dev/null | grep -v 'tokscale' || true) | crontab -`; + execSync(command, { stdio: "pipe" }); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + +function checkCrontab(): { exists: boolean; entry?: string; error?: string } { + try { + const result = execSync(`crontab -l 2>/dev/null | grep 'tokscale' || true`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + const entry = result.trim(); + return { exists: entry.length > 0, entry: entry || undefined }; + } catch (error) { + return { exists: false, error: (error as Error).message }; + } +} + +// ============================================================================= +// Windows Task Scheduler +// ============================================================================= + +function setupWindowsTask(): { success: boolean; error?: string } { + try { + ensureConfigDir(); + const tokscalePath = getTokscalePath(); + + try { + execSync(`schtasks /delete /tn "${WINDOWS_TASK_NAME}" /f`, { stdio: "pipe" }); + } catch { + // Task doesn't exist yet + } + + const command = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "\\"${tokscalePath}\\" submit --quiet >> \\"${LOG_FILE}\\" 2>&1" /f`; + execSync(command, { stdio: "pipe" }); + return { success: true }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + +function removeWindowsTask(): { success: boolean; error?: string } { + try { + execSync(`schtasks /delete /tn "${WINDOWS_TASK_NAME}" /f`, { stdio: "pipe" }); + return { success: true }; + } catch (error) { + const errorMsg = (error as Error).message; + if (errorMsg.includes("cannot find") || errorMsg.includes("task name")) { + return { success: true }; + } + return { success: false, error: errorMsg }; + } +} + +function checkWindowsTask(): { exists: boolean; error?: string } { + try { + execSync(`schtasks /query /tn "${WINDOWS_TASK_NAME}"`, { stdio: "pipe" }); + return { exists: true }; + } catch (error) { + const errorMsg = (error as Error).message; + if (errorMsg.includes("cannot find") || errorMsg.includes("task name")) { + return { exists: false }; + } + return { exists: false, error: errorMsg }; + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +export interface SyncSetupOptions { + interval?: string; +} + +export async function setupSync(_options: SyncSetupOptions = {}): Promise { + const credentials = loadCredentials(); + if (!credentials) { + console.log(pc.red("\n Error: Not logged in to Tokscale.")); + console.log(pc.gray(" Run 'tokscale login' first before setting up sync.\n")); + process.exit(1); + } + + console.log(pc.cyan("\n Tokscale - Setup Automatic Sync\n")); + console.log(pc.gray(` Logged in as: ${credentials.username}`)); + console.log(); + + const platform = isWindows() ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"; + console.log(pc.gray(` Platform: ${platform}`)); + console.log(pc.gray(` CLI path: ${getTokscalePath()}`)); + console.log(pc.gray(` Log file: ${LOG_FILE}`)); + console.log(); + + let result: { success: boolean; error?: string }; + + if (isWindows()) { + console.log(pc.gray(" Creating Windows Task Scheduler entry...")); + result = setupWindowsTask(); + } else { + console.log(pc.gray(" Adding crontab entry...")); + result = setupCrontab(); + } + + if (result.success) { + console.log(pc.green("\n Success! Automatic sync is now configured.")); + console.log(); + console.log(pc.white(" Schedule: Hourly (at minute 0)")); + console.log(pc.white(" Command: tokscale submit --quiet")); + console.log(pc.gray(` Logs: ${LOG_FILE}`)); + console.log(); + console.log(pc.gray(" Your usage data will be automatically submitted every hour.")); + console.log(pc.gray(" Use 'tokscale sync status' to check status.")); + console.log(pc.gray(" Use 'tokscale sync remove' to disable.\n")); + } else { + console.log(pc.red(`\n Error: Failed to set up sync.`)); + console.log(pc.gray(` ${result.error}\n`)); + process.exit(1); + } +} + +export async function removeSync(): Promise { + console.log(pc.cyan("\n Tokscale - Remove Automatic Sync\n")); + + let result: { success: boolean; error?: string }; + + if (isWindows()) { + console.log(pc.gray(" Removing Windows Task Scheduler entry...")); + result = removeWindowsTask(); + } else { + console.log(pc.gray(" Removing crontab entry...")); + result = removeCrontab(); + } + + if (result.success) { + console.log(pc.green("\n Success! Automatic sync has been removed.")); + console.log(pc.gray(" Your usage data will no longer be automatically submitted.\n")); + } else { + console.log(pc.red(`\n Error: Failed to remove sync.`)); + console.log(pc.gray(` ${result.error}\n`)); + process.exit(1); + } +} + +export async function syncStatus(): Promise { + console.log(pc.cyan("\n Tokscale - Sync Status\n")); + + const platform = isWindows() ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"; + console.log(pc.gray(` Platform: ${platform}`)); + + if (isWindows()) { + const status = checkWindowsTask(); + if (status.error) { + console.log(pc.yellow(` Status: Unable to check (${status.error})`)); + } else if (status.exists) { + console.log(pc.green(" Status: Active")); + console.log(pc.gray(` Task: ${WINDOWS_TASK_NAME}`)); + console.log(pc.gray(` Schedule: Hourly`)); + console.log(pc.gray(` Logs: ${LOG_FILE}`)); + } else { + console.log(pc.yellow(" Status: Not configured")); + console.log(pc.gray(" Run 'tokscale sync setup' to enable automatic sync.")); + } + } else { + const status = checkCrontab(); + if (status.error) { + console.log(pc.yellow(` Status: Unable to check (${status.error})`)); + } else if (status.exists) { + console.log(pc.green(" Status: Active")); + console.log(pc.gray(` Entry: ${status.entry}`)); + console.log(pc.gray(` Logs: ${LOG_FILE}`)); + } else { + console.log(pc.yellow(" Status: Not configured")); + console.log(pc.gray(" Run 'tokscale sync setup' to enable automatic sync.")); + } + } + + if (fs.existsSync(LOG_FILE)) { + const stats = fs.statSync(LOG_FILE); + const lastModified = stats.mtime.toLocaleString(); + console.log(pc.gray(` Last sync log: ${lastModified}`)); + } + + const credentials = loadCredentials(); + if (!credentials) { + console.log(pc.yellow("\n Warning: Not logged in to Tokscale.")); + console.log(pc.gray(" Sync will fail without authentication.")); + console.log(pc.gray(" Run 'tokscale login' to authenticate.")); + } else { + console.log(pc.gray(` Logged in as: ${credentials.username}`)); + } + + console.log(); +} From 5646e1b08fb14ddead403b28e26766929e1bd314 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 10:18:02 +0000 Subject: [PATCH 04/13] docs: add automatic sync and cross-machine aggregation documentation --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 1cafbc93a..e0dde659e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ - [Filtering by Platform](#filtering-by-platform) - [Date Filtering](#date-filtering) - [Social](#social) + - [Automatic Sync](#automatic-sync) - [Cursor IDE Commands](#cursor-ide-commands) - [Environment Variables](#environment-variables) - [Frontend Visualization](#frontend-visualization) @@ -286,6 +287,23 @@ tokscale logout CLI Submit +### Automatic Sync + +Set up automatic hourly submissions to keep your profile updated: + +```bash +# Set up hourly sync +tokscale sync setup + +# Check sync status +tokscale sync status + +# Remove automatic sync +tokscale sync remove +``` + +Logs are saved to `~/.config/tokscale/sync.log`. + ### Cursor IDE Commands Cursor IDE requires separate authentication via session token (different from the social platform login): @@ -388,6 +406,8 @@ Submitted data goes through Level 1 validation: - Required fields present - Duplicate detection +> **Cross-Machine Aggregation**: When you submit from multiple machines using the same GitHub account, your usage data is automatically aggregated (summed) rather than overwritten. Each device's contributions are tracked separately and combined into your total. + ## Wrapped 2025 ![Wrapped 2025](.github/assets/hero-wrapped-2025.png) From fa29476771e850f51b2a18d94749c626398a204c Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 10:51:19 +0000 Subject: [PATCH 05/13] fix: address Oracle review - prevent double-count, shell injection, cron cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store devices[deviceId] on new day inserts to prevent double-counting when same device resubmits (was migrating to __legacy__ and adding) - Fix shell injection risk in crontab setup using printf with escaped quotes - Fix Windows Task Scheduler logging by wrapping in cmd.exe for redirections - Use specific grep pattern 'tokscale submit --quiet' to avoid removing unrelated cron jobs - Add tests for insert→resubmit flow to verify no double-counting --- packages/cli/src/sync.ts | 19 +- .../frontend/__tests__/api/submit.test.ts | 214 ++++++++++++++++++ packages/frontend/src/app/api/submit/route.ts | 29 ++- 3 files changed, 253 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts index fef90b9c6..0e62a0ce1 100644 --- a/packages/cli/src/sync.ts +++ b/packages/cli/src/sync.ts @@ -47,9 +47,11 @@ function setupCrontab(): { success: boolean; error?: string } { try { ensureConfigDir(); const cronEntry = buildCronEntry(); - // Remove existing tokscale entries, then add new one + // Remove existing tokscale sync entries, then add new one // Uses || true to handle empty crontab gracefully - const command = `(crontab -l 2>/dev/null | grep -v 'tokscale' || true) | { cat; echo "${cronEntry}"; } | crontab -`; + // Use printf with single quotes to prevent shell injection if path contains $() or backticks + const escapedEntry = cronEntry.replace(/'/g, "'\\''"); + const command = `(crontab -l 2>/dev/null | grep -v 'tokscale submit --quiet' || true) | { cat; printf '%s\\n' '${escapedEntry}'; } | crontab -`; execSync(command, { stdio: "pipe" }); return { success: true }; } catch (error) { @@ -59,7 +61,8 @@ function setupCrontab(): { success: boolean; error?: string } { function removeCrontab(): { success: boolean; error?: string } { try { - const command = `(crontab -l 2>/dev/null | grep -v 'tokscale' || true) | crontab -`; + // Use specific pattern to only remove our sync entry, not unrelated tokscale jobs + const command = `(crontab -l 2>/dev/null | grep -v 'tokscale submit --quiet' || true) | crontab -`; execSync(command, { stdio: "pipe" }); return { success: true }; } catch (error) { @@ -69,7 +72,8 @@ function removeCrontab(): { success: boolean; error?: string } { function checkCrontab(): { exists: boolean; entry?: string; error?: string } { try { - const result = execSync(`crontab -l 2>/dev/null | grep 'tokscale' || true`, { + // Use specific pattern to only find our sync entry + const result = execSync(`crontab -l 2>/dev/null | grep 'tokscale submit --quiet' || true`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); @@ -95,8 +99,11 @@ function setupWindowsTask(): { success: boolean; error?: string } { // Task doesn't exist yet } - const command = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "\\"${tokscalePath}\\" submit --quiet >> \\"${LOG_FILE}\\" 2>&1" /f`; - execSync(command, { stdio: "pipe" }); + // Wrap in cmd.exe to enable shell redirections (>> and 2>&1) + // Without cmd.exe, schtasks /tr doesn't run under a shell, so redirections won't work + const windowsCommand = `cmd.exe /c "\\"${tokscalePath}\\" submit --quiet >> \\"${LOG_FILE}\\" 2>&1"`; + const scheduleCmd = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "${windowsCommand}" /f`; + execSync(scheduleCmd, { stdio: "pipe" }); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 5d54765a6..2c1e19136 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -402,6 +402,220 @@ describe('POST /api/submit - Source-Level Merge', () => { }); }); + describe('Insert Then Resubmit Same Day Same Device (Double-Count Prevention)', () => { + const createSourceDataWithDevices = ( + tokens: number, + cost: number, + deviceId: string, + modelId = 'claude-sonnet-4' + ): SourceBreakdownData => ({ + tokens, + cost, + input: Math.floor(tokens * 0.6), + output: Math.floor(tokens * 0.4), + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + models: { + [modelId]: { + tokens, + cost, + input: Math.floor(tokens * 0.6), + output: Math.floor(tokens * 0.4), + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + }, + }, + devices: { + [deviceId]: { + tokens, + cost, + input: Math.floor(tokens * 0.6), + output: Math.floor(tokens * 0.4), + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + models: { + [modelId]: { + tokens, + cost, + input: Math.floor(tokens * 0.6), + output: Math.floor(tokens * 0.4), + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 1, + }, + }, + }, + }, + }); + + it('should REPLACE tokens (not double) when same device resubmits same day', () => { + const deviceId = 'device-uuid-123'; + const sources = new Set(['claude']); + + const firstDayInsert: Record = { + claude: createSourceDataWithDevices(1000, 10, deviceId), + }; + expect(firstDayInsert.claude.tokens).toBe(1000); + expect(firstDayInsert.claude.devices?.[deviceId]?.tokens).toBe(1000); + + const secondSubmissionSameDay: Record = { + claude: { + tokens: 1500, + cost: 15, + input: 900, + output: 600, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 2, + models: { + 'claude-sonnet-4': { + tokens: 1500, + cost: 15, + input: 900, + output: 600, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 2, + }, + }, + }, + }; + + const merged = mergeSourceBreakdowns(firstDayInsert, secondSubmissionSameDay, sources, deviceId); + + expect(merged.claude.tokens).toBe(1500); + expect(merged.claude.cost).toBe(15); + expect(merged.claude.devices?.[deviceId]?.tokens).toBe(1500); + expect(Object.keys(merged.claude.devices || {}).length).toBe(1); + expect(merged.claude.devices?.['__legacy__']).toBeUndefined(); + }); + + it('should NOT create __legacy__ when first insert already has devices field', () => { + const deviceId = 'device-uuid-456'; + const sources = new Set(['claude']); + + const firstInsertWithDevices: Record = { + claude: createSourceDataWithDevices(500, 5, deviceId), + }; + + expect(firstInsertWithDevices.claude.devices).toBeDefined(); + expect(firstInsertWithDevices.claude.devices?.[deviceId]).toBeDefined(); + + const resubmit: Record = { + claude: { + tokens: 800, + cost: 8, + input: 480, + output: 320, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 3, + models: { + 'claude-sonnet-4': { + tokens: 800, + cost: 8, + input: 480, + output: 320, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 3, + }, + }, + }, + }; + + const merged = mergeSourceBreakdowns(firstInsertWithDevices, resubmit, sources, deviceId); + + expect(merged.claude.devices?.['__legacy__']).toBeUndefined(); + expect(merged.claude.tokens).toBe(800); + expect(merged.claude.devices?.[deviceId]?.tokens).toBe(800); + }); + + it('should simulate full insert→resubmit flow without double-counting', () => { + const deviceId = 'my-api-token-id'; + const sources = new Set(['claude', 'cursor']); + + const day1FirstInsert: Record = { + claude: createSourceDataWithDevices(2000, 20, deviceId, 'claude-sonnet-4'), + cursor: createSourceDataWithDevices(1000, 10, deviceId, 'gpt-4o'), + }; + + expect(day1FirstInsert.claude.tokens).toBe(2000); + expect(day1FirstInsert.cursor.tokens).toBe(1000); + + const day1Resubmit: Record = { + claude: { + tokens: 2500, + cost: 25, + input: 1500, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 10, + models: { + 'claude-sonnet-4': { + tokens: 2500, + cost: 25, + input: 1500, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 10, + }, + }, + }, + cursor: { + tokens: 1200, + cost: 12, + input: 720, + output: 480, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 5, + models: { + 'gpt-4o': { + tokens: 1200, + cost: 12, + input: 720, + output: 480, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + messages: 5, + }, + }, + }, + }; + + const merged = mergeSourceBreakdowns(day1FirstInsert, day1Resubmit, sources, deviceId); + + expect(merged.claude.tokens).toBe(2500); + expect(merged.cursor.tokens).toBe(1200); + expect(merged.claude.tokens).not.toBe(4500); + expect(merged.cursor.tokens).not.toBe(2200); + + expect(merged.claude.devices?.['__legacy__']).toBeUndefined(); + expect(merged.cursor.devices?.['__legacy__']).toBeUndefined(); + + expect(Object.keys(merged.claude.devices || {}).length).toBe(1); + expect(Object.keys(merged.cursor.devices || {}).length).toBe(1); + }); + }); + describe('Device-Level Tracking (Cross-Machine Aggregation)', () => { const createSourceData = (tokens: number, cost: number, modelId = 'claude-sonnet-4'): SourceBreakdownData => ({ tokens, diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index e0f3826c4..4516967d1 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -265,8 +265,31 @@ export async function POST(request: Request) { .where(eq(dailyBreakdown.id, existingDay.id)); } else { // ---- INSERT: New day ---- - const dayTotals = recalculateDayTotals(incomingSourceBreakdown); - const modelBreakdown = buildModelBreakdown(incomingSourceBreakdown); + // CRITICAL: Include devices[deviceId] from the start to prevent double-count on resubmit. + // Without this, resubmits would migrate existing data to __legacy__ and add new data, + // causing duplicate counting. + const incomingWithDevices: Record = {}; + for (const [sourceName, sourceData] of Object.entries(incomingSourceBreakdown)) { + incomingWithDevices[sourceName] = { + ...sourceData, + devices: { + [tokenRecord.tokenId]: { + tokens: sourceData.tokens, + cost: sourceData.cost, + input: sourceData.input, + output: sourceData.output, + cacheRead: sourceData.cacheRead, + cacheWrite: sourceData.cacheWrite, + reasoning: sourceData.reasoning || 0, + messages: sourceData.messages, + models: { ...sourceData.models }, + }, + }, + }; + } + + const dayTotals = recalculateDayTotals(incomingWithDevices); + const modelBreakdown = buildModelBreakdown(incomingWithDevices); await tx.insert(dailyBreakdown).values({ submissionId: submissionId, @@ -275,7 +298,7 @@ export async function POST(request: Request) { cost: dayTotals.cost.toFixed(4), inputTokens: dayTotals.inputTokens, outputTokens: dayTotals.outputTokens, - sourceBreakdown: incomingSourceBreakdown, + sourceBreakdown: incomingWithDevices, modelBreakdown: modelBreakdown, }); } From 48dd15eb78266b0568519b6ea20c454b3cf72f31 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 11:03:06 +0000 Subject: [PATCH 06/13] fix: address Oracle review round 2 - defensive defaults, cron marker, direct tests - Add ?? {} defensive defaults for models access in mergeSourceBreakdowns() - Use TOKSCALE_SYNC_MANAGED marker for cron entries (prevents false matches) - Windows sync now uses .cmd script file approach (simpler, no quoting issues) - Add direct mergeSourceBreakdowns() function call tests for legacy data handling --- packages/cli/src/sync.ts | 40 +++++---- .../frontend/__tests__/api/submit.test.ts | 82 +++++++++++++++++++ packages/frontend/src/lib/db/helpers.ts | 6 +- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts index 0e62a0ce1..caf8bd758 100644 --- a/packages/cli/src/sync.ts +++ b/packages/cli/src/sync.ts @@ -13,6 +13,8 @@ import { loadCredentials } from "./credentials.js"; const CONFIG_DIR = path.join(os.homedir(), ".config", "tokscale"); const LOG_FILE = path.join(CONFIG_DIR, "sync.log"); const WINDOWS_TASK_NAME = "TokscaleSync"; +const WINDOWS_SCRIPT_FILE = path.join(CONFIG_DIR, "sync-task.cmd"); +const CRON_MARKER = "# TOKSCALE_SYNC_MANAGED"; function ensureConfigDir(): void { if (!fs.existsSync(CONFIG_DIR)) { @@ -40,18 +42,18 @@ function isWindows(): boolean { function buildCronEntry(): string { const tokscalePath = escapePathForShell(getTokscalePath()); const logPath = escapePathForShell(LOG_FILE); - return `0 * * * * '${tokscalePath}' submit --quiet >> '${logPath}' 2>&1`; + return `0 * * * * '${tokscalePath}' submit --quiet >> '${logPath}' 2>&1 ${CRON_MARKER}`; } function setupCrontab(): { success: boolean; error?: string } { try { ensureConfigDir(); const cronEntry = buildCronEntry(); - // Remove existing tokscale sync entries, then add new one + // Remove existing tokscale sync entries (by marker), then add new one // Uses || true to handle empty crontab gracefully // Use printf with single quotes to prevent shell injection if path contains $() or backticks const escapedEntry = cronEntry.replace(/'/g, "'\\''"); - const command = `(crontab -l 2>/dev/null | grep -v 'tokscale submit --quiet' || true) | { cat; printf '%s\\n' '${escapedEntry}'; } | crontab -`; + const command = `(crontab -l 2>/dev/null | grep -v '${CRON_MARKER}' || true) | { cat; printf '%s\\n' '${escapedEntry}'; } | crontab -`; execSync(command, { stdio: "pipe" }); return { success: true }; } catch (error) { @@ -61,8 +63,7 @@ function setupCrontab(): { success: boolean; error?: string } { function removeCrontab(): { success: boolean; error?: string } { try { - // Use specific pattern to only remove our sync entry, not unrelated tokscale jobs - const command = `(crontab -l 2>/dev/null | grep -v 'tokscale submit --quiet' || true) | crontab -`; + const command = `(crontab -l 2>/dev/null | grep -v '${CRON_MARKER}' || true) | crontab -`; execSync(command, { stdio: "pipe" }); return { success: true }; } catch (error) { @@ -72,8 +73,7 @@ function removeCrontab(): { success: boolean; error?: string } { function checkCrontab(): { exists: boolean; entry?: string; error?: string } { try { - // Use specific pattern to only find our sync entry - const result = execSync(`crontab -l 2>/dev/null | grep 'tokscale submit --quiet' || true`, { + const result = execSync(`crontab -l 2>/dev/null | grep '${CRON_MARKER}' || true`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); @@ -99,10 +99,10 @@ function setupWindowsTask(): { success: boolean; error?: string } { // Task doesn't exist yet } - // Wrap in cmd.exe to enable shell redirections (>> and 2>&1) - // Without cmd.exe, schtasks /tr doesn't run under a shell, so redirections won't work - const windowsCommand = `cmd.exe /c "\\"${tokscalePath}\\" submit --quiet >> \\"${LOG_FILE}\\" 2>&1"`; - const scheduleCmd = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "${windowsCommand}" /f`; + const scriptContent = `@echo off\r\n"${tokscalePath}" submit --quiet >> "${LOG_FILE}" 2>&1\r\n`; + fs.writeFileSync(WINDOWS_SCRIPT_FILE, scriptContent); + + const scheduleCmd = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "${WINDOWS_SCRIPT_FILE}" /f`; execSync(scheduleCmd, { stdio: "pipe" }); return { success: true }; } catch (error) { @@ -113,14 +113,20 @@ function setupWindowsTask(): { success: boolean; error?: string } { function removeWindowsTask(): { success: boolean; error?: string } { try { execSync(`schtasks /delete /tn "${WINDOWS_TASK_NAME}" /f`, { stdio: "pipe" }); - return { success: true }; } catch (error) { const errorMsg = (error as Error).message; - if (errorMsg.includes("cannot find") || errorMsg.includes("task name")) { - return { success: true }; + if (!errorMsg.includes("cannot find") && !errorMsg.includes("task name")) { + return { success: false, error: errorMsg }; } - return { success: false, error: errorMsg }; } + + try { + if (fs.existsSync(WINDOWS_SCRIPT_FILE)) { + fs.unlinkSync(WINDOWS_SCRIPT_FILE); + } + } catch {} + + return { success: true }; } function checkWindowsTask(): { exists: boolean; error?: string } { @@ -177,6 +183,9 @@ export async function setupSync(_options: SyncSetupOptions = {}): Promise console.log(); console.log(pc.white(" Schedule: Hourly (at minute 0)")); console.log(pc.white(" Command: tokscale submit --quiet")); + if (isWindows()) { + console.log(pc.gray(` Script: ${WINDOWS_SCRIPT_FILE}`)); + } console.log(pc.gray(` Logs: ${LOG_FILE}`)); console.log(); console.log(pc.gray(" Your usage data will be automatically submitted every hour.")); @@ -225,6 +234,7 @@ export async function syncStatus(): Promise { } else if (status.exists) { console.log(pc.green(" Status: Active")); console.log(pc.gray(` Task: ${WINDOWS_TASK_NAME}`)); + console.log(pc.gray(` Script: ${WINDOWS_SCRIPT_FILE}`)); console.log(pc.gray(` Schedule: Hourly`)); console.log(pc.gray(` Logs: ${LOG_FILE}`)); } else { diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 2c1e19136..c3d03f80e 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -616,6 +616,88 @@ describe('POST /api/submit - Source-Level Merge', () => { }); }); + describe('mergeSourceBreakdowns - direct function calls', () => { + it('should handle legacy data without devices field', () => { + const existing: Record = { + claude: { + tokens: 1000, cost: 0.05, input: 500, output: 500, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10, + models: { 'claude-3': { tokens: 1000, cost: 0.05, input: 500, output: 500, cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10 } } + } + }; + + const incoming: Record = { + claude: { + tokens: 500, cost: 0.02, input: 250, output: 250, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 5, + models: { 'claude-3': { tokens: 500, cost: 0.02, input: 250, output: 250, cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 5 } } + } + }; + + const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); + + expect(result.claude.devices?.['__legacy__']).toBeDefined(); + expect(result.claude.devices?.['device-1']).toBeDefined(); + expect(result.claude.tokens).toBe(1500); + }); + + it('should replace same device data on resubmit (no double-count)', () => { + const existing: Record = { + claude: { + tokens: 1000, cost: 0.05, input: 500, output: 500, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10, + models: {}, + devices: { + 'device-1': { + tokens: 1000, cost: 0.05, input: 500, output: 500, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10, + models: {} + } + } + } + }; + + const incoming: Record = { + claude: { + tokens: 1500, cost: 0.08, input: 750, output: 750, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 15, + models: {} + } + }; + + const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); + + expect(result.claude.tokens).toBe(1500); + expect(result.claude.devices?.['device-1']?.tokens).toBe(1500); + }); + + it('should handle legacy data with only modelId (no models field)', () => { + const existing: Record = { + claude: { + tokens: 1000, cost: 0.05, input: 500, output: 500, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10, + models: undefined as unknown as Record, + modelId: 'claude-sonnet-4' + } + }; + + const incoming: Record = { + claude: { + tokens: 500, cost: 0.02, input: 250, output: 250, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 5, + models: {} + } + }; + + const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); + + expect(result.claude.devices?.['__legacy__']).toBeDefined(); + expect(result.claude.devices?.['__legacy__']?.models).toEqual({}); + expect(result.claude.devices?.['device-1']).toBeDefined(); + expect(result.claude.tokens).toBe(1500); + }); + }); + describe('Device-Level Tracking (Cross-Machine Aggregation)', () => { const createSourceData = (tokens: number, cost: number, modelId = 'claude-sonnet-4'): SourceBreakdownData => ({ tokens, diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 0eaeea140..a05d9a18a 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -110,7 +110,7 @@ function recalculateSourceAggregate(source: SourceBreakdownData): void { source.reasoning += deviceData.reasoning || 0; source.messages += deviceData.messages; - for (const [modelId, modelData] of Object.entries(deviceData.models)) { + for (const [modelId, modelData] of Object.entries(deviceData.models ?? {})) { if (!source.models[modelId]) { source.models[modelId] = { ...modelData }; } else { @@ -168,7 +168,7 @@ export function mergeSourceBreakdowns( cacheWrite: merged[sourceName].cacheWrite, reasoning: merged[sourceName].reasoning || 0, messages: merged[sourceName].messages, - models: { ...merged[sourceName].models }, + models: { ...(merged[sourceName].models ?? {}) }, }, }; } @@ -182,7 +182,7 @@ export function mergeSourceBreakdowns( cacheWrite: incomingSource.cacheWrite, reasoning: incomingSource.reasoning || 0, messages: incomingSource.messages, - models: { ...incomingSource.models }, + models: { ...(incomingSource.models ?? {}) }, }; recalculateSourceAggregate(merged[sourceName]); From 3ccf76178c4064ee41f351a20814ac016ce46cf6 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 11:11:46 +0000 Subject: [PATCH 07/13] fix: address Oracle review round 3 - legacy inherits to current device, Windows cmd wrapper --- packages/cli/src/sync.ts | 3 ++- .../frontend/__tests__/api/submit.test.ts | 22 +++++++++---------- packages/frontend/src/lib/db/helpers.ts | 17 +++----------- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts index caf8bd758..f86decc19 100644 --- a/packages/cli/src/sync.ts +++ b/packages/cli/src/sync.ts @@ -102,7 +102,8 @@ function setupWindowsTask(): { success: boolean; error?: string } { const scriptContent = `@echo off\r\n"${tokscalePath}" submit --quiet >> "${LOG_FILE}" 2>&1\r\n`; fs.writeFileSync(WINDOWS_SCRIPT_FILE, scriptContent); - const scheduleCmd = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "${WINDOWS_SCRIPT_FILE}" /f`; + // Use cmd.exe /c wrapper for robustness with Task Scheduler + const scheduleCmd = `schtasks /create /tn "${WINDOWS_TASK_NAME}" /sc HOURLY /tr "cmd.exe /c \\"${WINDOWS_SCRIPT_FILE}\\"" /f`; execSync(scheduleCmd, { stdio: "pipe" }); return { success: true }; } catch (error) { diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index c3d03f80e..d016bc01c 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -617,7 +617,7 @@ describe('POST /api/submit - Source-Level Merge', () => { }); describe('mergeSourceBreakdowns - direct function calls', () => { - it('should handle legacy data without devices field', () => { + it('should handle legacy data without devices field (first device inherits, no __legacy__)', () => { const existing: Record = { claude: { tokens: 1000, cost: 0.05, input: 500, output: 500, @@ -636,9 +636,9 @@ describe('POST /api/submit - Source-Level Merge', () => { const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); - expect(result.claude.devices?.['__legacy__']).toBeDefined(); + expect(result.claude.devices?.['__legacy__']).toBeUndefined(); expect(result.claude.devices?.['device-1']).toBeDefined(); - expect(result.claude.tokens).toBe(1500); + expect(result.claude.tokens).toBe(500); }); it('should replace same device data on resubmit (no double-count)', () => { @@ -671,7 +671,7 @@ describe('POST /api/submit - Source-Level Merge', () => { expect(result.claude.devices?.['device-1']?.tokens).toBe(1500); }); - it('should handle legacy data with only modelId (no models field)', () => { + it('should handle legacy data with only modelId - first device inherits (no __legacy__)', () => { const existing: Record = { claude: { tokens: 1000, cost: 0.05, input: 500, output: 500, @@ -691,10 +691,9 @@ describe('POST /api/submit - Source-Level Merge', () => { const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); - expect(result.claude.devices?.['__legacy__']).toBeDefined(); - expect(result.claude.devices?.['__legacy__']?.models).toEqual({}); + expect(result.claude.devices?.['__legacy__']).toBeUndefined(); expect(result.claude.devices?.['device-1']).toBeDefined(); - expect(result.claude.tokens).toBe(1500); + expect(result.claude.tokens).toBe(500); }); }); @@ -770,7 +769,7 @@ describe('POST /api/submit - Source-Level Merge', () => { expect(Object.keys(afterB.claude.devices || {}).length).toBe(2); }); - it('should migrate existing data without devices field to __legacy__ device', () => { + it('should NOT create __legacy__ when migrating - first device inherits legacy data', () => { const newDevice = 'new-device-uuid'; const sources = new Set(['claude']); @@ -805,11 +804,10 @@ describe('POST /api/submit - Source-Level Merge', () => { const merged = mergeSourceBreakdowns(existingWithoutDevices, newSubmission, sources, newDevice); - expect(merged.claude.devices?.['__legacy__']).toBeDefined(); - expect(merged.claude.devices?.['__legacy__']?.tokens).toBe(1000); + expect(merged.claude.devices?.['__legacy__']).toBeUndefined(); expect(merged.claude.devices?.[newDevice]?.tokens).toBe(500); - expect(merged.claude.tokens).toBe(1500); - expect(merged.claude.cost).toBe(15); + expect(merged.claude.tokens).toBe(500); + expect(merged.claude.cost).toBe(5); }); it('should aggregate models across devices correctly', () => { diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index a05d9a18a..fb9e0a29a 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -157,23 +157,12 @@ export function mergeSourceBreakdowns( }; } + // CRITICAL: Legacy migration - first device inherits (no __legacy__ to avoid double-count) if (!merged[sourceName].devices) { - merged[sourceName].devices = { - __legacy__: { - tokens: merged[sourceName].tokens, - cost: merged[sourceName].cost, - input: merged[sourceName].input, - output: merged[sourceName].output, - cacheRead: merged[sourceName].cacheRead, - cacheWrite: merged[sourceName].cacheWrite, - reasoning: merged[sourceName].reasoning || 0, - messages: merged[sourceName].messages, - models: { ...(merged[sourceName].models ?? {}) }, - }, - }; + merged[sourceName].devices = {}; } - merged[sourceName].devices![deviceId] = { + merged[sourceName].devices[deviceId] = { tokens: incomingSource.tokens, cost: incomingSource.cost, input: incomingSource.input, From 9f7ca839c073d84cf83d22c4f797c748607c8f01 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 11:20:40 +0000 Subject: [PATCH 08/13] fix: preserve other devices contributions in merge, proper legacy migration --- packages/frontend/src/lib/db/helpers.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index fb9e0a29a..0ce12c2f0 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -157,12 +157,28 @@ export function mergeSourceBreakdowns( }; } - // CRITICAL: Legacy migration - first device inherits (no __legacy__ to avoid double-count) + // MIGRATION: If existing source has NO devices field (legacy data) if (!merged[sourceName].devices) { - merged[sourceName].devices = {}; + // Seed devices with legacy totals under current deviceId + // This preserves historical data as this device's contribution + merged[sourceName].devices = { + [deviceId]: { + tokens: merged[sourceName].tokens, + cost: merged[sourceName].cost, + input: merged[sourceName].input, + output: merged[sourceName].output, + cacheRead: merged[sourceName].cacheRead, + cacheWrite: merged[sourceName].cacheWrite, + reasoning: merged[sourceName].reasoning || 0, + messages: merged[sourceName].messages, + models: { ...(merged[sourceName].models ?? {}) }, + }, + }; } - merged[sourceName].devices[deviceId] = { + // REPLACE this device's contribution (handles resubmits correctly) + // This preserves OTHER devices' contributions + merged[sourceName].devices![deviceId] = { tokens: incomingSource.tokens, cost: incomingSource.cost, input: incomingSource.input, From 7eb3e77937db5a3e080eb3fedaf121f963925a8a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 11:28:37 +0000 Subject: [PATCH 09/13] fix: restore __legacy__ migration to preserve historical data, add type coercion - Legacy migration now uses '__legacy__' device instead of current deviceId - This preserves historical data separately from new device contributions - Added Number(...) || 0 for all arithmetic in recalculateSourceAggregate - Handles potential string values from JSON serialization - Updated tests to expect __legacy__ entry for legacy data migrations --- .../frontend/__tests__/api/submit.test.ts | 27 +++++++---- packages/frontend/src/lib/db/helpers.ts | 48 +++++++++++-------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index d016bc01c..78b87c717 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -617,7 +617,7 @@ describe('POST /api/submit - Source-Level Merge', () => { }); describe('mergeSourceBreakdowns - direct function calls', () => { - it('should handle legacy data without devices field (first device inherits, no __legacy__)', () => { + it('should migrate legacy data to __legacy__ device and aggregate with new device', () => { const existing: Record = { claude: { tokens: 1000, cost: 0.05, input: 500, output: 500, @@ -636,9 +636,12 @@ describe('POST /api/submit - Source-Level Merge', () => { const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); - expect(result.claude.devices?.['__legacy__']).toBeUndefined(); + expect(result.claude.devices?.['__legacy__']).toBeDefined(); + expect(result.claude.devices?.['__legacy__']?.tokens).toBe(1000); expect(result.claude.devices?.['device-1']).toBeDefined(); - expect(result.claude.tokens).toBe(500); + expect(result.claude.devices?.['device-1']?.tokens).toBe(500); + expect(result.claude.tokens).toBe(1500); + expect(result.claude.cost).toBeCloseTo(0.07, 4); }); it('should replace same device data on resubmit (no double-count)', () => { @@ -671,7 +674,7 @@ describe('POST /api/submit - Source-Level Merge', () => { expect(result.claude.devices?.['device-1']?.tokens).toBe(1500); }); - it('should handle legacy data with only modelId - first device inherits (no __legacy__)', () => { + it('should migrate legacy data with only modelId to __legacy__ device', () => { const existing: Record = { claude: { tokens: 1000, cost: 0.05, input: 500, output: 500, @@ -691,9 +694,12 @@ describe('POST /api/submit - Source-Level Merge', () => { const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); - expect(result.claude.devices?.['__legacy__']).toBeUndefined(); + expect(result.claude.devices?.['__legacy__']).toBeDefined(); + expect(result.claude.devices?.['__legacy__']?.tokens).toBe(1000); expect(result.claude.devices?.['device-1']).toBeDefined(); - expect(result.claude.tokens).toBe(500); + expect(result.claude.devices?.['device-1']?.tokens).toBe(500); + expect(result.claude.tokens).toBe(1500); + expect(result.claude.cost).toBeCloseTo(0.07, 4); }); }); @@ -769,7 +775,7 @@ describe('POST /api/submit - Source-Level Merge', () => { expect(Object.keys(afterB.claude.devices || {}).length).toBe(2); }); - it('should NOT create __legacy__ when migrating - first device inherits legacy data', () => { + it('should migrate legacy data to __legacy__ device and preserve it', () => { const newDevice = 'new-device-uuid'; const sources = new Set(['claude']); @@ -804,10 +810,11 @@ describe('POST /api/submit - Source-Level Merge', () => { const merged = mergeSourceBreakdowns(existingWithoutDevices, newSubmission, sources, newDevice); - expect(merged.claude.devices?.['__legacy__']).toBeUndefined(); + expect(merged.claude.devices?.['__legacy__']).toBeDefined(); + expect(merged.claude.devices?.['__legacy__']?.tokens).toBe(1000); expect(merged.claude.devices?.[newDevice]?.tokens).toBe(500); - expect(merged.claude.tokens).toBe(500); - expect(merged.claude.cost).toBe(5); + expect(merged.claude.tokens).toBe(1500); + expect(merged.claude.cost).toBe(15); }); it('should aggregate models across devices correctly', () => { diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 0ce12c2f0..0fd2c8643 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -101,28 +101,37 @@ function recalculateSourceAggregate(source: SourceBreakdownData): void { source.models = {}; for (const deviceData of Object.values(source.devices)) { - source.tokens += deviceData.tokens; - source.cost += deviceData.cost; - source.input += deviceData.input; - source.output += deviceData.output; - source.cacheRead += deviceData.cacheRead; - source.cacheWrite += deviceData.cacheWrite; - source.reasoning += deviceData.reasoning || 0; - source.messages += deviceData.messages; + source.tokens += Number(deviceData.tokens) || 0; + source.cost += Number(deviceData.cost) || 0; + source.input += Number(deviceData.input) || 0; + source.output += Number(deviceData.output) || 0; + source.cacheRead += Number(deviceData.cacheRead) || 0; + source.cacheWrite += Number(deviceData.cacheWrite) || 0; + source.reasoning += Number(deviceData.reasoning) || 0; + source.messages += Number(deviceData.messages) || 0; for (const [modelId, modelData] of Object.entries(deviceData.models ?? {})) { if (!source.models[modelId]) { - source.models[modelId] = { ...modelData }; + source.models[modelId] = { + tokens: Number(modelData.tokens) || 0, + cost: Number(modelData.cost) || 0, + input: Number(modelData.input) || 0, + output: Number(modelData.output) || 0, + cacheRead: Number(modelData.cacheRead) || 0, + cacheWrite: Number(modelData.cacheWrite) || 0, + reasoning: Number(modelData.reasoning) || 0, + messages: Number(modelData.messages) || 0, + }; } else { const m = source.models[modelId]; - m.tokens += modelData.tokens; - m.cost += modelData.cost; - m.input += modelData.input; - m.output += modelData.output; - m.cacheRead += modelData.cacheRead; - m.cacheWrite += modelData.cacheWrite; - m.reasoning += modelData.reasoning || 0; - m.messages += modelData.messages; + m.tokens += Number(modelData.tokens) || 0; + m.cost += Number(modelData.cost) || 0; + m.input += Number(modelData.input) || 0; + m.output += Number(modelData.output) || 0; + m.cacheRead += Number(modelData.cacheRead) || 0; + m.cacheWrite += Number(modelData.cacheWrite) || 0; + m.reasoning += Number(modelData.reasoning) || 0; + m.messages += Number(modelData.messages) || 0; } } } @@ -158,11 +167,10 @@ export function mergeSourceBreakdowns( } // MIGRATION: If existing source has NO devices field (legacy data) + // Use "__legacy__" device to preserve historical data separately from current device if (!merged[sourceName].devices) { - // Seed devices with legacy totals under current deviceId - // This preserves historical data as this device's contribution merged[sourceName].devices = { - [deviceId]: { + "__legacy__": { tokens: merged[sourceName].tokens, cost: merged[sourceName].cost, input: merged[sourceName].input, From 6b177e91cba386c7189f0b654e0a6bdf8f109067 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 11:31:41 +0000 Subject: [PATCH 10/13] fix: handle empty devices aggregation, complete type coercion - Reset aggregates before empty devices check to prevent stale values - Use Number() coercion in recalculateDayTotals for consistency --- packages/frontend/src/lib/db/helpers.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 0fd2c8643..3b600643d 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -67,13 +67,13 @@ export function recalculateDayTotals( let reasoningTokens = 0; for (const source of Object.values(sourceBreakdown)) { - tokens += source.tokens || 0; - cost += source.cost || 0; - inputTokens += source.input || 0; - outputTokens += source.output || 0; - cacheReadTokens += source.cacheRead || 0; - cacheWriteTokens += source.cacheWrite || 0; - reasoningTokens += source.reasoning || 0; + tokens += Number(source.tokens) || 0; + cost += Number(source.cost) || 0; + inputTokens += Number(source.input) || 0; + outputTokens += Number(source.output) || 0; + cacheReadTokens += Number(source.cacheRead) || 0; + cacheWriteTokens += Number(source.cacheWrite) || 0; + reasoningTokens += Number(source.reasoning) || 0; } return { @@ -88,8 +88,7 @@ export function recalculateDayTotals( } function recalculateSourceAggregate(source: SourceBreakdownData): void { - if (!source.devices || Object.keys(source.devices).length === 0) return; - + // Reset aggregates first - always (prevents stale values when devices becomes empty) source.tokens = 0; source.cost = 0; source.input = 0; @@ -100,6 +99,9 @@ function recalculateSourceAggregate(source: SourceBreakdownData): void { source.messages = 0; source.models = {}; + // If no devices or empty devices, aggregates are zero (which we just set) + if (!source.devices || Object.keys(source.devices).length === 0) return; + for (const deviceData of Object.values(source.devices)) { source.tokens += Number(deviceData.tokens) || 0; source.cost += Number(deviceData.cost) || 0; From 6591b0ef4ddecad5534ee957f834bc05234480de Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Fri, 26 Dec 2025 11:35:27 +0000 Subject: [PATCH 11/13] fix: complete type coercion in route.ts aggregation Convert all || 0 patterns to Number(...) || 0 to handle cases where values may be strings from JSON parsing or database retrieval. --- packages/frontend/src/app/api/submit/route.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/frontend/src/app/api/submit/route.ts b/packages/frontend/src/app/api/submit/route.ts index 4516967d1..7f3ea5cf2 100644 --- a/packages/frontend/src/app/api/submit/route.ts +++ b/packages/frontend/src/app/api/submit/route.ts @@ -209,7 +209,7 @@ export async function POST(request: Request) { existing.output += modelData.output; existing.cacheRead += modelData.cacheRead; existing.cacheWrite += modelData.cacheWrite; - existing.reasoning = (existing.reasoning || 0) + modelData.reasoning; + existing.reasoning = (Number(existing.reasoning) || 0) + modelData.reasoning; existing.messages += modelData.messages; const existingModel = existing.models[source.modelId]; if (existingModel) { @@ -219,7 +219,7 @@ export async function POST(request: Request) { existingModel.output += modelData.output; existingModel.cacheRead += modelData.cacheRead; existingModel.cacheWrite += modelData.cacheWrite; - existingModel.reasoning = (existingModel.reasoning || 0) + modelData.reasoning; + existingModel.reasoning = (Number(existingModel.reasoning) || 0) + modelData.reasoning; existingModel.messages += modelData.messages; } else { existing.models[source.modelId] = modelData; @@ -280,7 +280,7 @@ export async function POST(request: Request) { output: sourceData.output, cacheRead: sourceData.cacheRead, cacheWrite: sourceData.cacheWrite, - reasoning: sourceData.reasoning || 0, + reasoning: Number(sourceData.reasoning) || 0, messages: sourceData.messages, models: { ...sourceData.models }, }, @@ -347,9 +347,9 @@ export async function POST(request: Request) { } else if (sd.modelId) { allModels.add(sd.modelId); } - totalCacheRead += sd.cacheRead || 0; - totalCacheCreation += sd.cacheWrite || 0; - totalReasoning += sd.reasoning || 0; + totalCacheRead += Number(sd.cacheRead) || 0; + totalCacheCreation += Number(sd.cacheWrite) || 0; + totalReasoning += Number(sd.reasoning) || 0; } } } From 5f932fc1f83d945950b94fdadf65b3c3b1b9e249 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sun, 28 Dec 2025 19:25:00 +0900 Subject: [PATCH 12/13] fix: address security and stability issues in cross-machine sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Windows sync: Mark as experimental/disabled pending security review - bunx detection: Prevent sync setup from temp cache paths with clear error message - Crontab security: Add path validation to prevent injection via control chars - Legacy migration: Fix modelId→models conversion to preserve model breakdown Closes issues identified in PR #55 review. --- packages/cli/src/sync.ts | 148 +++++++++++++++--- .../frontend/__tests__/api/submit.test.ts | 53 +++++++ packages/frontend/src/lib/db/helpers.ts | 23 ++- 3 files changed, 201 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts index f86decc19..d889c7e24 100644 --- a/packages/cli/src/sync.ts +++ b/packages/cli/src/sync.ts @@ -22,8 +22,12 @@ function ensureConfigDir(): void { } } -function getTokscalePath(): string { - return process.argv[1]; +function getTokscalePath(): { path: string; isTempPath: boolean } { + const path = process.argv[1]; + if (!path) { + return { path: '', isTempPath: false }; + } + return { path, isTempPath: isBunxTempPath(path) }; } // Escape single quotes for shell: replace ' with '\'' @@ -31,16 +35,90 @@ function escapePathForShell(filePath: string): string { return filePath.replace(/'/g, "'\\''"); } +/** + * Validate that a path is safe to use in shell commands and crontab. + * Rejects paths containing control characters that could inject entries. + * + * Security note: The % character is special in crontab - it's converted to newlines. + * From crontab(5): "Percent-signs (%) in the command, unless escaped with + * backslash (\), will be changed into newline characters" + */ +function validatePathSafety(filePath: string): { safe: boolean; reason?: string } { + // Check for newlines (could inject crontab entries) + if (filePath.includes('\n') || filePath.includes('\r')) { + return { safe: false, reason: 'Path contains newline characters' }; + } + + // Check for null bytes + if (filePath.includes('\0')) { + return { safe: false, reason: 'Path contains null bytes' }; + } + + // Check for percent signs (cron converts % to newlines - injection vector!) + if (filePath.includes('%')) { + return { safe: false, reason: 'Path contains % (cron special character that becomes newline)' }; + } + + // Check for ALL control characters (ASCII 0-31 including tab, plus DEL 0x7F) + const controlCharRegex = /[\x00-\x1F\x7F]/; + if (controlCharRegex.test(filePath)) { + return { safe: false, reason: 'Path contains control characters' }; + } + + return { safe: true }; +} + function isWindows(): boolean { return process.platform === "win32"; } +/** + * Check if running from a temp bunx cache path that may be cleaned up. + * + * Primary bunx patterns: + * - /tmp/bunx--/node_modules/.bin/ (most common!) + * - ~/.bun/install/cache/@tokscale/cli@x.x.x/... + * - /var/folders/.../T/bunx-... (macOS) + */ +function isBunxTempPath(filePath: string): boolean { + const p = filePath.toLowerCase(); + + // Pattern 1: Primary bunx temp execution (MOST COMMON) + // e.g., /tmp/bunx-501-tokscale/node_modules/.bin/tokscale + if (/\/bunx-\d+-/.test(p) && p.includes('/node_modules/.bin/')) { + return true; + } + + // Pattern 2: Bun install cache + if (p.includes('/.bun/install/cache/') || p.includes('\\.bun\\install\\cache\\')) { + return true; + } + + // Pattern 3: Bun tmp directory + if (p.includes('/.bun/tmp/') || p.includes('\\.bun\\tmp\\')) { + return true; + } + + // Pattern 4: macOS temp folders with bunx + if (p.includes('/var/folders/') && p.includes('/t/') && p.includes('bunx')) { + return true; + } + + // Pattern 5: Linux system temp with bunx + if (p.startsWith('/tmp/bunx-')) { + return true; + } + + return false; +} + // ============================================================================= // Crontab (macOS/Linux) // ============================================================================= function buildCronEntry(): string { - const tokscalePath = escapePathForShell(getTokscalePath()); + const { path } = getTokscalePath(); + const tokscalePath = escapePathForShell(path); const logPath = escapePathForShell(LOG_FILE); return `0 * * * * '${tokscalePath}' submit --quiet >> '${logPath}' 2>&1 ${CRON_MARKER}`; } @@ -48,6 +126,18 @@ function buildCronEntry(): string { function setupCrontab(): { success: boolean; error?: string } { try { ensureConfigDir(); + + const { path } = getTokscalePath(); + const pathValidation = validatePathSafety(path); + if (!pathValidation.safe) { + return { success: false, error: `Unsafe CLI path: ${pathValidation.reason}` }; + } + + const logPathValidation = validatePathSafety(LOG_FILE); + if (!logPathValidation.safe) { + return { success: false, error: `Unsafe log path: ${logPathValidation.reason}` }; + } + const cronEntry = buildCronEntry(); // Remove existing tokscale sync entries (by marker), then add new one // Uses || true to handle empty crontab gracefully @@ -91,7 +181,7 @@ function checkCrontab(): { exists: boolean; entry?: string; error?: string } { function setupWindowsTask(): { success: boolean; error?: string } { try { ensureConfigDir(); - const tokscalePath = getTokscalePath(); + const { path: tokscalePath } = getTokscalePath(); try { execSync(`schtasks /delete /tn "${WINDOWS_TASK_NAME}" /f`, { stdio: "pipe" }); @@ -163,17 +253,40 @@ export async function setupSync(_options: SyncSetupOptions = {}): Promise console.log(pc.gray(` Logged in as: ${credentials.username}`)); console.log(); + const tokscalePath = getTokscalePath(); + if (!tokscalePath.path) { + console.log(pc.red("\n Error: Could not determine CLI path.")); + console.log(pc.gray(" Please run tokscale using a full path.\n")); + process.exit(1); + } + + if (tokscalePath.isTempPath) { + console.log(pc.red("\n Error: Cannot set up sync when running via bunx.")); + console.log(); + console.log(pc.white(" The CLI is running from a temporary cache directory that may be cleaned up:")); + console.log(pc.gray(` ${tokscalePath.path}`)); + console.log(); + console.log(pc.white(" To fix, install tokscale globally:")); + console.log(pc.cyan(" bun add -g tokscale")); + console.log(); + console.log(pc.white(" Then run:")); + console.log(pc.cyan(" tokscale sync setup")); + console.log(); + process.exit(1); + } + const platform = isWindows() ? "Windows" : process.platform === "darwin" ? "macOS" : "Linux"; console.log(pc.gray(` Platform: ${platform}`)); - console.log(pc.gray(` CLI path: ${getTokscalePath()}`)); + console.log(pc.gray(` CLI path: ${tokscalePath.path}`)); console.log(pc.gray(` Log file: ${LOG_FILE}`)); console.log(); let result: { success: boolean; error?: string }; if (isWindows()) { - console.log(pc.gray(" Creating Windows Task Scheduler entry...")); - result = setupWindowsTask(); + console.log(pc.yellow("\n ⚠️ Windows sync support is experimental and disabled by default.")); + console.log(pc.gray(" Windows Task Scheduler integration requires additional security review.\n")); + process.exit(1); } else { console.log(pc.gray(" Adding crontab entry...")); result = setupCrontab(); @@ -205,8 +318,9 @@ export async function removeSync(): Promise { let result: { success: boolean; error?: string }; if (isWindows()) { - console.log(pc.gray(" Removing Windows Task Scheduler entry...")); - result = removeWindowsTask(); + console.log(pc.yellow(" Windows sync was never enabled (experimental).")); + console.log(); + return; } else { console.log(pc.gray(" Removing crontab entry...")); result = removeCrontab(); @@ -229,19 +343,9 @@ export async function syncStatus(): Promise { console.log(pc.gray(` Platform: ${platform}`)); if (isWindows()) { - const status = checkWindowsTask(); - if (status.error) { - console.log(pc.yellow(` Status: Unable to check (${status.error})`)); - } else if (status.exists) { - console.log(pc.green(" Status: Active")); - console.log(pc.gray(` Task: ${WINDOWS_TASK_NAME}`)); - console.log(pc.gray(` Script: ${WINDOWS_SCRIPT_FILE}`)); - console.log(pc.gray(` Schedule: Hourly`)); - console.log(pc.gray(` Logs: ${LOG_FILE}`)); - } else { - console.log(pc.yellow(" Status: Not configured")); - console.log(pc.gray(" Run 'tokscale sync setup' to enable automatic sync.")); - } + console.log(pc.yellow(" Status: Windows sync is experimental (disabled)")); + console.log(); + return; } else { const status = checkCrontab(); if (status.error) { diff --git a/packages/frontend/__tests__/api/submit.test.ts b/packages/frontend/__tests__/api/submit.test.ts index 78b87c717..324d0ed2c 100644 --- a/packages/frontend/__tests__/api/submit.test.ts +++ b/packages/frontend/__tests__/api/submit.test.ts @@ -891,4 +891,57 @@ describe('POST /api/submit - Source-Level Merge', () => { expect(afterB.cursor.devices?.[deviceB]).toBeUndefined(); }); }); + + describe('modelId Migration Tests', () => { + it('should migrate legacy data with only modelId to __legacy__ device with correct model breakdown', () => { + const existing: Record = { + claude: { + tokens: 1000, cost: 0.05, input: 500, output: 500, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10, + models: undefined as unknown as Record, + modelId: 'claude-sonnet-4' + } + }; + + const incoming: Record = { + claude: { + tokens: 500, cost: 0.02, input: 250, output: 250, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 5, + models: {} + } + }; + + const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); + + // Verify __legacy__ device was created with model breakdown from modelId + expect(result.claude.devices?.['__legacy__']?.models['claude-sonnet-4']).toBeDefined(); + expect(result.claude.devices?.['__legacy__']?.models['claude-sonnet-4']?.tokens).toBe(1000); + expect(result.claude.devices?.['__legacy__']?.models['claude-sonnet-4']?.cost).toBe(0.05); + }); + + it('should handle legacy data with EMPTY models object and modelId (falls back to modelId)', () => { + const existing: Record = { + claude: { + tokens: 1000, cost: 0.05, input: 500, output: 500, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 10, + models: {}, // Empty object - should fall back to modelId + modelId: 'claude-sonnet-4' + } + }; + + const incoming: Record = { + claude: { + tokens: 500, cost: 0.02, input: 250, output: 250, + cacheRead: 0, cacheWrite: 0, reasoning: 0, messages: 5, + models: {} + } + }; + + const result = mergeSourceBreakdowns(existing, incoming, new Set(['claude']), 'device-1'); + + // Should use modelId since models is empty + expect(result.claude.devices?.['__legacy__']?.models['claude-sonnet-4']).toBeDefined(); + expect(result.claude.devices?.['__legacy__']?.models['claude-sonnet-4']?.tokens).toBe(1000); + }); + }); }); diff --git a/packages/frontend/src/lib/db/helpers.ts b/packages/frontend/src/lib/db/helpers.ts index 3b600643d..7b1083f1a 100644 --- a/packages/frontend/src/lib/db/helpers.ts +++ b/packages/frontend/src/lib/db/helpers.ts @@ -171,6 +171,27 @@ export function mergeSourceBreakdowns( // MIGRATION: If existing source has NO devices field (legacy data) // Use "__legacy__" device to preserve historical data separately from current device if (!merged[sourceName].devices) { + // Check for NON-EMPTY models (empty {} is truthy but should fall back to modelId) + const hasModels = merged[sourceName].models && + Object.keys(merged[sourceName].models).length > 0; + + const legacyModels: Record = hasModels + ? { ...merged[sourceName].models } + : merged[sourceName].modelId?.trim() // Normalize empty strings + ? { + [merged[sourceName].modelId!]: { + tokens: merged[sourceName].tokens, + cost: merged[sourceName].cost, + input: merged[sourceName].input, + output: merged[sourceName].output, + cacheRead: merged[sourceName].cacheRead, + cacheWrite: merged[sourceName].cacheWrite, + reasoning: merged[sourceName].reasoning || 0, + messages: merged[sourceName].messages, + }, + } + : {}; + merged[sourceName].devices = { "__legacy__": { tokens: merged[sourceName].tokens, @@ -181,7 +202,7 @@ export function mergeSourceBreakdowns( cacheWrite: merged[sourceName].cacheWrite, reasoning: merged[sourceName].reasoning || 0, messages: merged[sourceName].messages, - models: { ...(merged[sourceName].models ?? {}) }, + models: legacyModels, }, }; } From 46e2e43f53f6b9c2c82b989db5d69bd4c8130fd6 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Wed, 7 Jan 2026 07:32:20 +0900 Subject: [PATCH 13/13] refactor: remove --quiet flag from submit command The submit command already has minimal necessary logs, and output is redirected to log file for cron jobs anyway. --no-spinner exists for scripts that need clean stdout. --- packages/cli/src/cli.ts | 2 -- packages/cli/src/submit.ts | 69 ++++++++++++++++---------------------- packages/cli/src/sync.ts | 6 ++-- 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 413ea7618..88e90834d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -366,7 +366,6 @@ async function main() { .option("--until ", "End date (YYYY-MM-DD)") .option("--year ", "Filter to specific year") .option("--dry-run", "Show what would be submitted without actually submitting") - .option("--quiet", "Suppress output (for cron jobs)") .action(async (options) => { await submit({ opencode: options.opencode, @@ -380,7 +379,6 @@ async function main() { until: options.until, year: options.year, dryRun: options.dryRun, - quiet: options.quiet, }); }); diff --git a/packages/cli/src/submit.ts b/packages/cli/src/submit.ts index 8e8ffc3e6..5b0d30a37 100644 --- a/packages/cli/src/submit.ts +++ b/packages/cli/src/submit.ts @@ -22,7 +22,6 @@ interface SubmitOptions { until?: string; year?: string; dryRun?: boolean; - quiet?: boolean; } interface SubmitResponse { @@ -50,27 +49,17 @@ type SourceType = "opencode" | "claude" | "codex" | "gemini" | "cursor" | "amp" * Submit command - sends usage data to the platform */ export async function submit(options: SubmitOptions = {}): Promise { - const { quiet } = options; - - const logIfNotQuiet = (...args: Parameters) => { - if (!quiet) console.log(...args); - }; - // Step 1: Check if logged in const credentials = loadCredentials(); if (!credentials) { - if (!quiet) { - console.log(pc.yellow("\n Not logged in.")); - console.log(pc.gray(" Run 'tokscale login' first.\n")); - } else { - console.error("Error: Not logged in. Run 'tokscale login' first."); - } + console.log(pc.yellow("\n Not logged in.")); + console.log(pc.gray(" Run 'tokscale login' first.\n")); process.exit(1); } - logIfNotQuiet(pc.cyan("\n Tokscale - Submit Usage Data\n")); + console.log(pc.cyan("\n Tokscale - Submit Usage Data\n")); - logIfNotQuiet(pc.gray(" Scanning local session data...")); + console.log(pc.gray(" Scanning local session data...")); const hasFilter = options.opencode || options.claude || options.codex || options.gemini || options.cursor || options.amp || options.droid; let sources: SourceType[] | undefined; @@ -125,28 +114,28 @@ export async function submit(options: SubmitOptions = {}): Promise { } // Step 4: Show summary - logIfNotQuiet(pc.white(" Data to submit:")); - logIfNotQuiet(pc.gray(` Date range: ${data.meta.dateRange.start} to ${data.meta.dateRange.end}`)); - logIfNotQuiet(pc.gray(` Active days: ${data.summary.activeDays}`)); - logIfNotQuiet(pc.gray(` Total tokens: ${data.summary.totalTokens.toLocaleString()}`)); - logIfNotQuiet(pc.gray(` Total cost: ${formatCurrency(data.summary.totalCost)}`)); - logIfNotQuiet(pc.gray(` Sources: ${data.summary.sources.join(", ")}`)); - logIfNotQuiet(pc.gray(` Models: ${data.summary.models.length} models`)); - logIfNotQuiet(); + console.log(pc.white(" Data to submit:")); + console.log(pc.gray(` Date range: ${data.meta.dateRange.start} to ${data.meta.dateRange.end}`)); + console.log(pc.gray(` Active days: ${data.summary.activeDays}`)); + console.log(pc.gray(` Total tokens: ${data.summary.totalTokens.toLocaleString()}`)); + console.log(pc.gray(` Total cost: ${formatCurrency(data.summary.totalCost)}`)); + console.log(pc.gray(` Sources: ${data.summary.sources.join(", ")}`)); + console.log(pc.gray(` Models: ${data.summary.models.length} models`)); + console.log(); if (data.summary.totalTokens === 0) { - logIfNotQuiet(pc.yellow(" No usage data found to submit.\n")); + console.log(pc.yellow(" No usage data found to submit.\n")); return; } // Step 5: Dry run check if (options.dryRun) { - logIfNotQuiet(pc.yellow(" Dry run - not submitting data.\n")); + console.log(pc.yellow(" Dry run - not submitting data.\n")); return; } // Step 6: Submit to server - logIfNotQuiet(pc.gray(" Submitting to server...")); + console.log(pc.gray(" Submitting to server...")); const baseUrl = getApiBaseUrl(); @@ -169,28 +158,28 @@ export async function submit(options: SubmitOptions = {}): Promise { console.error(pc.gray(` - ${detail}`)); } } - logIfNotQuiet(); + console.log(); process.exit(1); } // Success! - logIfNotQuiet(pc.green("\n Successfully submitted!")); - logIfNotQuiet(); - logIfNotQuiet(pc.white(" Summary:")); - logIfNotQuiet(pc.gray(` Submission ID: ${result.submissionId}`)); - logIfNotQuiet(pc.gray(` Total tokens: ${result.metrics?.totalTokens?.toLocaleString()}`)); - logIfNotQuiet(pc.gray(` Total cost: ${formatCurrency(result.metrics?.totalCost || 0)}`)); - logIfNotQuiet(pc.gray(` Active days: ${result.metrics?.activeDays}`)); - logIfNotQuiet(); - logIfNotQuiet(pc.cyan(` View your profile: ${baseUrl}/u/${credentials.username}`)); - logIfNotQuiet(); + console.log(pc.green("\n Successfully submitted!")); + console.log(); + console.log(pc.white(" Summary:")); + console.log(pc.gray(` Submission ID: ${result.submissionId}`)); + console.log(pc.gray(` Total tokens: ${result.metrics?.totalTokens?.toLocaleString()}`)); + console.log(pc.gray(` Total cost: ${formatCurrency(result.metrics?.totalCost || 0)}`)); + console.log(pc.gray(` Active days: ${result.metrics?.activeDays}`)); + console.log(); + console.log(pc.cyan(` View your profile: ${baseUrl}/u/${credentials.username}`)); + console.log(); if (result.warnings && result.warnings.length > 0) { - logIfNotQuiet(pc.yellow(" Warnings:")); + console.log(pc.yellow(" Warnings:")); for (const warning of result.warnings) { - logIfNotQuiet(pc.gray(` - ${warning}`)); + console.log(pc.gray(` - ${warning}`)); } - logIfNotQuiet(); + console.log(); } } catch (error) { console.error(pc.red(`\n Error: Failed to connect to server.`)); diff --git a/packages/cli/src/sync.ts b/packages/cli/src/sync.ts index d889c7e24..cf2f9670f 100644 --- a/packages/cli/src/sync.ts +++ b/packages/cli/src/sync.ts @@ -120,7 +120,7 @@ function buildCronEntry(): string { const { path } = getTokscalePath(); const tokscalePath = escapePathForShell(path); const logPath = escapePathForShell(LOG_FILE); - return `0 * * * * '${tokscalePath}' submit --quiet >> '${logPath}' 2>&1 ${CRON_MARKER}`; + return `0 * * * * '${tokscalePath}' submit >> '${logPath}' 2>&1 ${CRON_MARKER}`; } function setupCrontab(): { success: boolean; error?: string } { @@ -189,7 +189,7 @@ function setupWindowsTask(): { success: boolean; error?: string } { // Task doesn't exist yet } - const scriptContent = `@echo off\r\n"${tokscalePath}" submit --quiet >> "${LOG_FILE}" 2>&1\r\n`; + const scriptContent = `@echo off\r\n"${tokscalePath}" submit >> "${LOG_FILE}" 2>&1\r\n`; fs.writeFileSync(WINDOWS_SCRIPT_FILE, scriptContent); // Use cmd.exe /c wrapper for robustness with Task Scheduler @@ -296,7 +296,7 @@ export async function setupSync(_options: SyncSetupOptions = {}): Promise console.log(pc.green("\n Success! Automatic sync is now configured.")); console.log(); console.log(pc.white(" Schedule: Hourly (at minute 0)")); - console.log(pc.white(" Command: tokscale submit --quiet")); + console.log(pc.white(" Command: tokscale submit")); if (isWindows()) { console.log(pc.gray(` Script: ${WINDOWS_SCRIPT_FILE}`)); }