diff --git a/.gitignore b/.gitignore index 6fef9592..984ed5ba 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ next-env.d.ts .claude/settings.local.json .claude/worktrees/ .claude/review-loop.log +.claude/scheduled_tasks.lock # supabase cli runtime cache (machine-local) supabase/.temp/ diff --git a/.mission/plan.md b/.mission/plan.md new file mode 100644 index 00000000..4d219241 --- /dev/null +++ b/.mission/plan.md @@ -0,0 +1,129 @@ +# Mission: improve Straude activation + +**Started:** 2026-05-04 +**Goal:** Drive CLI activation (% of users who push at least once) from ~47% toward ~75% by removing the four error classes surfaced in PostHog, plus add tracking, plus a scheduled 7-day check-in. + +## Context + +PostHog analysis (last 7 days, 103 CLI users): + +- 53% of users (55) install Straude and never push once +- `$exception` is the #1 event with 1,723 occurrences across 73 users +- Three errors explain almost all failures: + - `ccusage is not installed or not on PATH` — 1,627 events / 14 users + - `write EPIPE` — 48 events / 48 users (every active user once) + - `Session expired or invalid` — 33 events / 9 users + - `Date X is outside the 30-day backfill window` — 13 events +- Among activated users, retention is strong: 69% pushed 2+ days, 17% pushed 6 of 7 days + +## Out of scope + +- Re-engaging the 55 stuck users (deferred) +- Any non-CLI / non-web code +- New features unrelated to activation + +## Verification commands + +- `bun run typecheck` (monorepo) +- `bun run test` (monorepo) +- `bun run build` (monorepo) +- `bun run --cwd packages/cli test` (CLI vitest) + +## Milestones + +### 1. CLI resilience + activation tracking — M (~25–35 min) + +**Goal:** Stop swallowing EPIPE on every active user; add events needed to measure activation. + +**Changes:** +- `packages/cli/src/index.ts` `main()`: register `process.stdout.on('error', …)` and stderr handlers — exit 0 on EPIPE. +- `packages/cli/src/lib/auth.ts`: add `installed_at` to `StraudeConfig`. If config doesn't exist yet at first run, write a separate marker (`~/.straude/.first-run`) so we don't lose the signal pre-login. +- `packages/cli/src/index.ts`: capture `cli_first_run` once per machine (gated by marker). Capture `cli_authenticated` on every invocation where a valid config loads. +- Tests in `packages/cli/__tests__/` for first-run gating and EPIPE handler. + +**Acceptance:** +- `bun run --cwd packages/cli test` passes. +- Manual: `node packages/cli/dist/index.js --help | head -1` exits 0. +- Manual: deleting `~/.straude/` and running CLI captures exactly one `cli_first_run`; subsequent runs capture zero additional first-run events. + +### 2. ccusage detect & install on first run — M (~30–45 min) + +**Goal:** Replace the hard throw at `ccusage.ts:49-51` with a one-time interactive install prompt. + +**Changes:** +- `packages/cli/src/lib/ccusage.ts`: when `isOnPath('ccusage')` is false, prompt user (TTY-only). On consent, run `npm install -g ccusage` (prefer `bun add -g` if bun present). Capture `ccusage_install_attempted` and `ccusage_install_succeeded`/`_failed`. +- Non-TTY (auto-push, CI): keep current throw with improved message. +- Tests for TTY/non-TTY branches, install success/failure, declined prompt. + +**Acceptance:** +- `bun run --cwd packages/cli test` passes. +- Manual on a system without ccusage: `npx straude@latest` prompts; accepting installs ccusage; push proceeds. +- Manual non-TTY: `npx straude@latest push < /dev/null` throws cleanly with the install command. + +### 3. Filter out-of-window backfill dates client-side — S (~10–15 min) + +**Goal:** Stop submitting dates the server will reject. + +**Changes:** +- `packages/cli/src/commands/push.ts`: after merging entries (~line 342), filter out entries older than `MAX_BACKFILL_DAYS`. If any dropped, log a single warning listing the dates. Do NOT throw. + +**Acceptance:** +- New unit test: 60-day input → only last 30 days reach submit body. +- Existing push tests still pass. + +### 4. Silent re-auth on 401 + sliding token refresh — L (~60–90 min) + +**Goal:** Eliminate "Session expired" failures. + +**Changes:** +- **Server** (`apps/web/app/api/usage/submit/route.ts` and `/api/cli/dashboard`): if JWT is older than 7 days, mint a fresh one via `createCliToken` and set `X-Straude-Refreshed-Token` response header. +- **Server helper** (`apps/web/lib/api/cli-auth.ts`): export `tokenAgeDays(token)`. +- **Client** (`packages/cli/src/lib/api.ts`): + - On every successful response, read `X-Straude-Refreshed-Token`; persist via `saveConfig` and update in-memory config. + - On 401: run `loginCommand(config.api_url)`, reload config, retry the request once. If retry also 401s, throw existing message. + - Skip auto-relogin when stdin is non-TTY OR `process.env.STRAUDE_AUTO === '1'`. +- Tests: refresh-header path, 401-retry path, 401-then-401-throws path. + +**Acceptance:** +- `bun run --cwd packages/cli test` and any web tests pass. +- `bun run typecheck` passes. +- Manual: token with `iat` >7 days old gets refreshed in `~/.straude/config.json` after a push. +- Manual: invalidated token triggers browser login automatically and push completes. + +### 5. Schedule weekly activation check-in via PostHog — S (~15–20 min) + +**Goal:** Auto-deliver an activation snapshot 7 days post-merge. + +**Changes (PostHog only, via MCP):** +- Create a saved insight: activation funnel `cli_first_run` → `usage_pushed` (filtered to `cli_version >= 0.1.24`). +- Create a weekly subscription delivered to `oscar.hong7@gmail.com`. +- Test delivery via `subscriptions-test-delivery-create`. +- Note the insight URL + baseline values in the PR description. + +**Acceptance:** +- Insight URL included in PR description. +- Test delivery succeeds. + +### 6. Integration, verification & PR — S (~15–20 min) + +**Goal:** Full sweep + open the PR. + +**Changes:** +- Run from repo root: `bun run typecheck && bun run test && bun run build`. +- Smoke-test: full login → push → simulate 401 → confirm auto-reauth. +- Update `docs/CHANGELOG.md` under `## Unreleased` (Added/Changed/Fixed). +- Update `docs/DECISIONS.md`: (a) ccusage auto-install reverses prior security stance — note trade-off; (b) sliding token refresh design. +- Bump `packages/cli/package.json` version → `0.1.24`. +- Open PR titled `fix(cli): unblock activation — ccusage install, EPIPE, silent reauth, backfill filter`. + +**Acceptance:** +- All three commands above exit 0. +- PR open with green CI. +- PR description includes activation baseline + scheduled-insight link + before/after summary per error class. + +## Risk register + +- **R1**: `npm install -g ccusage` requires sudo on some setups. → On EACCES, fall through to manual instruction. Don't escalate. +- **R2**: Server-side token refresh changes auth contract. → Header-based refresh is purely additive; older clients ignore it. +- **R3**: Auto-relogin opens browser unexpectedly during `--auto` background push. → Skip when stdin is non-TTY OR `STRAUDE_AUTO=1`. +- **R4**: PostHog scheduled subscription requires email integration. → Confirm via MCP; if unavailable, fall back to a notebook + manual reminder. diff --git a/.mission/progress.md b/.mission/progress.md new file mode 100644 index 00000000..3b99db6f --- /dev/null +++ b/.mission/progress.md @@ -0,0 +1,23 @@ +# Mission Progress + +**Mission:** improve Straude activation +**Started:** 2026-05-04 +**Status:** Complete. PR https://github.com/ohong/straude/pull/114 opened 2026-05-04. + +## Follow-ups + +- 7-day check-in scheduled (one-shot cron in this Claude session, plus the manual notebook): query insight `DV22QC1d` on 2026-05-11 and confirm activation rate moved from the 47% baseline. +- Re-engage the 55 install-but-never-pushed users once the PR ships and the new CLI propagates via npm. +- Consider relaxing or quarantining the flaky `authenticated-100ms.test.tsx > messages optimistic send` perf budget — flaked at 1018ms / 1068ms under concurrent monorepo load. + +## Milestones + +- [x] Milestone 1: CLI resilience + activation tracking +- [x] Milestone 2: ccusage detect & install on first run +- [x] Milestone 3: Filter out-of-window backfill dates client-side +- [x] Milestone 4: Silent re-auth on 401 + sliding token refresh +- [x] Milestone 5: Schedule weekly activation check-in via PostHog + - Insight: https://us.posthog.com/project/374497/insights/DV22QC1d + - Notebook: https://us.posthog.com/project/374497/notebooks/QQ7eCe7G + - PostHog Subscriptions are paid tier — using a notebook + manual reminder as the no-cost equivalent. +- [x] Milestone 6: Integration, verification & PR diff --git a/apps/web/__tests__/api/usage-submit.test.ts b/apps/web/__tests__/api/usage-submit.test.ts index e2814f86..b0ef0652 100644 --- a/apps/web/__tests__/api/usage-submit.test.ts +++ b/apps/web/__tests__/api/usage-submit.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/supabase/server", () => ({ vi.mock("@/lib/api/cli-auth", () => ({ verifyCliToken: vi.fn(), + verifyCliTokenWithRefresh: vi.fn(), })); vi.mock("@/lib/supabase/service", () => ({ @@ -18,7 +19,7 @@ vi.mock("@supabase/supabase-js", () => ({ import { POST, aggregateDeviceRows } from "@/app/api/usage/submit/route"; import { createClient } from "@/lib/supabase/server"; -import { verifyCliToken } from "@/lib/api/cli-auth"; +import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; import { getServiceClient } from "@/lib/supabase/service"; function makeEntry(dateStr: string, overrides: Record = {}) { @@ -124,6 +125,12 @@ beforeEach(() => { process.env.SUPABASE_SECRET_KEY = "secret"; process.env.NEXT_PUBLIC_APP_URL = "https://straude.com"; (verifyCliToken as any).mockReturnValue(null); + // Auto-derive verifyCliTokenWithRefresh from verifyCliToken so existing + // tests can keep setting `verifyCliToken.mockReturnValue("cli-user-id")`. + (verifyCliTokenWithRefresh as any).mockImplementation((header: string | null) => { + const userId = (verifyCliToken as any)(header); + return userId ? { userId, username: null, refreshedToken: null } : null; + }); }); describe("POST /api/usage/submit", () => { diff --git a/apps/web/__tests__/flows/cli-push-flow.test.ts b/apps/web/__tests__/flows/cli-push-flow.test.ts index a46eb1ea..e6ec30fa 100644 --- a/apps/web/__tests__/flows/cli-push-flow.test.ts +++ b/apps/web/__tests__/flows/cli-push-flow.test.ts @@ -15,6 +15,7 @@ vi.mock("@/lib/supabase/server", () => ({ vi.mock("@/lib/api/cli-auth", () => ({ createCliToken: vi.fn(() => "mock-cli-jwt-token"), verifyCliToken: vi.fn(), + verifyCliTokenWithRefresh: vi.fn(), })); const mockServiceClient = { @@ -26,7 +27,21 @@ vi.mock("@/lib/supabase/service", () => ({ getServiceClient: vi.fn(() => mockServiceClient), })); -import { verifyCliToken } from "@/lib/api/cli-auth"; +import { verifyCliToken, verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; + +/** + * Auto-derive verifyCliTokenWithRefresh from verifyCliToken so existing + * tests that set verifyCliToken's return value continue to work. Must be + * called after vi.clearAllMocks() in each beforeEach. + */ +function autoDeriveCliAuthMocks() { + (verifyCliTokenWithRefresh as ReturnType).mockImplementation( + (header: string | null) => { + const userId = (verifyCliToken as ReturnType)(header); + return userId ? { userId, username: null, refreshedToken: null } : null; + }, + ); +} // --------------------------------------------------------------------------- // Helpers @@ -65,6 +80,7 @@ describe("Flow: CLI Push", () => { beforeEach(() => { vi.useFakeTimers({ now: new Date('2026-03-13T12:00:00Z'), toFake: ['Date'] }); vi.clearAllMocks(); + autoDeriveCliAuthMocks(); mockServiceClient.rpc.mockResolvedValue({ data: null, error: null }); vi.stubEnv("NEXT_PUBLIC_APP_URL", "https://straude.com"); vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://test.supabase.co"); diff --git a/apps/web/__tests__/flows/web-import-flow.test.ts b/apps/web/__tests__/flows/web-import-flow.test.ts index 7bb00cdb..8b9c2715 100644 --- a/apps/web/__tests__/flows/web-import-flow.test.ts +++ b/apps/web/__tests__/flows/web-import-flow.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/supabase/server", () => ({ vi.mock("@/lib/api/cli-auth", () => ({ verifyCliToken: vi.fn(() => null), // web flow — no CLI token + verifyCliTokenWithRefresh: vi.fn(() => null), })); vi.mock("@/lib/supabase/service", () => ({ diff --git a/apps/web/__tests__/unit/cli-auth.test.ts b/apps/web/__tests__/unit/cli-auth.test.ts index 48508bfa..81936553 100644 --- a/apps/web/__tests__/unit/cli-auth.test.ts +++ b/apps/web/__tests__/unit/cli-auth.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { createCliToken, verifyCliToken } from "@/lib/api/cli-auth"; +import { + createCliToken, + verifyCliToken, + verifyCliTokenWithRefresh, + TOKEN_REFRESH_AFTER_DAYS, +} from "@/lib/api/cli-auth"; const TEST_SECRET = "test-secret-key-for-jwt"; @@ -140,4 +145,42 @@ describe("cli-auth", () => { expect(verifyCliToken(`Bearer ${token}`)).toBeNull(); }); }); + + describe("verifyCliTokenWithRefresh", () => { + it("returns userId, username, and no refresh on a fresh token", () => { + const token = createCliToken("user-1", "alice"); + const result = verifyCliTokenWithRefresh(`Bearer ${token}`); + expect(result).not.toBeNull(); + expect(result!.userId).toBe("user-1"); + expect(result!.username).toBe("alice"); + expect(result!.refreshedToken).toBeNull(); + }); + + it("emits a refreshed token once iat is older than the threshold", () => { + // Mint a token "now" then jump the clock past the refresh threshold. + const token = createCliToken("user-1", "alice"); + vi.setSystemTime( + new Date(Date.now() + (TOKEN_REFRESH_AFTER_DAYS + 1) * 24 * 60 * 60 * 1000), + ); + const result = verifyCliTokenWithRefresh(`Bearer ${token}`); + expect(result).not.toBeNull(); + expect(result!.refreshedToken).toBeTypeOf("string"); + expect(result!.refreshedToken!.split(".")).toHaveLength(3); + expect(result!.refreshedToken).not.toBe(token); + }); + + it("does not refresh if the token is just barely under the threshold", () => { + const token = createCliToken("user-1", "alice"); + vi.setSystemTime( + new Date(Date.now() + (TOKEN_REFRESH_AFTER_DAYS - 1) * 24 * 60 * 60 * 1000), + ); + const result = verifyCliTokenWithRefresh(`Bearer ${token}`); + expect(result!.refreshedToken).toBeNull(); + }); + + it("returns null for invalid tokens", () => { + expect(verifyCliTokenWithRefresh("Bearer not.a.token")).toBeNull(); + expect(verifyCliTokenWithRefresh(null)).toBeNull(); + }); + }); }); diff --git a/apps/web/app/api/cli/dashboard/route.ts b/apps/web/app/api/cli/dashboard/route.ts index 37919ff2..11290b2a 100644 --- a/apps/web/app/api/cli/dashboard/route.ts +++ b/apps/web/app/api/cli/dashboard/route.ts @@ -1,14 +1,15 @@ import { NextResponse } from "next/server"; -import { verifyCliToken } from "@/lib/api/cli-auth"; +import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; import { getServiceClient } from "@/lib/supabase/service"; export async function GET(request: Request) { // Auth: verify CLI JWT from Authorization header const authHeader = request.headers.get("authorization"); - const userId = verifyCliToken(authHeader); - if (!userId) { + const auth = verifyCliTokenWithRefresh(authHeader); + if (!auth) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const userId = auth.userId; const db = getServiceClient(); @@ -179,15 +180,23 @@ export async function GET(request: Request) { leaderboard = { rank, total_users: totalUsers, above, below }; } - return NextResponse.json({ - username: profile.username, - level: levelRow ? Number(levelRow.level) : null, - streak, - daily, - week_cost, - prev_week_cost, - leaderboard, - model_breakdown, - total_output_tokens, - }); + const headers: Record = {}; + if (auth.refreshedToken) { + headers["X-Straude-Refreshed-Token"] = auth.refreshedToken; + } + + return NextResponse.json( + { + username: profile.username, + level: levelRow ? Number(levelRow.level) : null, + streak, + daily, + week_cost, + prev_week_cost, + leaderboard, + model_breakdown, + total_output_tokens, + }, + { headers }, + ); } diff --git a/apps/web/app/api/usage/submit/route.ts b/apps/web/app/api/usage/submit/route.ts index 802d2de5..b79075d3 100644 --- a/apps/web/app/api/usage/submit/route.ts +++ b/apps/web/app/api/usage/submit/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; -import { verifyCliToken } from "@/lib/api/cli-auth"; +import { verifyCliTokenWithRefresh } from "@/lib/api/cli-auth"; import { getServiceClient } from "@/lib/supabase/service"; import { checkAndAwardAchievements } from "@/lib/achievements"; import { rateLimit } from "@/lib/rate-limit"; @@ -51,13 +51,21 @@ function hasNonCodexModels(models: unknown): boolean { interface AuthContext { userId: string; source: "cli" | "web"; + /** When set, the response should include X-Straude-Refreshed-Token. */ + refreshedToken?: string | null; } async function resolveAuthContext(request: Request): Promise { // Try CLI JWT first const authHeader = request.headers.get("authorization"); - const cliUserId = verifyCliToken(authHeader); - if (cliUserId) return { userId: cliUserId, source: "cli" }; + const cliAuth = verifyCliTokenWithRefresh(authHeader); + if (cliAuth) { + return { + userId: cliAuth.userId, + source: "cli", + refreshedToken: cliAuth.refreshedToken, + }; + } // Fall back to Supabase session (web) try { @@ -482,9 +490,14 @@ export async function POST(request: Request) { }) .catch(() => {}); + const responseHeaders: Record = {}; + if (auth.source === "cli" && auth.refreshedToken) { + responseHeaders["X-Straude-Refreshed-Token"] = auth.refreshedToken; + } + const response: UsageSubmitResponse = { results }; if (errors.length > 0) { - return NextResponse.json({ ...response, errors }, { status: 207 }); + return NextResponse.json({ ...response, errors }, { status: 207, headers: responseHeaders }); } - return NextResponse.json(response); + return NextResponse.json(response, { headers: responseHeaders }); } diff --git a/apps/web/lib/api/cli-auth.ts b/apps/web/lib/api/cli-auth.ts index 027f0401..f35ef6ff 100644 --- a/apps/web/lib/api/cli-auth.ts +++ b/apps/web/lib/api/cli-auth.ts @@ -7,6 +7,20 @@ interface JwtPayload { exp: number; } +/** + * If a verified token is older than this many days, the next CLI request + * gets a fresh one returned in the X-Straude-Refreshed-Token response header. + * This keeps active users from ever hitting the 30-day expiry cliff. + */ +export const TOKEN_REFRESH_AFTER_DAYS = 7; + +export interface CliAuthResult { + userId: string; + username: string | null; + /** Set when the verified token is older than TOKEN_REFRESH_AFTER_DAYS. */ + refreshedToken: string | null; +} + function base64urlEncode(data: string): string { return Buffer.from(data, "utf-8") .toString("base64") @@ -51,11 +65,7 @@ export function createCliToken(userId: string, username: string | null): string return `${header}.${payload}.${signature}`; } -/** - * Verify a CLI JWT from the Authorization header. - * Returns the user_id (sub) if valid, null otherwise. - */ -export function verifyCliToken(authHeader: string | null): string | null { +function decodeAndVerify(authHeader: string | null): JwtPayload | null { if (!authHeader?.startsWith("Bearer ")) return null; const secret = process.env.CLI_JWT_SECRET; @@ -67,7 +77,6 @@ export function verifyCliToken(authHeader: string | null): string | null { const [header, payload, signature] = parts as [string, string, string]; - // Verify signature const expectedSig = sign(header, payload, secret); const sigBuf = Buffer.from(signature, "utf-8"); const expectedBuf = Buffer.from(expectedSig, "utf-8"); @@ -75,9 +84,7 @@ export function verifyCliToken(authHeader: string | null): string | null { return null; } - // Decode and check expiry let decoded: JwtPayload; - // malformed JWT payload — treat as anonymous try { decoded = JSON.parse(base64urlDecode(payload)); } catch { @@ -88,6 +95,41 @@ export function verifyCliToken(authHeader: string | null): string | null { if (!decoded.sub || !decoded.exp || decoded.exp < now) { return null; } + return decoded; +} + +/** + * Verify a CLI JWT from the Authorization header. + * Returns the user_id (sub) if valid, null otherwise. + */ +export function verifyCliToken(authHeader: string | null): string | null { + return decodeAndVerify(authHeader)?.sub ?? null; +} + +/** + * Verify and, if the token is approaching expiry, mint a fresh one. CLI + * routes attach `refreshedToken` to a response header so the CLI can rotate + * its stored credential without the user ever seeing a "session expired" + * error. + */ +export function verifyCliTokenWithRefresh(authHeader: string | null): CliAuthResult | null { + const decoded = decodeAndVerify(authHeader); + if (!decoded) return null; + + const username = decoded.username ?? null; + const tokenAgeSeconds = Math.floor(Date.now() / 1000) - decoded.iat; + const refreshThresholdSeconds = TOKEN_REFRESH_AFTER_DAYS * 24 * 60 * 60; + + let refreshedToken: string | null = null; + if (tokenAgeSeconds >= refreshThresholdSeconds && process.env.CLI_JWT_SECRET) { + try { + refreshedToken = createCliToken(decoded.sub, username); + } catch { + // If we can't mint a refresh, the request still succeeds with the old + // token. The user just won't get the rotation this round. + refreshedToken = null; + } + } - return decoded.sub; + return { userId: decoded.sub, username, refreshedToken }; } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 907c1c93..dae6cc17 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- **CLI activation tracking events: `cli_first_run` and `cli_authenticated`.** PostHog showed 103 users tried Straude in the last 7 days but only 48 (47%) ever pushed once successfully — the missing event was a clean install→activate funnel. The CLI now writes `~/.straude/.first-run` on the first invocation per machine and captures `cli_first_run` (with `platform`, `node_version`, `command`) before any other code runs, so even `npx straude --help` counts as install. Every subsequent invocation that loads a stored config also captures `cli_authenticated`. Saved insight `DV22QC1d` ([URL](https://us.posthog.com/project/374497/insights/DV22QC1d)) tracks the funnel; goal is ≥75% activation on `cli_version` ≥ 0.1.24. +- **CLI prompts to install ccusage on first run.** The "ccusage is not installed or not on PATH" error fired 1,627 times across 14 users — the single biggest activation blocker. The CLI now detects the missing binary, prompts the user (TTY-only), and runs `bun add -g ccusage` (or `npm install -g ccusage` if bun isn't available) with stdio inherited so install progress is visible. Non-TTY contexts (auto-push, CI) keep the original throw with the explicit install command. Telemetry: `ccusage_install_attempted`, `_succeeded`, `_failed`, `_declined`, `_skipped`. Lives in `packages/cli/src/lib/ccusage.ts` (`ensureCcusageInstalled`) and the new `packages/cli/src/lib/prompt.ts` (TTY-aware yes/no helper). Documented in `docs/DECISIONS.md`. +- **CLI sliding-window token refresh + silent re-auth on 401.** "Session expired" errors fired 33 times across 9 users. Two changes eliminate the cliff: (a) when the server (`apps/web/app/api/usage/submit/route.ts`, `/api/cli/dashboard`) sees a CLI JWT older than 7 days, it mints a fresh one via `createCliToken` and returns it in the `X-Straude-Refreshed-Token` response header — `verifyCliTokenWithRefresh` in `apps/web/lib/api/cli-auth.ts` exposes the new `CliAuthResult` shape; the CLI persists the rotated token via `saveConfig` and mutates the in-memory config so subsequent calls in the same flow use it. (b) On 401, `apiRequest` runs a registered `AuthRefreshStrategy` (wired in `index.ts` to call `loginCommand`), reloads config, and retries the request once. Gated on `isInteractive()` so auto-push and CI runs still surface the original "Session expired" message. Older CLI clients ignore the new header — change is purely additive. Constant `TOKEN_REFRESH_AFTER_DAYS = 7`. +- **`.mission/` planning artifacts at repo root.** Mission plan and progress files for the activation-fix work, generated by the `/mission:plan` workflow. Contains the goal, milestone breakdown, acceptance criteria, and risk register; future contributors can use them as a template for non-trivial multi-step work. - **Team affiliation badge.** Users can enter an organization URL on `/settings` ("Team" field, between Website and GitHub Username). On save, the server validates the URL, fetches the favicon from Google's `s2/favicons` endpoint, uploads the PNG bytes to a new public-read `team-favicons` Supabase Storage bucket keyed by `.png`, and writes the resulting public URL to two new columns on `users` (`team_url`, `team_favicon_url`). Subsequent users that enter the same domain reuse the cached favicon — no extra Google fetch, no extra upload. A new `` component (`apps/web/components/app/shared/TeamBadge.tsx`) renders the favicon as a clickable badge inline next to the user's @handle on every surface it appears: profile header (next to display name + LevelBadge), feed cards (next to @handle in `ActivityCard`), leaderboard rows (desktop table + mobile list), and the sidebar current-user chip. Click target opens the team URL in a new tab with `rel="noopener noreferrer"`; alt text is derived from the hostname (e.g. `anthropic.com logo`); a Building2 lucide icon is rendered as a fallback when the favicon URL fails to load. The favicon resolver is server-only with a 5s fetch timeout and gracefully degrades to `team_favicon_url: null` (renders the fallback icon) on Google or Storage failure rather than blocking the save. Two migrations: `20260501120000_add_team_affiliation.sql` adds the columns + Storage bucket, and `20260501130000_team_affiliation_full_redaction.sql` re-states the sanitized `public.users` SELECT list with `team_url`/`team_favicon_url` added and refreshes `get_feed` to surface them via the existing `jsonb_build_object` allow-list (the previous list was bound to the pre-team-affiliation column set). 13 vitest cases cover the resolver (validation, normalization, cache hit/miss, graceful degradation); a Playwright spec at `apps/web/e2e/team-badge.spec.ts` asserts the badge renders correctly on `/u/{user}` and `/leaderboard`. Modeled on X Premium Organizations. - **Recap "Midnight" dark theme.** New background option (id `11`) for `/recap` and the public `/recap/[username]` page — a dark gradient (`#0B0D12 → #1B1F2A → #2A1A12`) with a black overlay and inverted text palette so the card stays legible. Plumbed `dark` through `RecapCardImage` so the downloaded landscape PNG (`/api/recap/image?bg=11`) matches the in-app card. The Open Graph share card still renders the default light background — Next.js's `opengraph-image.tsx` route convention only sees `params`, not `searchParams`. Existing light backgrounds are unchanged. Suggested by @larpa via community prompts. - **`CONTRIBUTING.md` at repo root.** Lightweight contribution guide inspired by Warp's, scaled down for a smaller project: TL;DR up top, mermaid flow diagram (bug → PR; feature → issue → PR), pointers to `docs/SETUP.md` / `docs/LOCAL_DEV.md` for environment setup, branch/commit conventions (`handle/short-description` prefix), and a testing bar that asks for regression tests on bug fixes and unit/e2e coverage on new behavior — without the heavier readiness-label and spec-PR gating Warp uses. Also covers code style (defers to `CLAUDE.md`), agent-assisted contributions, security disclosure via GitHub's private reporting, and the Contributor Covenant. Issue/repo links use the real `github.com/ohong/straude` remote. @@ -21,6 +25,8 @@ ### Changed +- **CLI handles broken pipes gracefully.** Top-level `process.stdout.on('error', …)` and `process.stderr.on('error', …)` handlers in `packages/cli/src/index.ts` exit cleanly on EPIPE instead of throwing — the previous behavior fired a `write EPIPE` exception once for every active CLI user (48 users / 48 events in the last 7 days) when output was piped to a process that closed early (`straude --help | head`). +- **CLI filters out-of-window backfill dates client-side instead of letting the whole batch fail.** `pushCommand` in `packages/cli/src/commands/push.ts` now mirrors the server's 30-day check (new exported `isWithinBackfillWindow` helper) and drops dates outside the window with a single `Note: skipping N date(s) outside the 30-day backfill window` log line. Previously, ccusage occasionally returned an entry on the boundary of the window and the server's `/api/usage/submit` route rejected the entire submit with HTTP 400 ("Date X is outside the 30-day backfill window") — 13 events across multiple users in the last 7 days. - **"Grow Your Crew" referral CTA moved from the right sidebar to the left sidebar.** Per @bern's community-prompt request: the right rail was already crowded enough to scroll on shorter viewports while the left rail had vertical slack between Latest Activities and the bottom-anchored All-Time stats block. The block now sits between Latest Activities and the All-Time footer in `Sidebar.tsx` (and in the suspense skeleton in `app/(app)/layout.tsx`), so it fills that gap and the right rail no longer needs to scroll. `RightSidebar` no longer takes a `username` prop. - **`docs/agentsview-migration-exploration.md` rewritten as a decision-grade brief.** Restructured around the Minto Pyramid Principle for an engineering leader: recommendation up top (greenlight a phased, additive migration; Phase 0 is the `/api/usage/submit` 5xx alert from the 2026-04-28 collector_meta incident retro), three load-bearing reasons (north-star metric leak from non-Anthropic/non-OpenAI models that LiteLLM-via-agentsview fixes for free; ~500 lines of `codex-native.ts` retired; multi-agent strategic option for Cursor/Copilot/Gemini/Warp/etc. created at copy-update cost), explicit cost-benefit table, kill-switch (`STRAUDE_COLLECTOR=legacy` env-var flip), and a "what would change my recommendation" decision aid. Updated maintainer signal (wesm/agentsview at 869⭐, v0.26.0 released 2026-04-29 — stronger long-term bet than the prior doc's "single-maintainer with unknown release cadence"). Updated install paths (pip/uvx removed in v0.26; only `curl \| bash` and desktop now — reinforces the "additive, ccusage stays as fallback" approach). Original technical scope preserved in appendices. - **ProductHuntBadge now uses the official Product Hunt embed.** Replaced the custom medal/SVG badge with PH's official ``/`` embed (`featured.svg?post_id=1114059&theme=dark`) so the badge stays in sync with our launch ranking automatically. Sized at 75% of the canonical 250×54 (188×41) to fit the hero, dark-themed to match the landing page palette. Allow-listed `https://api.producthunt.com` in the `next.config.ts` CSP `img-src` directive — without that, the badge SVG was blocked by CSP and rendered as a broken-image icon. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0492526b..6c472d4d 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,5 +1,64 @@ # Architecture & Design Decisions +## CLI auto-installs `ccusage` on first interactive run (2026-05-04) + +**Decision:** When `straude push` runs and `ccusage` is not on PATH, prompt the user (TTY only) and run `bun add -g ccusage` (or `npm install -g ccusage`) on consent. Non-TTY contexts continue to throw with the explicit install command. + +**Why:** PostHog showed the "ccusage is not installed or not on PATH" error fired 1,627 times across 14 users in the 7 days preceding the change — 14 of the 103 total CLI users (13.6%) hit this error and many never recovered. The previous comment in `packages/cli/src/lib/ccusage.ts` explicitly said the CLI "do[es] not auto-install/execute package-runner fallbacks" "for security reasons." That stance was the right starting position but cost too much in activation: a user who runs `npx straude` and gets a hard error has no easy recovery path. + +**Alternatives considered:** + +1. **Bundle ccusage as a hard `dependencies` entry in `packages/cli/package.json`.** Tightest UX but inherits ccusage's release cadence and license posture. A breaking change in ccusage would force a Straude release. Rejected. +2. **Inline a usage parser so ccusage isn't needed at all.** ~500–1000 lines of new code maintaining session-format compatibility with Claude Code's evolving on-disk layout. The Codex collector (`codex-native.ts`) is already inline and is non-trivial to keep current; doubling that surface to remove a single dependency was a bad trade. Rejected. +3. **Detect and prompt with copy-pasteable instructions, but never run install ourselves.** What we had before, just with better wording. Doesn't fix the underlying activation drop — users still face a hard stop. Rejected. + +**Why this is safe enough:** + +- Consent is required: the prompt defaults to Yes but the user has to press Enter or `y`. Declining produces a clean error with the same install command. +- Non-TTY contexts (auto-push via launchd/cron, Claude Code hooks, CI) preserve the original throw, so non-interactive systems can never spawn an unattended `npm install -g`. +- The package name is hardcoded (`ccusage`) — there's no string interpolation that could be hijacked. +- We prefer `bun add -g` only when `bun` is already on PATH (Straude is bun-first). Users on a stock npm setup get `npm install -g`. +- Telemetry events (`ccusage_install_attempted`, `_succeeded`, `_failed`, `_declined`) make adoption and failure rates measurable, so we can roll back the trade-off if it turns out to surprise users. + +## CLI session credentials use a sliding 7-day refresh, not a refresh-token endpoint (2026-05-04) + +**Decision:** When the CLI sends a JWT older than 7 days (`TOKEN_REFRESH_AFTER_DAYS` in `apps/web/lib/api/cli-auth.ts`) to a CLI-authenticated endpoint, the server mints a fresh 30-day JWT via `createCliToken` and returns it in the `X-Straude-Refreshed-Token` response header. The CLI persists the new token (`saveConfig`) and uses it for the next request. On 401 — the rare case where rotation can't recover (token deleted, secret rotated, JWT expired before any request) — `apiRequest` runs a registered `AuthRefreshStrategy` that calls `loginCommand`, then retries the original request once. Auto-relogin is gated on `isInteractive()`, so auto-push and CI runs still surface the original "Session expired" message. + +**Why:** "Session expired or invalid" fired 33 times across 9 users in the 7 days preceding the change. Tokens have a 30-day TTL — none of the 9 users hit organic expiry that fast. The errors were a mix of secret rotation in deploys, users with stored configs from a different `--api-url` environment, and edge cases. Either way, the right experience is "the session refreshes itself when you keep using the tool, and re-runs login transparently when refresh isn't possible." + +**Alternatives considered:** + +1. **Add a dedicated `/api/auth/cli/refresh` endpoint and have the CLI call it proactively.** More standard OAuth shape, but every CLI invocation would need an extra round-trip just to check freshness, and the implementation would need both client and server changes for the same outcome we get from a header. Rejected. +2. **Issue refresh tokens alongside access tokens.** Would let us shorten the access-token TTL, but the JWTs aren't an attack surface that benefits much from short TTLs in the CLI context (token theft requires read access to a 0o600 file in the user's home), and we'd be carrying two tokens through `~/.straude/config.json` for marginal value. Rejected. +3. **Lengthen the JWT TTL to 90+ days.** Pushes the cliff out but doesn't remove it; secret rotation and user-environment edge cases still hit. Rejected. + +**Why header-based rotation is safe:** + +- Purely additive: older CLI versions ignore unknown response headers, so the new server keeps working with `straude` 0.1.23 and earlier. +- The token is rotated only on a verified, non-expired JWT — same auth check the request already passes. +- The CLI saves over the old token at the same path with the same `0o600` mode (`saveConfig`). +- No third party sees the header — the server returns it on first-party endpoints that the CLI already calls. + +**Why auto-relogin is opt-in to interactivity:** an unattended `straude push` (auto-push, CI, hook) running in 30 days should fail loudly so the user notices and runs login manually, not silently spawn a browser at 9pm. The `isInteractive()` gate matches the same TTY-detection used for the ccusage install prompt above. + +## Activation tracking via `cli_first_run` marker file (2026-05-04) + +**Decision:** Detect first-ever CLI invocation per machine by writing `~/.straude/.first-run` (`0o600`) on the first run and capturing a `cli_first_run` PostHog event then. The event fires before the help/version short-circuits so even `npx straude --help` counts as "user installed and tried Straude" — the canonical activation signal. + +**Why a separate marker rather than piggybacking on `~/.straude/machine_id` or the config file:** + +- `machine_id` is created lazily by `getMachineId()` the first time any code asks for a distinct ID. Tying first-run detection to its absence would couple two concerns, and a future refactor (e.g. eager-creating `machine_id` at install time) would silently break the funnel. +- The config file (`~/.straude/config.json`) only exists after a successful login, but most "users who never push" never even get to login — they hit the ccusage error first. We need a marker that exists from the very first invocation, before any auth. +- A dedicated `.first-run` marker is explicit, cheap, and unambiguous to read. + +**On the `cli_authenticated` event:** fires on every invocation that loads a stored config for a real command (not `--help`/`--version`). Together with `cli_first_run` and the existing `usage_pushed` it gives a clean three-step funnel: install → has-stored-creds → pushed. Useful for diagnosing whether activation drops happen between install and login (ccusage / EPIPE / declined-prompt) vs. between login and push (auth / network / bug). + +## CLI activation check-in is a saved insight + manual reminder, not a PostHog Subscription (2026-05-04) + +**Decision:** Track the activation funnel via saved insight [`DV22QC1d`](https://us.posthog.com/project/374497/insights/DV22QC1d) and a companion notebook ([`QQ7eCe7G`](https://us.posthog.com/project/374497/notebooks/QQ7eCe7G)). For now, check it manually on Mondays. + +**Why not Subscriptions:** PostHog Subscriptions (scheduled email/Slack delivery of insights) are a paid-tier feature. The Side Projects org is on the free tier, so `subscriptions-create` returns 402 Payment Required. The closest no-cost equivalents are (a) bookmarking the insight URL and checking it manually, or (b) running the query weekly via the PostHog MCP from a Claude scheduled agent. We picked (a) for now because it requires no additional moving parts; if the manual check ever slips, we can either upgrade the tier (sustainable long-term) or stand up a `/schedule`-based remote agent that runs `mcp__posthog__insight-query` on a cron. + ## `calculate_user_streak` runs as SECURITY DEFINER (2026-04-30) **Decision:** Promote `public.calculate_user_streak(uuid, integer)` to `SECURITY DEFINER` with a fixed `search_path = public` and grant `EXECUTE` to both `authenticated` and `anon`. diff --git a/packages/cli/__tests__/api.test.ts b/packages/cli/__tests__/api.test.ts index bd4f6337..72f4f7fb 100644 --- a/packages/cli/__tests__/api.test.ts +++ b/packages/cli/__tests__/api.test.ts @@ -1,12 +1,32 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { apiRequest, apiRequestNoAuth } from "../src/lib/api.js"; +import { apiRequest, apiRequestNoAuth, setAuthRefreshStrategy, REFRESHED_TOKEN_HEADER } from "../src/lib/api.js"; import type { StraudeConfig } from "../src/lib/auth.js"; +vi.mock("../src/lib/auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, saveConfig: vi.fn() }; +}); + +vi.mock("../src/lib/prompt.js", () => ({ + isInteractive: vi.fn(() => false), + promptYesNo: vi.fn(), +})); + +import { saveConfig } from "../src/lib/auth.js"; +import { isInteractive } from "../src/lib/prompt.js"; + const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); +const mockSaveConfig = vi.mocked(saveConfig); +const mockIsInteractive = vi.mocked(isInteractive); + beforeEach(() => { mockFetch.mockReset(); + mockSaveConfig.mockReset(); + mockIsInteractive.mockReset(); + mockIsInteractive.mockReturnValue(false); + setAuthRefreshStrategy(null); }); const config: StraudeConfig = { @@ -89,6 +109,134 @@ describe("apiRequest", () => { }); }); +describe("apiRequest — sliding token refresh", () => { + it("persists a refreshed token from response header", async () => { + const mutableConfig: StraudeConfig = { ...config }; + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + headers: { get: (name: string) => name === REFRESHED_TOKEN_HEADER ? "new-token-xyz" : null }, + }); + await apiRequest(mutableConfig, "/api/test"); + expect(mutableConfig.token).toBe("new-token-xyz"); + expect(mockSaveConfig).toHaveBeenCalledWith( + expect.objectContaining({ token: "new-token-xyz" }), + ); + }); + + it("does not save when no refresh header is present", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + headers: { get: () => null }, + }); + await apiRequest(config, "/api/test"); + expect(mockSaveConfig).not.toHaveBeenCalled(); + }); + + it("swallows read-only-fs saveConfig errors so the request still resolves", async () => { + mockSaveConfig.mockImplementation(() => { + const err = new Error("read-only filesystem") as NodeJS.ErrnoException; + err.code = "EROFS"; + throw err; + }); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true }), + headers: { get: () => "new-token" }, + }); + await expect(apiRequest(config, "/api/test")).resolves.toEqual({ ok: true }); + }); + + it("propagates unexpected saveConfig errors instead of swallowing them", async () => { + mockSaveConfig.mockImplementation(() => { + const err = new Error("disk full") as NodeJS.ErrnoException; + err.code = "ENOSPC"; + throw err; + }); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true }), + headers: { get: () => "new-token" }, + }); + await expect(apiRequest(config, "/api/test")).rejects.toThrow(/disk full/); + }); +}); + +describe("apiRequest — silent re-auth on 401", () => { + it("retries once after running the refresh strategy when interactive", async () => { + mockIsInteractive.mockReturnValue(true); + const refreshStrategy = vi.fn(async () => ({ + ...config, + token: "fresh-token", + })); + setAuthRefreshStrategy(refreshStrategy); + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: "Unauthorized" }), + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: "ok" }), + headers: { get: () => null }, + }); + + const mutable: StraudeConfig = { ...config }; + const result = await apiRequest<{ data: string }>(mutable, "/api/test"); + expect(result.data).toBe("ok"); + expect(refreshStrategy).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mutable.token).toBe("fresh-token"); + // Second call uses the new token. + const secondHeaders = (mockFetch.mock.calls[1]![1] as { headers: Record }).headers; + expect(secondHeaders.Authorization).toBe("Bearer fresh-token"); + }); + + it("throws the original error when refresh strategy returns null", async () => { + mockIsInteractive.mockReturnValue(true); + setAuthRefreshStrategy(async () => null); + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: "Unauthorized" }), + }); + await expect(apiRequest(config, "/api/test")).rejects.toThrow( + "Session expired or invalid", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("does not retry in non-interactive contexts (auto-push)", async () => { + mockIsInteractive.mockReturnValue(false); + const refreshStrategy = vi.fn(); + setAuthRefreshStrategy(refreshStrategy); + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: "Unauthorized" }), + }); + await expect(apiRequest(config, "/api/test")).rejects.toThrow( + "Session expired or invalid", + ); + expect(refreshStrategy).not.toHaveBeenCalled(); + }); + + it("does not retry when no strategy is registered", async () => { + mockIsInteractive.mockReturnValue(true); + setAuthRefreshStrategy(null); + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({ error: "Unauthorized" }), + }); + await expect(apiRequest(config, "/api/test")).rejects.toThrow( + "Session expired or invalid", + ); + }); +}); + describe("apiRequestNoAuth", () => { it("does not include Authorization header", async () => { mockFetch.mockResolvedValue({ diff --git a/packages/cli/__tests__/ccusage-install.test.ts b/packages/cli/__tests__/ccusage-install.test.ts new file mode 100644 index 00000000..89cc6670 --- /dev/null +++ b/packages/cli/__tests__/ccusage-install.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ensureCcusageInstalled, _resetCcusageResolver } from "../src/lib/ccusage.js"; + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), + execFile: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(() => false) }; +}); + +vi.mock("../src/lib/prompt.js", () => ({ + isInteractive: vi.fn(), + promptYesNo: vi.fn(), +})); + +vi.mock("../src/lib/posthog.js", () => ({ + posthog: { + capture: vi.fn(), + _shutdown: vi.fn(() => Promise.resolve()), + }, +})); + +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { isInteractive, promptYesNo } from "../src/lib/prompt.js"; + +const mockExecFileSync = vi.mocked(execFileSync); +const mockExistsSync = vi.mocked(existsSync); +const mockIsInteractive = vi.mocked(isInteractive); +const mockPromptYesNo = vi.mocked(promptYesNo); + +beforeEach(() => { + vi.clearAllMocks(); + _resetCcusageResolver(); +}); + +describe("ensureCcusageInstalled", () => { + it("returns immediately when ccusage is on PATH", async () => { + mockExistsSync.mockReturnValue(true); + await expect(ensureCcusageInstalled()).resolves.toBeUndefined(); + expect(mockIsInteractive).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it("throws the manual-install error in non-TTY contexts", async () => { + mockExistsSync.mockReturnValue(false); + mockIsInteractive.mockReturnValue(false); + await expect(ensureCcusageInstalled()).rejects.toThrow(/not installed or not on PATH/); + expect(mockPromptYesNo).not.toHaveBeenCalled(); + }); + + it("throws when the user declines the prompt", async () => { + mockExistsSync.mockReturnValue(false); + mockIsInteractive.mockReturnValue(true); + mockPromptYesNo.mockResolvedValue(false); + await expect(ensureCcusageInstalled()).rejects.toThrow(/Install it manually/); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it("installs successfully when accepted and binary appears on PATH", async () => { + // Two calls to existsSync per resolver invocation per PATH dir × suffix. + // Simulate: not present at start → install runs → present afterwards. + let installRan = false; + mockExistsSync.mockImplementation((p: unknown) => { + const path = String(p); + // bun-detection probes return false; ccusage probes flip after install. + if (path.includes("ccusage")) return installRan; + return false; // bun not present → falls back to npm + }); + mockIsInteractive.mockReturnValue(true); + mockPromptYesNo.mockResolvedValue(true); + mockExecFileSync.mockImplementation(() => { + installRan = true; + return Buffer.from(""); + }); + + await expect(ensureCcusageInstalled()).resolves.toBeUndefined(); + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const [cmd, args] = mockExecFileSync.mock.calls[0]!; + expect(cmd).toBe("npm"); + expect(args).toEqual(["install", "-g", "ccusage"]); + }); + + it("prefers bun when bun is on PATH", async () => { + let installRan = false; + mockExistsSync.mockImplementation((p: unknown) => { + const path = String(p); + if (path.includes("ccusage")) return installRan; + if (path.includes("bun")) return true; + return false; + }); + mockIsInteractive.mockReturnValue(true); + mockPromptYesNo.mockResolvedValue(true); + mockExecFileSync.mockImplementation(() => { + installRan = true; + return Buffer.from(""); + }); + + await ensureCcusageInstalled(); + const [cmd, args] = mockExecFileSync.mock.calls[0]!; + expect(cmd).toBe("bun"); + expect(args).toEqual(["add", "-g", "ccusage"]); + }); + + it("surfaces install errors with a manual-fallback message", async () => { + mockExistsSync.mockReturnValue(false); + mockIsInteractive.mockReturnValue(true); + mockPromptYesNo.mockResolvedValue(true); + mockExecFileSync.mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }); + await expect(ensureCcusageInstalled()).rejects.toThrow(/Install it manually/); + }); + + it("throws when install command succeeds but binary still missing from PATH", async () => { + mockExistsSync.mockReturnValue(false); // never on PATH + mockIsInteractive.mockReturnValue(true); + mockPromptYesNo.mockResolvedValue(true); + mockExecFileSync.mockReturnValue(Buffer.from("")); + await expect(ensureCcusageInstalled()).rejects.toThrow(/may need to open a new shell/); + }); +}); diff --git a/packages/cli/__tests__/commands/push.test.ts b/packages/cli/__tests__/commands/push.test.ts index 248a506a..62e63690 100755 --- a/packages/cli/__tests__/commands/push.test.ts +++ b/packages/cli/__tests__/commands/push.test.ts @@ -17,6 +17,7 @@ vi.mock("../../src/lib/api.js", () => ({ vi.mock("../../src/lib/ccusage.js", () => ({ runCcusageRawAsync: vi.fn(), parseCcusageOutput: vi.fn(), + ensureCcusageInstalled: vi.fn(() => Promise.resolve()), })); vi.mock("../../src/lib/codex-native.js", () => ({ @@ -202,6 +203,53 @@ describe("pushCommand", () => { expect(process.exit).toHaveBeenCalledWith(1); }); + it("filters out-of-window entries client-side and warns", async () => { + // Faked date: 2026-03-13. 60 days back is 2026-01-12. + const today = todayStr(); + const oldDate = "2026-01-12"; + + mockRunCcusageRawAsync.mockResolvedValue("{}"); + mockParseCcusageOutput.mockReturnValue({ + data: [ + { + date: oldDate, + models: ["claude-sonnet-4-5-20250929"], + inputTokens: 100, outputTokens: 50, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 150, costUSD: 0.01, + }, + { + date: today, + models: ["claude-sonnet-4-5-20250929"], + inputTokens: 1000, outputTokens: 500, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 1500, costUSD: 0.05, + }, + ], + }); + mockApiRequest.mockResolvedValue({ + results: [ + { date: today, usage_id: "u-1", post_id: "p-1", post_url: "https://straude.com/post/p-1" }, + ], + }); + + await pushCommand({ days: 30 }); + + // The submit body must contain only the in-window entry. + const submitCall = mockApiRequest.mock.calls.find( + ([, path]) => path === "/api/usage/submit", + ); + expect(submitCall).toBeDefined(); + const body = JSON.parse((submitCall![2] as { body: string }).body); + expect(body.entries).toHaveLength(1); + expect(body.entries[0].date).toBe(today); + + // And the user got a heads-up about the dropped row. + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining(`skipping 1 date(s) outside the 30-day backfill window: ${oldDate}`), + ); + }); + it("handles API submission failure", async () => { mockRunCcusageRawAsync.mockResolvedValue("{}"); mockParseCcusageOutput.mockReturnValue({ diff --git a/packages/cli/__tests__/first-run.test.ts b/packages/cli/__tests__/first-run.test.ts new file mode 100644 index 00000000..bf09631f --- /dev/null +++ b/packages/cli/__tests__/first-run.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FIRST_RUN_MARKER, isFirstRun, markFirstRun } from "../src/lib/first-run.js"; +import { CONFIG_DIR } from "../src/config.js"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +import { existsSync, writeFileSync, mkdirSync } from "node:fs"; + +const mockExistsSync = vi.mocked(existsSync); +const mockWriteFileSync = vi.mocked(writeFileSync); +const mockMkdirSync = vi.mocked(mkdirSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("isFirstRun", () => { + it("returns true when marker file is missing", () => { + mockExistsSync.mockReturnValue(false); + expect(isFirstRun()).toBe(true); + expect(mockExistsSync).toHaveBeenCalledWith(FIRST_RUN_MARKER); + }); + + it("returns false when marker file exists", () => { + mockExistsSync.mockReturnValue(true); + expect(isFirstRun()).toBe(false); + }); +}); + +describe("markFirstRun", () => { + it("writes the marker and creates the config dir if missing", () => { + mockExistsSync.mockReturnValue(false); + markFirstRun(); + expect(mockMkdirSync).toHaveBeenCalledWith(CONFIG_DIR, { recursive: true, mode: 0o700 }); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const [path, contents, opts] = mockWriteFileSync.mock.calls[0]!; + expect(path).toBe(FIRST_RUN_MARKER); + expect(typeof contents).toBe("string"); + expect(opts).toEqual({ encoding: "utf-8", mode: 0o600 }); + }); + + it("skips mkdir when the config dir already exists", () => { + mockExistsSync.mockReturnValue(true); + markFirstRun(); + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + }); + + it("swallows write errors silently (read-only home)", () => { + mockExistsSync.mockReturnValue(true); + mockWriteFileSync.mockImplementation(() => { + throw new Error("EACCES: permission denied"); + }); + expect(() => markFirstRun()).not.toThrow(); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 6939f065..d48ee646 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "straude", - "version": "0.1.23", + "version": "0.1.24", "description": "CLI for pushing Claude Code usage stats to Straude", "main": "dist/index.js", "bin": { diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts index 66b1403e..ce7f5a4a 100755 --- a/packages/cli/src/commands/push.ts +++ b/packages/cli/src/commands/push.ts @@ -4,7 +4,7 @@ import { loadConfig, updateLastPushDate, saveConfig } from "../lib/auth.js"; import type { StraudeConfig } from "../lib/auth.js"; import { loginCommand } from "./login.js"; import { apiRequest } from "../lib/api.js"; -import { runCcusageRawAsync, parseCcusageOutput } from "../lib/ccusage.js"; +import { runCcusageRawAsync, parseCcusageOutput, ensureCcusageInstalled } from "../lib/ccusage.js"; import type { CcusageDailyEntry, ModelBreakdownEntry } from "../lib/ccusage.js"; import { CODEX_NATIVE_COLLECTOR, @@ -59,6 +59,10 @@ function isMissingClaudeDataError(error: Error): boolean { return error.message.includes("No valid Claude data directories found"); } +function isCcusageNotInstalledError(error: Error): boolean { + return error.message.includes("ccusage is not installed or not on PATH"); +} + function formatDate(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -92,6 +96,19 @@ function daysBetweenStrings(dateStrA: string, dateStrB: string): number { return Math.round((b.getTime() - a.getTime()) / msPerDay); } +/** + * Mirrors the server's backfill-window check (apps/web/app/api/usage/submit/ + * route.ts). Pre-filtering on the client keeps a single edge-case row from + * failing the whole submit with HTTP 400. + */ +export function isWithinBackfillWindow(dateStr: string): boolean { + const now = Date.now(); + const target = new Date(dateStr).getTime(); + if (Number.isNaN(target)) return false; + const diffDays = (now - target) / 86_400_000; + return diffDays >= -1 && diffDays <= MAX_BACKFILL_DAYS; +} + function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${Math.round(n / 1_000)}k`; @@ -250,12 +267,29 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) : `Pushing usage for ${formatDate(sinceDate)} to ${formatDate(untilDate)}...`, ); + // Try to ensure ccusage is installed. In a TTY this prompts the user and + // runs `bun add -g` / `npm install -g`. We catch the throw rather than + // propagating it: Codex-only users (no Claude data) shouldn't be blocked by + // a missing ccusage. If ccusage is genuinely required, the runCcusage call + // below will surface the "not installed" error and we treat it the same as + // missing Claude data. + let ccusageReady = true; + try { + await ensureCcusageInstalled(config); + } catch { + ccusageReady = false; + } + // Run ccusage + codex in parallel — the single biggest perf win const scanSpinner = new Spinner("scan"); scanSpinner.start(); let codexCollectFailed = false; const [claudeResult, codexParsed] = await Promise.all([ - runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err), + ccusageReady + ? runCcusageRawAsync(sinceStr, untilStr, options.timeoutMs).catch((err: Error) => err) + : Promise.resolve( + new Error("ccusage is not installed or not on PATH"), + ), collectCodexUsageAsync(sinceStr, untilStr).catch(() => { codexCollectFailed = true; return { @@ -276,7 +310,10 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) if (claudeResult instanceof Error) { // Codex-only users do not have local Claude data; keep other Claude failures fatal. - if (isMissingClaudeDataError(claudeResult)) { + if ( + isMissingClaudeDataError(claudeResult) || + isCcusageNotInstalledError(claudeResult) + ) { console.log("No Claude Code data found locally; syncing Codex usage only."); } else { console.error(claudeResult.message); @@ -339,7 +376,21 @@ export async function pushCommand(options: PushOptions, apiUrlOverride?: string) const codexEntries = codexParsed.data.filter((entry) => !blockedDates.has(entry.date)); // Merge Claude + Codex entries by date - const entries = mergeEntries(claudeEntries, codexEntries); + const merged = mergeEntries(claudeEntries, codexEntries); + + // Drop entries the server would reject as out-of-window. Pre-filtering keeps + // a single edge-case row from failing the whole batch with HTTP 400. + const droppedDates: string[] = []; + const entries = merged.filter((entry) => { + if (isWithinBackfillWindow(entry.date)) return true; + droppedDates.push(entry.date); + return false; + }); + if (droppedDates.length > 0) { + console.log( + `Note: skipping ${droppedDates.length} date(s) outside the ${MAX_BACKFILL_DAYS}-day backfill window: ${droppedDates.join(", ")}`, + ); + } if (entries.length === 0) { console.log("No usage data found for the specified period."); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b0ab2804..d10271c3 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,9 +5,12 @@ import { pushCommand } from "./commands/push.js"; import { statusCommand } from "./commands/status.js"; import { autoCommand, enableAutoPush, disableAutoPush } from "./commands/auto.js"; import { loadConfig } from "./lib/auth.js"; +import { setAuthRefreshStrategy } from "./lib/api.js"; import { CLI_VERSION } from "./config.js"; import { posthog } from "./lib/posthog.js"; import { setDebug } from "./lib/debug.js"; +import { isFirstRun, markFirstRun } from "./lib/first-run.js"; +import { getDistinctId } from "./lib/machine-id.js"; import { errorMessage, isPushInvocation, @@ -15,6 +18,30 @@ import { reportUsagePushFailed, } from "./lib/telemetry.js"; +// On 401, transparently re-run the browser login flow and let api.ts retry +// the failed request. apiRequest gates this on isInteractive() so auto-push +// and CI runs still surface the original error. +setAuthRefreshStrategy(async (apiUrl) => { + await loginCommand(apiUrl); + return loadConfig(); +}); + +// Exit cleanly when stdout/stderr is piped to a process that closes early +// (e.g., `straude --help | head`). Without this, every active user hits a +// `write EPIPE` exception. +function silenceEpipe(stream: NodeJS.WriteStream): void { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") { + // Preserve any failure status the main flow has already set; otherwise + // a piped command that was already failing would report success here. + process.exit(process.exitCode ?? 0); + } + throw err; + }); +} +silenceEpipe(process.stdout); +silenceEpipe(process.stderr); + const HELP = ` straude v${CLI_VERSION} — Push your Claude Code usage to Straude @@ -114,6 +141,22 @@ async function main(): Promise { setDebug(true); } + // Activation telemetry: fire `cli_first_run` once per machine. Done before + // the help/version short-circuits so `npx straude --help` still counts as + // installation — that's the canonical "user tried straude" signal. + if (isFirstRun()) { + markFirstRun(); + posthog.capture({ + distinctId: getDistinctId(null), + event: "cli_first_run", + properties: { + platform: process.platform, + node_version: process.version, + command: command ?? "push", + }, + }); + } + if (options.version) { console.log(`straude v${CLI_VERSION}`); return; @@ -124,6 +167,20 @@ async function main(): Promise { return; } + // `cli_authenticated` fires whenever a stored config is loaded for a real + // command run (not --help/--version). Pairs with `cli_first_run` and + // `usage_pushed` to power the activation funnel. + const existingConfig = loadConfig(); + if (existingConfig) { + posthog.capture({ + distinctId: getDistinctId(existingConfig), + event: "cli_authenticated", + properties: { + command: command ?? "push", + }, + }); + } + const apiUrl = options.apiUrl as string | undefined; if (!command || command === "push") { @@ -174,6 +231,7 @@ let exitCode = 0; main() .catch((err: unknown) => { exitCode = 1; + process.exitCode = 1; const config = loadConfig(); if (isPushInvocation(activeCommand)) { reportUsagePushFailed(config, err, { diff --git a/packages/cli/src/lib/api.ts b/packages/cli/src/lib/api.ts index f3750dc4..adf939fd 100644 --- a/packages/cli/src/lib/api.ts +++ b/packages/cli/src/lib/api.ts @@ -1,14 +1,38 @@ import type { StraudeConfig } from "./auth.js"; +import { saveConfig } from "./auth.js"; +import { isInteractive } from "./prompt.js"; export interface ApiError { error: string; status: number; } -export async function apiRequest( +export const REFRESHED_TOKEN_HEADER = "x-straude-refreshed-token"; + +/** + * Pluggable strategy for re-authenticating when the server returns 401. + * Registered at startup from index.ts so api.ts doesn't take a hard dependency + * on the login command (which would be circular). + */ +type AuthRefreshStrategy = (apiUrl: string) => Promise; + +let authRefreshStrategy: AuthRefreshStrategy | null = null; + +export function setAuthRefreshStrategy(fn: AuthRefreshStrategy | null): void { + authRefreshStrategy = fn; +} + +class SessionExpiredError extends Error { + constructor() { + super("Session expired or invalid. Run `npx straude@latest login` to re-authenticate."); + this.name = "SessionExpiredError"; + } +} + +async function doRequest( config: StraudeConfig, path: string, - options: RequestInit = {}, + options: RequestInit, ): Promise { const url = `${config.api_url}${path}`; const headers: Record = { @@ -28,7 +52,7 @@ export async function apiRequest( // ignore parse errors } if (res.status === 401) { - throw new Error("Session expired or invalid. Run `npx straude@latest login` to re-authenticate."); + throw new SessionExpiredError(); } if (res.status === 404) { throw new Error(`Endpoint not found (${path}). Try updating the CLI: bunx straude@latest`); @@ -36,9 +60,55 @@ export async function apiRequest( throw new Error(message); } + // Sliding-window token refresh: when the server decides our JWT is getting + // stale it returns a fresh one in a header. Persist it so the next CLI run + // (and the next request in this same run) uses the new token. Mutating the + // caller's config in place avoids threading the new token through every + // call site. + const refreshed = res.headers?.get?.(REFRESHED_TOKEN_HEADER) ?? null; + if (refreshed) { + config.token = refreshed; + try { + saveConfig(config); + } catch (error) { + // Read-only home directory: keep the new token in memory but don't + // crash the request — the user just won't get rotation persisted. + // Surface anything else (disk full, etc.) so it isn't silently swallowed. + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") { + throw error; + } + } + } + return res.json() as Promise; } +export async function apiRequest( + config: StraudeConfig, + path: string, + options: RequestInit = {}, +): Promise { + try { + return await doRequest(config, path, options); + } catch (err) { + if ( + err instanceof SessionExpiredError && + authRefreshStrategy && + isInteractive() + ) { + const fresh = await authRefreshStrategy(config.api_url); + if (!fresh) throw err; + // Update the caller's config in place so any subsequent calls in the + // same flow (e.g. the dashboard fetch after submit) see the new token. + config.token = fresh.token; + config.username = fresh.username; + return await doRequest(config, path, options); + } + throw err; + } +} + export async function apiRequestNoAuth( apiUrl: string, path: string, diff --git a/packages/cli/src/lib/ccusage.ts b/packages/cli/src/lib/ccusage.ts index 5089f4c1..7d81c45c 100755 --- a/packages/cli/src/lib/ccusage.ts +++ b/packages/cli/src/lib/ccusage.ts @@ -10,6 +10,10 @@ import { type TokenNormalizationMode, } from "./token-normalization.js"; import { DEFAULT_SUBPROCESS_TIMEOUT_MS } from "../config.js"; +import { isInteractive, promptYesNo } from "./prompt.js"; +import { posthog } from "./posthog.js"; +import { getDistinctId } from "./machine-id.js"; +import type { StraudeConfig } from "./auth.js"; /** Type-safe representation of the error thrown by execFileSync / execFile. */ interface ExecError extends Error { @@ -56,6 +60,111 @@ export function _resetCcusageResolver(): void { _resolved = undefined; } +/** Whether ccusage is currently resolvable on PATH. */ +export function isCcusageInstalled(): boolean { + return isOnPath("ccusage"); +} + +/** + * Best-effort install of ccusage globally. Prefers `bun add -g` when bun is + * present (faster, and Straude is bun-first), falls back to `npm install -g`. + * stdio is inherited so the user sees install progress. + */ +function installCcusage(): void { + const useBun = isOnPath("bun"); + const cmd = useBun ? "bun" : "npm"; + const args = useBun ? ["add", "-g", "ccusage"] : ["install", "-g", "ccusage"]; + execFileSync(cmd, args, { + stdio: "inherit", + timeout: 5 * 60 * 1000, + // npm/bun on Windows are .cmd shims; execFileSync skips PATHEXT lookup. + shell: process.platform === "win32", + }); +} + +/** + * Make sure ccusage is installed and runnable. If missing and we're attached to + * an interactive TTY, prompt to install. Non-TTY callers (auto-push, CI) get + * the same throw as before so they can recover by installing manually. + * + * Pass `config` so PostHog events are attributed to the logged-in user when + * possible; falls back to the machine UUID otherwise. + */ +export async function ensureCcusageInstalled( + config: StraudeConfig | null = null, +): Promise { + if (isCcusageInstalled()) return; + + const distinctId = getDistinctId(config); + + if (!isInteractive()) { + posthog.capture({ + distinctId, + event: "ccusage_install_skipped", + properties: { reason: "non_interactive" }, + }); + throw new Error( + "ccusage is not installed or not on PATH. Install it globally and retry (e.g. `npm install -g ccusage`).", + ); + } + + console.log("\nStraude needs `ccusage` to read your Claude Code usage."); + const accepted = await promptYesNo( + "Install ccusage globally now? [Y/n] ", + true, + ); + + if (!accepted) { + posthog.capture({ + distinctId, + event: "ccusage_install_declined", + }); + throw new Error( + "ccusage is required. Install it manually with `npm install -g ccusage` and run straude again.", + ); + } + + posthog.capture({ + distinctId, + event: "ccusage_install_attempted", + properties: { manager: isOnPath("bun") ? "bun" : "npm" }, + }); + + try { + installCcusage(); + } catch (err) { + posthog.capture({ + distinctId, + event: "ccusage_install_failed", + properties: { error: (err as Error).message?.slice(0, 200) ?? "unknown" }, + }); + throw new Error( + `Failed to install ccusage automatically: ${(err as Error).message}\n` + + "Install it manually with `npm install -g ccusage` and run straude again.", + ); + } + + // Reset resolver cache so the freshly installed binary is picked up. + _resolved = undefined; + + if (!isCcusageInstalled()) { + posthog.capture({ + distinctId, + event: "ccusage_install_failed", + properties: { error: "not_on_path_after_install" }, + }); + throw new Error( + "ccusage installed but not found on PATH. You may need to open a new shell, then re-run straude.", + ); + } + + posthog.capture({ + distinctId, + event: "ccusage_install_succeeded", + }); + console.log("ccusage installed successfully.\n"); +} + /** Run ccusage via the resolved binary. */ function execCcusage(args: string[], timeoutMs?: number): string { const { cmd, args: prefix } = resolveCcusageCommand(); diff --git a/packages/cli/src/lib/first-run.ts b/packages/cli/src/lib/first-run.ts new file mode 100644 index 00000000..f3ca4676 --- /dev/null +++ b/packages/cli/src/lib/first-run.ts @@ -0,0 +1,25 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { CONFIG_DIR } from "../config.js"; + +export const FIRST_RUN_MARKER = join(CONFIG_DIR, ".first-run"); + +export function isFirstRun(): boolean { + return !existsSync(FIRST_RUN_MARKER); +} + +export function markFirstRun(): void { + try { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); + } + writeFileSync(FIRST_RUN_MARKER, new Date().toISOString() + "\n", { + encoding: "utf-8", + mode: 0o600, + }); + } catch { + // Read-only home directory: skip marker write. Means we may re-fire + // cli_first_run on every invocation for this machine — annoying but never + // breaks the CLI. + } +} diff --git a/packages/cli/src/lib/prompt.ts b/packages/cli/src/lib/prompt.ts new file mode 100644 index 00000000..036ccfb2 --- /dev/null +++ b/packages/cli/src/lib/prompt.ts @@ -0,0 +1,27 @@ +import { createInterface } from "node:readline"; + +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY); +} + +/** + * Prompt the user with a yes/no question on stdin. Returns true for "y"/"yes" + * (case-insensitive), false otherwise. Empty input falls back to `defaultValue`. + * + * Caller is responsible for checking `isInteractive()` first — calling this in + * a non-TTY context will hang waiting on stdin. + */ +export function promptYesNo(question: string, defaultValue: boolean): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + const trimmed = answer.trim(); + if (trimmed === "") { + resolve(defaultValue); + return; + } + resolve(/^y(es)?$/i.test(trimmed)); + }); + }); +}