diff --git a/CLAUDE.md b/CLAUDE.md index ddd62c4..dabdc07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,6 +217,12 @@ Two separate key systems coexist: - `context.secrets.onDidChange` listener keeps status bar live without reload - After key validation in the webview (`serviceId === "recost"`), `recost.keyOnline` context is also updated so the status bar stays in sync +**Project ID (for remote scan submission)** — projects are created **only** in the web dashboard; the extension and CLI never auto-create them. Remote enrichment requires a user-supplied Project ID: +- In the extension: paste the dashboard's Project ID into the Keys tab. It is stored per-workspace in `workspaceState` (`recost.manualProjectId:`) and validated against `GET /projects/{id}` (key-fingerprinted validation snapshots). +- `resolveScanProjectTarget()` in `webview-provider.ts` returns `{ projectId, source: "manual" }` when a Project ID is set, otherwise `null`. A `null` target means the scan runs **local-only** and posts a `scanNotification` nudging the user to add a Project ID. +- In the CLI (`src/cli/scan.ts`): supply the Project ID via `--project-id ` or the `RECOST_PROJECT_ID` env var (flag wins). Without it, the CLI runs local-only and prints a stderr nudge. +- There is no auto-created `recost.projectId` globalState key and no `createProject`/`findProjectByName` in `api-client.ts` anymore. + **Chat provider keys** (OpenAI, Anthropic, etc.) — managed in `webview-provider.ts` + `chat/provider-registry.ts`: - Stored per-provider in `context.secrets` under provider-specific keys (e.g., `eco.providerApiKey.openai`) - Resolved via env var → SecretStorage fallback in `resolveProviderAuth()` diff --git a/docs/superpowers/plans/2026-06-10-dashboard-only-project-id.md b/docs/superpowers/plans/2026-06-10-dashboard-only-project-id.md new file mode 100644 index 0000000..b5a6d83 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-dashboard-only-project-id.md @@ -0,0 +1,442 @@ +# Dashboard-only Project ID Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a user-supplied (dashboard-created) Project ID the only path to remote scan enrichment; remove all project auto-creation from the extension and CLI. + +**Architecture:** The extension already stores a workspace-scoped manual Project ID and validates it. We delete the auto-create-and-persist fallback (`getOrCreateProject`, `recost.projectId` globalState, the 404→recreate recovery) so the scan resolver returns either the manual project or `null`. A `null` target means local-only results plus a nudge. The CLI gains a `--project-id` flag / `RECOST_PROJECT_ID` env var and likewise stops creating projects. `createProject`/`findProjectByName` are deleted from the API client. + +**Tech Stack:** TypeScript (strict), esbuild, React 18 (webview), Node test scripts compiled with `tsc` and run via `node dist-test/...`. + +--- + +## File Structure + +| File | Responsibility | Change | +|------|----------------|--------| +| `src/webview/scan-publishing-handler.ts` | Scan orchestration + remote submit | Nullable project target; remove 404 auto-recreate; local-only + nudge on null; drop `getProjectId`/`setProjectId` from context | +| `src/test/scan-publishing-handler.test.ts` | Handler behavior tests | Adjust stubs (`source: "manual"`, drop `createProject`); add null-target + manual-target tests | +| `src/webview-provider.ts` | Provider wiring + project resolution | `resolveScanProjectTarget` → manual-or-null; delete `getOrCreateProject`; retire `this.projectId`/`recost.projectId`; `getProjectId` (chat) → manual ID; drop createProject/findProjectByName import | +| `src/api-client.ts` | HTTP client | Delete `createProject`, `findProjectByName` | +| `src/cli/scan.ts` | CLI scan runner | `--project-id`/`RECOST_PROJECT_ID`; remove `createProject`; nudge + help | +| `webview/src/components/KeysPage.tsx` | Keys tab UI | Copy: remote now requires a dashboard-created Project ID; add "Open dashboard" affordance | + +**Build/test commands** (run from `extension/`): +- Full type+unit test suite: `npm test` +- Single handler test after compile: `npm test` runs `dist-test/test/scan-publishing-handler.test.js` near the end of the chain. There is no per-file runner; the suite compiles everything via `tsc -p tsconfig.scanner-tests.json` first, so a type error anywhere fails fast. +- Extension build: `npm run build:ext` +- Webview build: `npm run build:webview` + +--- + +## Task 1: Handler — nullable project target, no auto-creation (TDD) + +**Files:** +- Modify: `src/webview/scan-publishing-handler.ts` +- Test: `src/test/scan-publishing-handler.test.ts` + +- [ ] **Step 1: Update the test harness stubs for the new contract** + +In `src/test/scan-publishing-handler.test.ts`, remove the now-unused `createProject` stub from the api-client cache mock (lines ~46-54). The exports object becomes: + +```typescript + exports: { + submitScan: async () => { + if (nextScanError) throw nextScanError; + return { scanId: "scan-stub", summary: { totalEndpoints: 0, redundantCalls: 0, n1Suspects: 0, batchOpportunities: 0, cacheOpportunities: 0 } }; + }, + getAllEndpoints: async () => [], + getAllSuggestions: async () => [], + }, +``` + +In `makeCtx`, remove the `setProjectId` and `getProjectId` properties, and change the resolver stub to return a manual target: + +```typescript + setLastFindings: () => {}, + getManualProjectId: () => null, + getRcApiKey: async () => "rc-good", + resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "manual" as const }), + getWorkspaceName: () => "ws", +``` + +- [ ] **Step 2: Add the two new behavior tests** + +Insert before `console.log("PASS scan-publishing-handler");` in `runTests()`: + +```typescript + // 5. No project target (no manual ID) → local-only + nudge, never calls submitScan + { + const posted: HostMessage[] = []; + nextScanError = null; + let submitCalled = false; + const api = require.cache[require.resolve("../api-client")]!.exports as { submitScan: (...a: unknown[]) => Promise }; + const realSubmit = api.submitScan; + api.submitScan = async (...a: unknown[]) => { submitCalled = true; return realSubmit(...a); }; + const ctx: ScanPublishingHandlerContext = { + ...makeCtx(posted), + resolveScanProjectTarget: async () => null, + }; + const handler = new ScanPublishingHandler(ctx); + await handler.handleStartScan(); + api.submitScan = realSubmit; + assert.equal(submitCalled, false, "submitScan must not be called without a project target"); + const nudge = posted.find((m) => m.type === "scanNotification" && /Project ID/i.test((m as { message: string }).message)); + assert.ok(nudge, "expected a nudge to add a Project ID"); + } + + // 6. Manual project target → submitScan IS called with that project id + { + const posted: HostMessage[] = []; + nextScanError = null; + let submittedProjectId: string | null = null; + const api = require.cache[require.resolve("../api-client")]!.exports as { submitScan: (projectId: string, ...a: unknown[]) => Promise }; + const realSubmit = api.submitScan; + api.submitScan = async (projectId: string, ...a: unknown[]) => { submittedProjectId = projectId; return realSubmit(projectId, ...a); }; + const ctx: ScanPublishingHandlerContext = { + ...makeCtx(posted), + resolveScanProjectTarget: async () => ({ projectId: "proj-manual", source: "manual" as const }), + }; + const handler = new ScanPublishingHandler(ctx); + await handler.handleStartScan(); + api.submitScan = realSubmit; + assert.equal(submittedProjectId, "proj-manual"); + } +``` + +- [ ] **Step 3: Run the suite to verify the new tests fail** + +Run: `npm test` +Expected: TypeScript compile error first (`getProjectId`/`setProjectId` still referenced in `scan-publishing-handler.ts` but removed from the context stub), OR — once Step 4 type changes are partially applied — test 5 fails because the handler still dereferences a null target. Either way the suite is RED. + +- [ ] **Step 4: Update the handler context interface** + +In `src/webview/scan-publishing-handler.ts`, in `ScanPublishingHandlerContext`, delete the `setProjectId` and `getProjectId` members and change the resolver signature: + +```typescript + getManualProjectId(): string | null; + getRcApiKey(): Promise; + resolveScanProjectTarget(rcApiKey: string): Promise<{ projectId: string; source: "manual" } | null>; + getWorkspaceName(): string; +``` + +Remove the `createProject` import; the import line becomes: + +```typescript +import { submitScan, getAllEndpoints, getAllSuggestions, type ApiClientError } from "../api-client"; +``` + +- [ ] **Step 5: Replace the project-resolution + submit block** + +In `handleStartScan`, replace the four local-only fallback expressions that read `manualProjectId ?? this.ctx.getProjectId() ?? "local"` (the no-key path, the empty-remote path, the optimistic publish, and the catch-path publishes) with `manualProjectId ?? "local"`. + +Then replace the `try { const projectTarget = ... }` resolution + 404-recovery block (currently lines ~726-741) with: + +```typescript + const projectTarget = await this.ctx.resolveScanProjectTarget(rcApiKey); + if (!projectTarget) { + this.ctx.postMessage({ + type: "scanNotification", + message: "Add a Project ID from your dashboard in the Keys tab to sync remotely.", + }); + return; + } + const projectId = projectTarget.projectId; + const scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); +``` + +(The optimistic `publishLocalOnlyResults(manualProjectId ?? "local", newLocalScanId())` call just above the `try` stays, so local results are already on screen when we `return`.) Delete the inner `try/catch` that called `createProject` on a 404 — `submitScan` is now awaited directly. Leave the outer `catch` (429 / auth / 404-manual / fetch-failed handling) intact; its `manualProjectId ?? "local"` fallbacks were updated above. + +- [ ] **Step 6: Run the suite to verify it passes** + +Run: `npm test` +Expected: PASS, ending with `PASS scan-publishing-handler` and the suite's final line. + +- [ ] **Step 7: Commit** + +```bash +git add src/webview/scan-publishing-handler.ts src/test/scan-publishing-handler.test.ts +git commit -m "feat(#45): scan handler resolves manual project or goes local-only; drop auto-create" +``` + +--- + +## Task 2: Provider — manual-or-null resolver, retire auto-creation state + +**Files:** +- Modify: `src/webview-provider.ts` + +- [ ] **Step 1: Rewrite `resolveScanProjectTarget`** + +Replace the method (currently ~lines 492-500) with: + +```typescript + private async resolveScanProjectTarget( + _rcApiKey: string + ): Promise<{ projectId: string; source: "manual" } | null> { + const manualProjectId = this.getManualProjectId(); + return manualProjectId ? { projectId: manualProjectId, source: "manual" } : null; + } +``` + +- [ ] **Step 2: Delete `getOrCreateProject`** + +Remove the entire `getOrCreateProject` method (currently ~lines 592-603). + +- [ ] **Step 3: Remove the dead import and field** + +Change the api-client import (line 10) from: + +```typescript +import { findProjectByName, createProject, validateProjectId } from "./api-client"; +``` + +to: + +```typescript +import { validateProjectId } from "./api-client"; +``` + +Delete the `private projectId: string | null = null;` field declaration (~line 147) and the line in `resolveWebviewView` that hydrates it (`this.projectId = this.context.globalState.get("recost.projectId") ?? null;`, ~line 284). + +- [ ] **Step 4: Rewire the chat project-id and drop unused wiring** + +In the `ChatHandler` construction, change `getProjectId: () => this.projectId,` to: + +```typescript + getProjectId: () => this.getManualProjectId(), +``` + +In the `ScanPublishingHandler` construction, delete the `setProjectId: (id) => { this.projectId = id; },` and `getProjectId: () => this.projectId,` properties (they were removed from the context interface in Task 1). + +- [ ] **Step 5: Build the extension to verify it compiles** + +Run: `npm run build:ext` +Expected: builds with no TypeScript errors. (No `recost.projectId`, `getOrCreateProject`, `createProject`, or `findProjectByName` references remain in `webview-provider.ts`.) + +- [ ] **Step 6: Run the full suite** + +Run: `npm test` +Expected: PASS (no behavioral test depends on the removed provider internals beyond the handler tests already updated). + +- [ ] **Step 7: Commit** + +```bash +git add src/webview-provider.ts +git commit -m "refactor(#45): manual-or-null project resolver; retire recost.projectId auto-state" +``` + +--- + +## Task 3: API client — delete project-creation functions + +**Files:** +- Modify: `src/api-client.ts` + +- [ ] **Step 1: Confirm there are no remaining callers** + +Run: `grep -rn "createProject\|findProjectByName" src/ | grep -v node_modules` +Expected: no matches (after Tasks 1-2). If any remain, they must be removed before deleting the functions. + +- [ ] **Step 2: Delete the functions** + +Remove `createProject` (currently ~lines 46-52) and `findProjectByName` (~lines 54-63) from `src/api-client.ts`. Keep `validateRcApiKey`, `validateProjectId`, `submitScan`, `getAllEndpoints`, `getAllSuggestions`. + +- [ ] **Step 3: Build + test** + +Run: `npm run build:ext && npm test` +Expected: builds and the suite PASSES. (`src/test/api-client.test.ts` does not reference the deleted functions.) + +- [ ] **Step 4: Commit** + +```bash +git add src/api-client.ts +git commit -m "refactor(#45): remove createProject/findProjectByName from API client" +``` + +--- + +## Task 4: CLI — supply Project ID via flag/env, no auto-creation + +**Files:** +- Modify: `src/cli/scan.ts` + +- [ ] **Step 1: Extend `CliOptions` and `parseArgs`** + +Change the interface (~line 14): + +```typescript +interface CliOptions { + target: string; + format: "json" | "summary" | "context"; + projectId?: string; +} +``` + +In `parseArgs`, add a local `let projectId: string | undefined;` near `let format`, and a flag branch inside the `while` loop, before the `if (!target)` branch: + +```typescript + if (arg === "--project-id") { + const value = args.shift(); + if (!value) throw new Error("--project-id requires a value"); + projectId = value; + continue; + } +``` + +Then resolve env as a fallback at the return: + +```typescript + if (!target) return null; + return { target, format, projectId: projectId ?? process.env.RECOST_PROJECT_ID?.trim() || undefined }; +``` + +- [ ] **Step 2: Remove auto-creation from the scan run** + +Replace the remote-enrichment guard and the `createProject` line. Change the import (line 6) to drop `createProject`: + +```typescript +import { getAllEndpoints, getAllSuggestions, submitScan } from "../api-client"; +``` + +Replace the block at ~lines 233-239: + +```typescript + const rcApiKey = resolveRcApiKey(); + const remoteApiCalls = apiCalls.filter(shouldSubmitRemote); + let remoteResult: CliResult["remote"] = null; + if (rcApiKey && options.projectId && remoteApiCalls.length > 0) { + try { + projectId = options.projectId; + const remoteScan = await submitScan(projectId, remoteApiCalls, rcApiKey); +``` + +(The rest of the `try` body — `scanId = remoteScan.scanId;` through `mode = "remote-enriched";` — is unchanged.) + +- [ ] **Step 3: Add the no-project-id nudge** + +Immediately after the `if (rcApiKey && options.projectId && remoteApiCalls.length > 0) { ... }` block closes, add: + +```typescript + if (rcApiKey && !options.projectId && remoteApiCalls.length > 0) { + process.stderr.write("Set RECOST_PROJECT_ID (or --project-id) to sync scans remotely. Showing local-only results.\n"); + } +``` + +- [ ] **Step 4: Document the flag in `printHelp`** + +Add a line to the options section of `printHelp()` (locate the existing `--format` help line and add beneath it): + +```typescript + " --project-id Dashboard project ID for remote sync (or RECOST_PROJECT_ID env var)", +``` + +- [ ] **Step 5: Build and smoke-test the CLI** + +Run: `npm run build:ext` +Expected: compiles. Then verify help shows the flag: + +Run: `node dist/cli/scan.js --help 2>&1 | grep -- "--project-id"` +Expected: prints the new help line. + +- [ ] **Step 6: Commit** + +```bash +git add src/cli/scan.ts +git commit -m "feat(#45): CLI takes --project-id/RECOST_PROJECT_ID; no project auto-creation" +``` + +--- + +## Task 5: Keys tab — copy reflects dashboard-only project creation + +**Files:** +- Modify: `webview/src/components/KeysPage.tsx` + +- [ ] **Step 1: Update the descriptive copy** + +Replace the description line (currently `Optional per-workspace override for remote scan uploads.`) with text that states remote sync requires a dashboard-created project: + +```tsx +
+ Create a project in the ReCost dashboard, then paste its ID here to sync scans remotely. Without it, scans stay local-only. +
+``` + +- [ ] **Step 2: Add an "Open dashboard" affordance using the existing IPC** + +Directly below that description `
`, add a button that posts the existing `openDashboard` message (already handled by the host dispatcher): + +```tsx + +``` + +- [ ] **Step 3: Confirm `openDashboard` is an accepted webview message** + +Run: `grep -rn "openDashboard" webview/src/ src/messages.ts src/webview-provider.ts` +Expected: `openDashboard` appears in the host dispatch (`src/webview-provider.ts`) and the `WebviewMessage` union (`src/messages.ts`). If it is NOT in the `WebviewMessage` type, add `| { type: "openDashboard" }` to that union in `src/messages.ts` and a matching `openDashboard: () => this.handleOpenDashboard()` dispatch entry (verify it already exists before adding). + +- [ ] **Step 4: Build the webview** + +Run: `npm run build:webview` +Expected: builds with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add webview/src/components/KeysPage.tsx src/messages.ts +git commit -m "feat(#45): Keys tab copy + dashboard link for required Project ID" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: No stale references remain** + +Run: `grep -rn "getOrCreateProject\|createProject\|findProjectByName\|recost\.projectId" src/ webview/src/ | grep -v node_modules` +Expected: no matches. + +- [ ] **Step 2: Full build + test** + +Run: `npm run build && npm test` +Expected: full build (dashboard + webview + extension) succeeds and the test suite PASSES. + +- [ ] **Step 3: Update CLAUDE.md auth section** + +In `CLAUDE.md`, the "Auth / API Key System" and cost-estimation notes describe scan submission. Update the ReCost API key section to note that remote scans require a user-supplied (dashboard-created) Project ID set in the Keys tab (or `--project-id`/`RECOST_PROJECT_ID` for the CLI), and that the extension/CLI no longer auto-create projects. Commit: + +```bash +git add CLAUDE.md +git commit -m "docs(#45): note dashboard-only project creation in auth section" +``` + +--- + +## Spec coverage check + +- Extension auto-creation removed → Tasks 1, 2. +- 404→auto-recreate removed → Task 1 (Step 5). +- `resolveScanProjectTarget` manual-or-null → Tasks 1 (type), 2 (impl). +- `recost.projectId` / `this.projectId` retired → Task 2. +- Chat `getProjectId` → manual ID → Task 2 (Step 4). +- CLI `--project-id`/`RECOST_PROJECT_ID`, no createProject, nudge, help → Task 4. +- API client `createProject`/`findProjectByName` deleted → Task 3. +- Keys tab copy + dashboard link → Task 5. +- No-ID/failure degradation (local-only + nudge) → Task 1 (Steps 2, 5); existing 429/auth/manual-404 paths preserved → Task 1 (Step 5). +- Tests for null target, manual target, regression grep → Tasks 1, 6. diff --git a/docs/superpowers/specs/2026-06-09-dashboard-only-project-id-design.md b/docs/superpowers/specs/2026-06-09-dashboard-only-project-id-design.md new file mode 100644 index 0000000..05b8806 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-dashboard-only-project-id-design.md @@ -0,0 +1,132 @@ +# Dashboard-only project creation; manual Project ID as the sole remote path + +**Issue:** #45 (Extension: Opt-in Project ID Persistence) — reframed. +**Date:** 2026-06-09 +**Status:** Approved design, pending implementation plan. + +## Background + +Issue #45 originally asked for *opt-in* persistence of an auto-created project ID, on +the premise that "the extension creates a new project on every scan." That premise is +already stale: the current code persists an auto-created project ID across scans +(`getOrCreateProject()` in `webview-provider.ts`, stored in `globalState` under +`recost.projectId`), and a full bring-your-own-ID flow already exists (workspace-scoped +manual Project ID in the Keys tab, validated against `GET /projects/{id}`). + +Rather than build the obsolete opt-in toggle or a telemetry-handoff panel, the team +decided to **invert the model**: + +> Projects are created **only** in the web dashboard. The extension and CLI never create +> projects. The single way to get remote enrichment is to supply a Project ID (obtained +> from the dashboard) — in the extension via the Keys tab, in the CLI via flag/env. + +This is mostly a **removal** of code (auto-creation) plus a small rewire, and it makes +the manually-entered Project ID the single source of truth for remote scans. + +## Principle + +- Project creation happens in the dashboard, never in the extension or CLI. +- Remote enrichment runs only when **both** a ReCost API key **and** a user-supplied + Project ID are present (and the project is valid for that key). +- Any other state — no key, no Project ID, or an invalid Project ID — degrades + gracefully to **local-only** results plus a nudge telling the user how to connect. + +## Scope + +Four areas change. Persistence storage, validation, and all local analysis are untouched. + +### A. Extension scan flow + +Files: `src/webview-provider.ts`, `src/webview/scan-publishing-handler.ts`. + +- **Delete** `getOrCreateProject()` and its calls to `createProject` / `findProjectByName`. +- **Delete** the 404 → auto-recreate recovery block in `scan-publishing-handler.ts` + (currently `if (status === 404 && projectTarget.source === "auto") { createProject... }`). + A 404 from `submitScan` now means the supplied Project ID is invalid for this key. +- `resolveScanProjectTarget(rcApiKey)` collapses to: + - manual ID present → `{ projectId: , source: "manual" }` + - otherwise → `null` (no remote target). +- `handleStartScan`: remote submit runs only when a key is present **and** + `resolveScanProjectTarget` returns non-null. Otherwise call `publishLocalOnlyResults`. + The local-only `projectId` argument becomes `manualProjectId ?? "local"` — the former + `getProjectId() ?? "local"` fallback is removed. +- **Retire** the `recost.projectId` `globalState` key and the `this.projectId` field on + `ReCostSidebarProvider` (no longer written by any flow). + +### B. Chat context + +Files: `src/webview-provider.ts`, `src/webview/chat-handler.ts` (consumer, unchanged shape). + +- The `getProjectId()` callback passed to `ChatHandler` now returns `getManualProjectId()` + instead of the retired `this.projectId`. The chat fallback chain + `lastEndpoints[0]?.projectId ?? providerProjectId ?? "local"` is unchanged. + +### C. CLI scan + +File: `src/cli/scan.ts`. + +- Add `projectId?: string` to `CliOptions`, resolved from a `--project-id` flag (via the + existing `getFlag` helper) **or** the `RECOST_PROJECT_ID` env var. Flag takes precedence + over env. +- **Delete** the `createProject(path.basename(...))` call. +- Remote enrichment runs only when `rcApiKey && projectId && remoteApiCalls.length > 0`. + Absent any of these → local-only (the existing default path; `projectId` stays `"local"`). +- When a key is present but no Project ID is supplied, write a stderr nudge: + `Set RECOST_PROJECT_ID (or --project-id) to sync scans remotely.` +- Update `printHelp()` to document the flag and env var. + +### D. Keys tab UX + +File: `webview/src/components/KeysPage.tsx`. + +- Keep the existing Project ID input as the single remote control. There is no auto-created + ID to surface anymore. +- Add a one-line hint with a dashboard link near the input: + *"Create a project in the dashboard, then paste its ID here to sync scans."* The link + targets the dashboard base URL (`RECOST_DASHBOARD_BASE_URL` via existing config). + +### E. API client cleanup + +File: `src/api-client.ts`. + +- **Delete** `createProject` and `findProjectByName` (no remaining callers after A and C). +- Keep `validateProjectId` (powers Keys-tab validation) and `submitScan` / + `getAllEndpoints` / `getAllSuggestions`. + +## No-ID / failure behavior (graceful degradation) + +| State | Behavior | +|-------|----------| +| No API key | Local-only + existing "no key" notification. | +| Key, no Project ID | Local-only + nudge: "Add a Project ID from your dashboard in the Keys tab to sync remotely." | +| Key + invalid Project ID (404) | Local-only; keep the saved manual ID; notify "Project ID … was not found." (existing manual-404 message path). | +| Key + valid Project ID | Remote submit to that project (existing path). | + +## What stays untouched + +- Workspace-scoped manual Project ID storage (`recost.manualProjectId:`). +- The validate → valid/invalid snapshot flow, key-fingerprinted. +- `submitScan`, `getAllEndpoints`, `getAllSuggestions`, `validateProjectId`. +- All local scanning, waste detection, simulator, and intelligence layers. + +## Testing + +- `resolveScanProjectTarget`: returns `null` with no manual ID; returns + `{ projectId, source: "manual" }` when a manual ID is set. +- Extension scan, key present, no manual ID → local-only results + nudge notification; + assert neither `createProject` nor `submitScan` is called. +- Extension scan, key + valid manual ID → `submitScan` called with that ID; remote-enriched + results published. +- Extension scan, key + manual ID that 404s → local-only; saved manual ID retained; + not-found notification. +- CLI: `--project-id` and `RECOST_PROJECT_ID` each drive remote submit to that ID; flag + beats env; absent → local-only with no project creation. +- Regression: no remaining references to `getOrCreateProject`, `createProject`, + `findProjectByName`, or `recost.projectId` in the codebase. + +## Out of scope + +- The opt-in `recost.persistProjectId` setting from the original #45 spec (obsolete under + this model). +- Any telemetry-handoff `.env` snippet panel. +- Dashboard-side project-creation UX (already exists; not part of this repo's change). diff --git a/src/api-client.ts b/src/api-client.ts index 0eea290..081998b 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -43,24 +43,6 @@ async function apiFetchWith( return res.json() as Promise; } -export async function createProject(name: string, rcApiKey?: string): Promise { - const { data } = await apiFetch<{ data: { id: string } }>("/projects", { - method: "POST", - body: JSON.stringify({ name }), - }, rcApiKey); - return data.id; -} - -export async function findProjectByName(name: string, rcApiKey?: string): Promise { - const encoded = encodeURIComponent(name); - const { data } = await apiFetch<{ data: { id: string; name: string }[] }>( - `/projects?name=${encoded}&limit=1`, - undefined, - rcApiKey - ); - return data[0]?.id ?? null; -} - export async function validateRcApiKey(rcApiKey: string): Promise { if (!rcApiKey.startsWith("rc-")) { const err = new Error("Invalid ReCost API key — keys must start with rc-") as Error & { status: number }; diff --git a/src/cli/scan.ts b/src/cli/scan.ts index 57509ae..6fc2db6 100644 --- a/src/cli/scan.ts +++ b/src/cli/scan.ts @@ -3,7 +3,7 @@ import * as path from "path"; import { newLocalScanId } from "../scan-id"; import { createFilesystemScanAccess } from "./filesystem-adapter"; import { detectLocalWastePatternsInFiles, scanFiles } from "../scanner/core-scanner"; -import { createProject, getAllEndpoints, getAllSuggestions, submitScan } from "../api-client"; +import { getAllEndpoints, getAllSuggestions, submitScan } from "../api-client"; import { buildLocalScanResults, buildRemoteScanResults, shouldSubmitRemote, type FinalScanResults } from "../scan-results"; import { buildSnapshot } from "../intelligence/builder"; import { scoreSnapshot } from "../intelligence/scorer"; @@ -14,6 +14,7 @@ import { buildExportContext, formatAsJSON, formatAsMarkdown } from "../intellige interface CliOptions { target: string; format: "json" | "summary" | "context"; + projectId?: string; } interface CliResult { @@ -53,9 +54,13 @@ function getFlag(args: string[], flag: string): string | null { function printHelp(): void { process.stdout.write( [ - "Usage: node dist/cli/scan.js [--format json|summary|context]", + "Usage: node dist/cli/scan.js [--format json|summary|context] [--project-id ]", " node dist/cli/scan.js pack [--format markdown|json] [--output ] [--append-claude-md]", "", + "Options:", + " --format Output format: json (default), summary, context", + " --project-id Dashboard project ID for remote sync (or RECOST_PROJECT_ID env var)", + "", "Formats:", " json Full scan results as JSON (default)", " summary Human-readable summary of endpoints and issues", @@ -83,6 +88,7 @@ function parseArgs(argv: string[]): CliOptions | null { const args = [...argv]; let target = ""; let format: CliOptions["format"] = "json"; + let projectId: string | undefined; while (args.length > 0) { const arg = args.shift(); @@ -96,6 +102,12 @@ function parseArgs(argv: string[]): CliOptions | null { } throw new Error(`Unsupported format: ${value ?? "(missing value)"}`); } + if (arg === "--project-id") { + const value = args.shift(); + if (!value) throw new Error("--project-id requires a value"); + projectId = value; + continue; + } if (!target) { target = arg; continue; @@ -104,7 +116,7 @@ function parseArgs(argv: string[]): CliOptions | null { } if (!target) return null; - return { target, format }; + return { target, format, projectId: (projectId ?? process.env.RECOST_PROJECT_ID?.trim()) || undefined }; } function writeSummary(result: CliResult): void { @@ -233,9 +245,9 @@ async function main(): Promise { const rcApiKey = resolveRcApiKey(); const remoteApiCalls = apiCalls.filter(shouldSubmitRemote); let remoteResult: CliResult["remote"] = null; - if (rcApiKey && remoteApiCalls.length > 0) { + if (rcApiKey && options.projectId && remoteApiCalls.length > 0) { try { - projectId = await createProject(path.basename(path.resolve(options.target)), rcApiKey); + projectId = options.projectId; const remoteScan = await submitScan(projectId, remoteApiCalls, rcApiKey); scanId = remoteScan.scanId; const [remoteEndpoints, remoteSuggestions] = await Promise.all([ @@ -265,6 +277,10 @@ async function main(): Promise { } } + if (rcApiKey && !options.projectId && remoteApiCalls.length > 0) { + process.stderr.write("Set RECOST_PROJECT_ID (or --project-id) to sync scans remotely. Showing local-only results.\n"); + } + const result: CliResult = { target: path.resolve(options.target), scannedFileCount: access.files.length, diff --git a/src/test/scan-publishing-handler.test.ts b/src/test/scan-publishing-handler.test.ts index 9c2cec0..d3424fe 100644 --- a/src/test/scan-publishing-handler.test.ts +++ b/src/test/scan-publishing-handler.test.ts @@ -44,7 +44,6 @@ require.cache[require.resolve("../api-client")] = { filename: require.resolve("../api-client"), loaded: true, exports: { - createProject: async () => "proj-stub", submitScan: async () => { if (nextScanError) throw nextScanError; return { scanId: "scan-stub", summary: { totalEndpoints: 0, redundantCalls: 0, n1Suspects: 0, batchOpportunities: 0, cacheOpportunities: 0 } }; @@ -67,11 +66,9 @@ function makeCtx(posted: HostMessage[]): ScanPublishingHandlerContext { setLastSummary: () => {}, setLastApiCalls: () => {}, setLastFindings: () => {}, - setProjectId: () => {}, - getProjectId: () => null, getManualProjectId: () => null, getRcApiKey: async () => "rc-good", - resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "auto" }), + resolveScanProjectTarget: async () => ({ projectId: "proj-stub", source: "manual" as const }), getWorkspaceName: () => "ws", openKeys: () => {}, setRecostValidationState: noop, @@ -150,6 +147,44 @@ async function runTests() { assert.equal(refreshedAfterUpdate, true, "refreshStatusBar must be called after sendRecostKeyStatusUpdate"); } + // 5. No project target (no manual ID) → local-only + nudge, never calls submitScan + { + const posted: HostMessage[] = []; + nextScanError = null; + let submitCalled = false; + const api = require.cache[require.resolve("../api-client")]!.exports as { submitScan: (...a: unknown[]) => Promise }; + const realSubmit = api.submitScan; + api.submitScan = async (...a: unknown[]) => { submitCalled = true; return realSubmit(...a); }; + const ctx: ScanPublishingHandlerContext = { + ...makeCtx(posted), + resolveScanProjectTarget: async () => null, + }; + const handler = new ScanPublishingHandler(ctx); + await handler.handleStartScan(); + api.submitScan = realSubmit; + assert.equal(submitCalled, false, "submitScan must not be called without a project target"); + const nudge = posted.find((m) => m.type === "scanNotification" && /Project ID/i.test((m as { message: string }).message)); + assert.ok(nudge, "expected a nudge to add a Project ID"); + } + + // 6. Manual project target → submitScan IS called with that project id + { + const posted: HostMessage[] = []; + nextScanError = null; + let submittedProjectId: string | null = null; + const api = require.cache[require.resolve("../api-client")]!.exports as { submitScan: (projectId: string, ...a: unknown[]) => Promise }; + const realSubmit = api.submitScan; + api.submitScan = async (projectId: string, ...a: unknown[]) => { submittedProjectId = projectId; return realSubmit(projectId, ...a); }; + const ctx: ScanPublishingHandlerContext = { + ...makeCtx(posted), + resolveScanProjectTarget: async () => ({ projectId: "proj-manual", source: "manual" as const }), + }; + const handler = new ScanPublishingHandler(ctx); + await handler.handleStartScan(); + api.submitScan = realSubmit; + assert.equal(submittedProjectId, "proj-manual"); + } + console.log("PASS scan-publishing-handler"); } diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 6f0b442..98f41b8 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -7,7 +7,7 @@ import { detectLocalWastePatterns, countScopedWorkspaceFiles, } from "./scanner/workspace-scanner"; -import { findProjectByName, createProject, validateProjectId } from "./api-client"; +import { validateProjectId } from "./api-client"; import { getDefaultChatSelection, type ChatProviderId, @@ -144,7 +144,6 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { private lastEndpoints: EndpointRecord[] = []; private lastSuggestions: Suggestion[] = []; private lastSummary: ScanSummary | null = null; - private projectId: string | null = null; private lastApiCalls: ApiCallInput[] = []; private lastFindings: Awaited> = []; @@ -226,7 +225,7 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { getLastEndpoints: () => this.lastEndpoints, getLastSuggestions: () => this.lastSuggestions, getLastSummary: () => this.lastSummary, - getProjectId: () => this.projectId, + getProjectId: () => this.getManualProjectId(), setLastSuggestions: (suggestions) => { this.lastSuggestions = suggestions; }, setLastSummary: (summary) => { this.lastSummary = summary; }, getKeyServiceIdForProvider: (providerId) => this.getKeyServiceIdForProvider(providerId), @@ -244,8 +243,6 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { setLastSummary: (summary) => { this.lastSummary = summary; }, setLastApiCalls: (calls) => { this.lastApiCalls = calls; }, setLastFindings: (findings) => { this.lastFindings = findings; }, - setProjectId: (id) => { this.projectId = id; }, - getProjectId: () => this.projectId, getManualProjectId: () => this.getManualProjectId(), getRcApiKey: () => this.getRcApiKey(), resolveScanProjectTarget: (rcApiKey) => this.resolveScanProjectTarget(rcApiKey), @@ -281,7 +278,6 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { this.context.subscriptions.push(messageSub); webviewView.onDidDispose(() => messageSub.dispose()); - this.projectId = this.context.globalState.get("recost.projectId") ?? null; this.sendChatConfig().catch((e) => getOutputChannel().appendLine(`sendChatConfig failed: ${e instanceof Error ? e.message : String(e)}`)); this.sendAllKeyStatuses().catch((e) => getOutputChannel().appendLine(`sendAllKeyStatuses failed: ${e instanceof Error ? e.message : String(e)}`)); this.sendProjectIdStatus().catch((e) => getOutputChannel().appendLine(`sendProjectIdStatus failed: ${e instanceof Error ? e.message : String(e)}`)); @@ -490,13 +486,10 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { } private async resolveScanProjectTarget( - rcApiKey: string - ): Promise<{ projectId: string; source: "manual" | "auto" }> { + _rcApiKey: string + ): Promise<{ projectId: string; source: "manual" } | null> { const manualProjectId = this.getManualProjectId(); - if (manualProjectId) { - return { projectId: manualProjectId, source: "manual" }; - } - return { projectId: await this.getOrCreateProject(rcApiKey), source: "auto" }; + return manualProjectId ? { projectId: manualProjectId, source: "manual" } : null; } private async sendAllKeyStatuses(focusServiceId?: KeyServiceId) { @@ -589,19 +582,6 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { return this.chatHandler.handleRunAiReview(); } - private async getOrCreateProject(rcApiKey?: string): Promise { - if (this.projectId) { - return this.projectId; - } - // No local record — check if a project with this workspace name already exists - // (handles cloning the same repo on a new machine) - const existing = await findProjectByName(this.getWorkspaceName(), rcApiKey); - const id = existing ?? await createProject(this.getWorkspaceName(), rcApiKey); - this.projectId = id; - await this.context.globalState.update("recost.projectId", id); - return id; - } - private getWorkspaceName(): string { return vscode.workspace.workspaceFolders?.[0]?.name ?? "recost-workspace"; } diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index 2b6b4a8..e2943a6 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -5,7 +5,7 @@ import { countScopedWorkspaceFiles, getWorkspaceScanFiles, } from "../scanner/workspace-scanner"; -import { createProject, submitScan, getAllEndpoints, getAllSuggestions, type ApiClientError } from "../api-client"; +import { submitScan, getAllEndpoints, getAllSuggestions, type ApiClientError } from "../api-client"; import type { HostMessage, KeyServiceId } from "../messages"; import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "../analysis/types"; import { classifyEndpointScope, detectEndpointProvider } from "../scanner/endpoint-classification"; @@ -51,11 +51,9 @@ export interface ScanPublishingHandlerContext { setLastSummary(summary: ScanSummary | null): void; setLastApiCalls(calls: ApiCallInput[]): void; setLastFindings(findings: Awaited>): void; - setProjectId(id: string | null): void; - getProjectId(): string | null; getManualProjectId(): string | null; getRcApiKey(): Promise; - resolveScanProjectTarget(rcApiKey: string): Promise<{ projectId: string; source: "manual" | "auto" }>; + resolveScanProjectTarget(rcApiKey: string): Promise<{ projectId: string; source: "manual" } | null>; getWorkspaceName(): string; openKeys(focusServiceId?: KeyServiceId): void; setRecostValidationState(snapshot: PersistedKeyValidationSnapshot): Promise; @@ -695,7 +693,7 @@ export class ScanPublishingHandler { const manualProjectId = this.ctx.getManualProjectId(); let rcApiKey = await this.ctx.getRcApiKey(); if (!rcApiKey) { - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); + publishLocalOnlyResults(manualProjectId ?? "local", newLocalScanId()); this.ctx.postMessage({ type: "scanNotification", message: "No ReCost API key — showing local results only. Add a key in Keys to enable remote sync.", @@ -705,7 +703,7 @@ export class ScanPublishingHandler { const { submitted: remoteApiCalls, unknownProviderCount, unknownProviderHosts } = buildRemoteApiCalls(apiCalls); if (remoteApiCalls.length === 0) { - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); + publishLocalOnlyResults(manualProjectId ?? "local", newLocalScanId()); return; } if (unknownProviderCount > 0) { @@ -720,25 +718,19 @@ export class ScanPublishingHandler { }); } - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); + publishLocalOnlyResults(manualProjectId ?? "local", newLocalScanId()); try { const projectTarget = await this.ctx.resolveScanProjectTarget(rcApiKey); - let projectId = projectTarget.projectId; - let scanResult; - try { - scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); - } catch (err: unknown) { - if ((err as { status?: number }).status === 404 && projectTarget.source === "auto") { - const freshId = await createProject(this.ctx.getWorkspaceName(), rcApiKey); - this.ctx.setProjectId(freshId); - projectId = freshId; - await this.ctx.context.globalState.update("recost.projectId", freshId); - scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); - } else { - throw err; - } + if (!projectTarget) { + this.ctx.postMessage({ + type: "scanNotification", + message: "Add a Project ID from your dashboard in the Keys tab to sync remotely.", + }); + return; } + const projectId = projectTarget.projectId; + const scanResult = await submitScan(projectId, remoteApiCalls, rcApiKey); const [remoteEndpoints, suggestions] = await Promise.all([ getAllEndpoints(projectId, scanResult.scanId, rcApiKey), @@ -816,7 +808,7 @@ export class ScanPublishingHandler { type: "scanNotification", message: `ReCost scan rate limit reached. ${waitText} Showing local results.`, }); - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); + publishLocalOnlyResults(manualProjectId ?? "local", newLocalScanId()); return; } @@ -840,7 +832,7 @@ export class ScanPublishingHandler { this.ctx.refreshStatusBar(); this.ctx.openKeys("recost"); } - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); + publishLocalOnlyResults(manualProjectId ?? "local", newLocalScanId()); if (status === 404 && manualProjectId) { this.ctx.postMessage({ type: "scanNotification", diff --git a/webview/src/components/KeysPage.tsx b/webview/src/components/KeysPage.tsx index 6b724df..38d3d78 100644 --- a/webview/src/components/KeysPage.tsx +++ b/webview/src/components/KeysPage.tsx @@ -133,8 +133,24 @@ export function KeysPage({ statuses, focusServiceId, projectIdStatus }: KeysPage {projectStatusLabel}
- Optional per-workspace override for remote scan uploads. + Create a project in the ReCost dashboard, then paste its ID here to sync scans remotely. Without it, scans stay local-only.
+ {projectIdStatus.message && (
{projectIdStatus.message}