diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 700113020..da39e4ffa 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,3 +1,3 @@ { "mcpServers": {} -} \ No newline at end of file +} diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 17d262524..362875e00 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -13,6 +13,7 @@ We will acknowledge receipt and work to address the issue as quickly as possible ## Scope This policy applies to: + - The CoRATES web application - Cloudflare Workers and Durable Objects - Client-side synchronization and storage logic diff --git a/docs/architecture/diagrams/01-package-architecture.md b/docs/architecture/diagrams/01-package-architecture.md index 33441ef80..bd6d06ea8 100644 --- a/docs/architecture/diagrams/01-package-architecture.md +++ b/docs/architecture/diagrams/01-package-architecture.md @@ -31,11 +31,11 @@ graph TB ## Package Details -| Package | Purpose | Tech | -| --------- | ----------------------------------- | ------------------------ | -| `web` | Main SolidJS application | SolidJS, Vite, Tailwind | -| `workers` | Backend API and real-time sync | Hono, Cloudflare Workers | -| `landing` | Marketing site (includes web app) | SolidStart | -| `ui` | Shared component library | SolidJS, Zag.js | -| `shared` | Shared error definitions and utilities | TypeScript | -| `mcp` | Development tooling (docs, linting) | Node.js | +| Package | Purpose | Tech | +| --------- | -------------------------------------- | ------------------------ | +| `web` | Main SolidJS application | SolidJS, Vite, Tailwind | +| `workers` | Backend API and real-time sync | Hono, Cloudflare Workers | +| `landing` | Marketing site (includes web app) | SolidStart | +| `ui` | Shared component library | SolidJS, Zag.js | +| `shared` | Shared error definitions and utilities | TypeScript | +| `mcp` | Development tooling (docs, linting) | Node.js | diff --git a/docs/architecture/diagrams/04-data-model.md b/docs/architecture/diagrams/04-data-model.md index a5edee244..6a9272035 100644 --- a/docs/architecture/diagrams/04-data-model.md +++ b/docs/architecture/diagrams/04-data-model.md @@ -66,17 +66,18 @@ Individual response to a checklist question. Stored entirely in Durable Objects ## Storage Split -| Entity | Storage | Reason | -| ---------------------------- | -------------------------- | ----------------------------------- | -| Users | D1 (SQLite) | User accounts, authentication | -| Projects (metadata) | D1 (SQLite) | Basic project info (id, name, description, createdBy, timestamps) - source of truth for access control | -| Project Members (relationships) | D1 (SQLite) | Access control (who can access which projects) | -| Studies, Checklists, Answers | Durable Objects (Yjs Document) | All project content - real-time sync, offline collaboration | -| Project Metadata (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access | -| Project Members (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access | -| PDFs | R2 | Large binary files | +| Entity | Storage | Reason | +| ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Users | D1 (SQLite) | User accounts, authentication | +| Projects (metadata) | D1 (SQLite) | Basic project info (id, name, description, createdBy, timestamps) - source of truth for access control | +| Project Members (relationships) | D1 (SQLite) | Access control (who can access which projects) | +| Studies, Checklists, Answers | Durable Objects (Yjs Document) | All project content - real-time sync, offline collaboration | +| Project Metadata (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access | +| Project Members (synced) | Durable Objects (Yjs Document) | Synced copy from D1 for real-time access | +| PDFs | R2 | Large binary files | **Architecture Notes**: + - **D1** stores project metadata (name, description, etc.) and membership relationships. This is the source of truth for authorization and access control. - **Durable Objects** store the actual project content (studies, checklists, answers) in a Yjs Document, plus synced copies of metadata and members for real-time collaborative access. - When a project is created, it's written to D1 first, then metadata is synced to the Durable Object. diff --git a/docs/architecture/diagrams/05-frontend-routes.md b/docs/architecture/diagrams/05-frontend-routes.md index 2e1129756..284c0e73a 100644 --- a/docs/architecture/diagrams/05-frontend-routes.md +++ b/docs/architecture/diagrams/05-frontend-routes.md @@ -43,13 +43,13 @@ flowchart TD No authentication required. Redirects to dashboard if already logged in. -| Route | Component | Purpose | -| ----------------- | -------------- | ------------------------- | -| `/signin` | SignIn | Email/password login | -| `/signup` | SignUp | New account creation | -| `/check-email` | CheckEmail | Email verification prompt | -| `/complete-profile` | CompleteProfile | Initial profile setup | -| `/reset-password` | ResetPassword | Password recovery | +| Route | Component | Purpose | +| ------------------- | --------------- | ------------------------- | +| `/signin` | SignIn | Email/password login | +| `/signup` | SignUp | New account creation | +| `/check-email` | CheckEmail | Email verification prompt | +| `/complete-profile` | CompleteProfile | Initial profile setup | +| `/reset-password` | ResetPassword | Password recovery | ### Protected Routes (ProtectedGuard) @@ -64,16 +64,16 @@ Requires authentication. Redirects to signin if not logged in. ### Project Routes -| Route | Component | Purpose | -| ------------------------------ | --------------------- | ------------------------------ | -| `/projects/:projectId` | ProjectView | Project overview, studies list | -| `/.../checklists/:checklistId` | ChecklistYjsWrapper | Checklist assessment | +| Route | Component | Purpose | +| -------------------------------------------- | --------------------- | ------------------------------ | +| `/projects/:projectId` | ProjectView | Project overview, studies list | +| `/.../checklists/:checklistId` | ChecklistYjsWrapper | Checklist assessment | | `/.../reconcile/:checklist1Id/:checklist2Id` | ReconciliationWrapper | Compare two checklists | ### Local Routes Routes for local-only checklists (no authentication required, stored in IndexedDB). -| Route | Component | Purpose | -| ------------------------ | ------------------ | ---------------------------- | -| `/checklist/:checklistId` | LocalChecklistView | Local-only checklist editor | +| Route | Component | Purpose | +| ------------------------- | ------------------ | --------------------------- | +| `/checklist/:checklistId` | LocalChecklistView | Local-only checklist editor | diff --git a/docs/architecture/diagrams/06-api-routes.md b/docs/architecture/diagrams/06-api-routes.md index f19b665b0..0c7760d45 100644 --- a/docs/architecture/diagrams/06-api-routes.md +++ b/docs/architecture/diagrams/06-api-routes.md @@ -89,6 +89,7 @@ Handled by BetterAuth. Includes signin, signup, session management. ### Billing (`/api/billing/*`) Stripe integration for subscriptions and payments: + - `GET /api/billing/subscription` - Get user subscription - `POST /api/billing/checkout` - Create Stripe checkout session - `POST /api/billing/portal` - Create Stripe customer portal session @@ -113,6 +114,7 @@ Google Drive integration endpoints for importing documents. ### Database (`/api/db/*`) Development/diagnostic endpoints: + - `GET /api/db/users` - List users (development only) ### Durable Object Routes diff --git a/eslint.config.js b/eslint.config.js index 7e59694a3..3e8149a1a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,6 +30,7 @@ export default [ globals: { // Browser globals + performance: 'readonly', ReadableStream: 'readonly', HTMLDivElement: 'readonly', HTMLInputElement: 'readonly', diff --git a/package.json b/package.json index 5d666f69b..a746240b4 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "deps:update:latest": "pnpm -r up --latest", "user:make-admin:prod": "pnpm --filter @corates/workers run user:make-admin:prod", "user:make-admin:local": "pnpm --filter @corates/workers run user:make-admin:local", - "loc": "bash ./scripts/loc-report.sh", + "loc": "node ./scripts/loc-report.mjs", "docs": "npx serve ./docs/architecture -l 3020", "openapi": "pnpm --filter @corates/workers run openapi:generate" }, diff --git a/packages/ui/src/zag/Tooltip.jsx b/packages/ui/src/zag/Tooltip.jsx index 68fe6ae8d..3a26bcc8b 100644 --- a/packages/ui/src/zag/Tooltip.jsx +++ b/packages/ui/src/zag/Tooltip.jsx @@ -11,6 +11,23 @@ export function Tooltip(props) { placement: props.placement || 'top', gutter: 8, strategy: 'fixed', // Use fixed positioning to avoid stacking context issues + flip: true, + // shift: true, + boundary: () => { + // Get the main navbar element to use as a boundary + const navbar = document.querySelector('nav[class*="sticky"]'); + if (navbar) { + const navbarRect = navbar.getBoundingClientRect(); + const navbarBottom = navbarRect.bottom; + return { + x: 0, + y: navbarBottom, + width: window.innerWidth, + height: window.innerHeight - navbarBottom, + }; + } + return 'viewport'; + }, }, openDelay: props.openDelay ?? 100, closeDelay: props.closeDelay ?? 100, diff --git a/packages/web/.gitignore b/packages/web/.gitignore index cd541b256..4039865a4 100644 --- a/packages/web/.gitignore +++ b/packages/web/.gitignore @@ -11,6 +11,7 @@ # testing /coverage +bundle-analysis.html # production /build diff --git a/packages/web/package.json b/packages/web/package.json index a5377ba27..eef8993b2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,7 +12,8 @@ "deploy": "pnpm run build", "test:ui": "vitest --ui --ui.port=51234", "test": "vitest run --reporter=verbose", - "format": "prettier --write ." + "format": "prettier --write .", + "analyze": "vite build --mode analyze && source-map-explorer dist/assets/index-*.js --no-border-checks" }, "dependencies": { "@corates/shared": "workspace:*", @@ -36,7 +37,10 @@ "jsdom": "^27.3.0", "prettier": "^3.7.4", "puppeteer": "^24.34.0", + "rollup-plugin-visualizer": "^6.0.5", + "source-map-explorer": "^2.5.3", "tailwindcss": "^4.1.18", + "terser": "^5.44.1", "vite": "^7.3.0", "vite-plugin-solid": "^2.11.10", "vitest": "^4.0.16" diff --git a/packages/web/src/api/pdf-api.js b/packages/web/src/api/pdf-api.js index b69a64256..401e72bb8 100644 --- a/packages/web/src/api/pdf-api.js +++ b/packages/web/src/api/pdf-api.js @@ -40,25 +40,25 @@ export async function fetchPdfViaProxy(url) { export async function uploadPdf(projectId, studyId, file, fileName = null) { const url = `${API_BASE}/api/projects/${projectId}/studies/${studyId}/pdfs`; - let body; - let headers = {}; + // Always use FormData for consistency and better browser streaming support + // This works for both File objects and ArrayBuffers (by converting ArrayBuffer to Blob) + const formData = new FormData(); if (file instanceof File) { - const formData = new FormData(); formData.append('file', file); - body = formData; } else { - // ArrayBuffer - body = file; - headers['Content-Type'] = 'application/pdf'; - headers['X-File-Name'] = fileName || 'document.pdf'; + // Convert ArrayBuffer to Blob so we can use FormData + const blob = new Blob([file], { type: 'application/pdf' }); + const fileObj = new File([blob], fileName || 'document.pdf', { type: 'application/pdf' }); + formData.append('file', fileObj); } + // Don't set Content-Type - browser will set it automatically with boundary for multipart/form-data + // Don't set Content-Length - browser will calculate it automatically for FormData const response = await fetch(url, { method: 'POST', credentials: 'include', - headers, - body, + body: formData, }); if (!response.ok) { diff --git a/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx b/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx index 461e06569..72a0296bf 100644 --- a/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx +++ b/packages/web/src/components/checklist-ui/AMSTAR2Checklist.jsx @@ -2,7 +2,7 @@ import { createSignal, createEffect, Show, For } from 'solid-js'; import { AMSTAR_CHECKLIST } from '@/AMSTAR2/checklist-map.js'; import { createChecklist as createAMSTAR2Checklist } from '@/AMSTAR2/checklist.js'; import { FaSolidCircleInfo } from 'solid-icons/fa'; -import { Tooltip, FloatingPanel } from '@corates/ui'; +import { Tooltip } from '@corates/ui'; import NoteEditor from '@checklist-ui/common/NoteEditor.jsx'; export function Question1(props) { @@ -695,43 +695,13 @@ function StandardQuestion(props) { } function QuestionInfo(props) { - const [showInfo, setShowInfo] = createSignal(false); - const [panelPos, setPanelPos] = createSignal({ x: 0, y: 0 }); - let containerRef = () => props.containerRef; - - function openInfoPanel() { - if (containerRef()) { - const btnRect = containerRef().getBoundingClientRect(); - setPanelPos({ - x: btnRect.left + 20, - y: btnRect.top - 20, - }); - } - setShowInfo(true); - } - - let question = () => props.question.text.split('.')[0] + '. Learn more'; - return ( <> - setShowInfo(details.open)} - position={panelPos()} - onPositionChange={pos => setPanelPos(pos.position)} - showMaximize={false} - > - {props.question.info} -
- - +
diff --git a/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx b/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx index 3b146d3db..52e9e8947 100644 --- a/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx +++ b/packages/web/src/components/checklist-ui/compare/ChecklistReconciliation.jsx @@ -15,6 +15,7 @@ import { } from '@/AMSTAR2/checklist-compare.js'; import ReconciliationQuestionPage from './ReconciliationQuestionPage.jsx'; import SummaryView from './SummaryView.jsx'; +import { createChecklist } from '@/AMSTAR2/checklist.js'; export default function ChecklistReconciliation(props) { // props.checklist1 - First reviewer's checklist data @@ -285,7 +286,6 @@ export default function ChecklistReconciliation(props) { if (!props.updateChecklistAnswer) return; // Get default empty structure from createChecklist - const { createChecklist } = await import('@/AMSTAR2/checklist.js'); const defaultChecklist = createChecklist({ name: 'temp', id: 'temp', diff --git a/packages/web/src/stores/projectActionsStore/pdfs.js b/packages/web/src/stores/projectActionsStore/pdfs.js index 97547a493..31e1293de 100644 --- a/packages/web/src/stores/projectActionsStore/pdfs.js +++ b/packages/web/src/stores/projectActionsStore/pdfs.js @@ -188,6 +188,7 @@ export function createPdfActions(getActiveConnection, getActiveProjectId, getCur /** * Delete a PDF from a study (uses active project) + * Ensures cleanup from R2, IndexedDB, and Y.js with proper error handling */ async function deletePdfFromStudy(studyId, pdf) { const projectId = getActiveProjectId(); @@ -198,12 +199,43 @@ export function createPdfActions(getActiveConnection, getActiveProjectId, getCur throw new Error('Not connected to project'); } + let r2Deleted = false; + try { - await deletePdf(projectId, studyId, pdf.fileName); - ops.removePdfFromStudy(studyId, pdf.id); - removeCachedPdf(projectId, studyId, pdf.fileName).catch(err => - console.warn('Failed to remove PDF from cache:', err), - ); + // Step 1: Delete from R2 storage first + try { + await deletePdf(projectId, studyId, pdf.fileName); + r2Deleted = true; + } catch (r2Err) { + console.error('Failed to delete PDF from R2:', r2Err); + // Still attempt IndexedDB cleanup even if R2 deletion fails + } + + // Step 2: Always attempt IndexedDB cleanup, even if previous step failed + try { + await removeCachedPdf(projectId, studyId, pdf.fileName); + } catch (cacheErr) { + console.warn('Failed to remove PDF from IndexedDB cache:', cacheErr); + // Don't throw - cache cleanup failure shouldn't block the operation + } + + // Step 3: Remove from Y.js only if R2 deletion succeeded + // This prevents inconsistencies where PDF exists in R2 but not in Y.js + if (r2Deleted) { + try { + ops.removePdfFromStudy(studyId, pdf.id); + } catch (yjsErr) { + console.error('Failed to remove PDF from Y.js:', yjsErr); + // R2 deletion succeeded but Y.js removal failed - log warning + // The PDF will remain in Y.js but is deleted from R2 + throw new Error('PDF deleted from R2 but failed to remove from study'); + } + } + + // If R2 deletion failed, throw to indicate the operation didn't fully succeed + if (!r2Deleted) { + throw new Error('Failed to delete PDF from R2 storage'); + } } catch (err) { console.error('Error deleting PDF:', err); throw err; diff --git a/packages/web/src/stores/projectActionsStore/studies.js b/packages/web/src/stores/projectActionsStore/studies.js index c07c5b095..367e4394d 100644 --- a/packages/web/src/stores/projectActionsStore/studies.js +++ b/packages/web/src/stores/projectActionsStore/studies.js @@ -3,11 +3,12 @@ */ import { uploadPdf, fetchPdfViaProxy, downloadPdf, deletePdf } from '@api/pdf-api.js'; -import { cachePdf } from '@primitives/pdfCache.js'; +import { cachePdf, clearStudyCache } from '@primitives/pdfCache.js'; import { showToast } from '@corates/ui'; import { importFromGoogleDrive } from '@api/google-drive.js'; import { extractPdfDoi, extractPdfTitle } from '@/lib/pdfUtils.js'; import { fetchFromDOI } from '@/lib/referenceLookup.js'; +import projectStore from '../projectStore.js'; // ============================================================================ // Helpers @@ -256,15 +257,41 @@ export function createStudyActions(getActiveConnection, getActiveProjectId, getC /** * Delete a study (low-level, no confirmation) (uses active project) + * Cleans up all associated PDFs from R2 and IndexedDB before deleting from Y.js */ - function deleteStudy(studyId) { + async function deleteStudy(studyId) { + const projectId = getActiveProjectId(); const ops = getActiveConnection(); if (!ops?.deleteStudy) { console.error('No connection for active project'); showToast.error('Delete Failed', 'Not connected to project'); return; } + try { + // Get study data to access PDFs before deletion + const study = projectStore.getStudy(projectId, studyId); + const pdfs = study?.pdfs || []; + + // Delete each PDF from R2 storage + if (pdfs.length > 0) { + const deletePromises = pdfs.map(pdf => { + if (!pdf?.fileName) return Promise.resolve(); + return deletePdf(projectId, studyId, pdf.fileName).catch(err => { + // Log but don't block - continue with other PDFs + console.warn(`Failed to delete PDF ${pdf.fileName} from R2:`, err); + }); + }); + await Promise.all(deletePromises); + } + + // Clear all PDFs for this study from IndexedDB + await clearStudyCache(projectId, studyId).catch(err => { + // Log but don't block - continue with Y.js deletion + console.warn('Failed to clear study PDF cache from IndexedDB:', err); + }); + + // Finally, delete the study from Y.js ops.deleteStudy(studyId); } catch (err) { console.error('Error deleting study:', err); diff --git a/packages/web/vite.config.js b/packages/web/vite.config.js index 68c9885b8..70b2e9ed5 100644 --- a/packages/web/vite.config.js +++ b/packages/web/vite.config.js @@ -3,7 +3,7 @@ import solidPlugin from 'vite-plugin-solid'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; -export default defineConfig({ +export default defineConfig(({ mode }) => ({ base: process.env.VITE_BASEPATH || '/', resolve: { alias: { @@ -27,6 +27,13 @@ export default defineConfig({ plugins: [solidPlugin(), tailwindcss()], build: { target: ['es2020', 'safari14'], + minify: mode === 'analyze' ? 'terser' : 'esbuild', + sourcemap: mode === 'analyze', + terserOptions: { + format: { + comments: false, + }, + }, }, test: { environment: 'jsdom', @@ -34,4 +41,4 @@ export default defineConfig({ setupFiles: ['./src/__tests__/setup.js'], include: ['src/**/*.{test,spec}.{js,jsx}'], }, -}); +})); diff --git a/packages/workers/package.json b/packages/workers/package.json index 568e17e05..0ec5d1477 100644 --- a/packages/workers/package.json +++ b/packages/workers/package.json @@ -7,7 +7,7 @@ "license": "PolyForm-Noncommercial-1.0.0", "scripts": { "dev": "wrangler dev", - "deploy": "wrangler deploy --env production", + "deploy": "pnpm --filter shared build && wrangler deploy --env production", "tail": "wrangler tail", "test": "pnpm db:generate:test && vitest run", "test:watch": "vitest", @@ -19,7 +19,7 @@ "db:migrate": "y | wrangler d1 migrations apply corates-db --local", "db:migrate:prod": "wrangler d1 migrations apply corates-db --remote", "db:setup:prod": "wrangler d1 execute corates-db-prod --remote --file=migrations/0001_init.sql", - "db:reset:prod": "./scripts/reset-db-prod.sh", + "db:reset:prod": "node ./scripts/reset-db-prod.mjs", "user:make-admin:local": "node ./scripts/make-admin-local.mjs", "user:make-admin:prod": "node ./scripts/make-admin-prod.mjs", "openapi:generate": "node ./scripts/generate-openapi.mjs" diff --git a/packages/workers/scripts/reset-db-prod.mjs b/packages/workers/scripts/reset-db-prod.mjs new file mode 100644 index 000000000..ce8530906 --- /dev/null +++ b/packages/workers/scripts/reset-db-prod.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/* global console, process */ +/** + * Reset production D1 database, clear R2 bucket, and redeploy workers + * + * Usage: node scripts/reset-db-prod.mjs + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +const BUCKET_NAME = 'corates-pdfs-prod'; +const DB_NAME = 'corates-db-prod'; + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + cwd: ROOT, + ...options, + }); + + if (result.status !== 0 && !options.allowFailure) { + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + const message = stderr || stdout || `${command} exited with code ${result.status}`; + throw new Error(message); + } + + return { + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + status: result.status, + }; +} + +function runWranglerD1Execute(command, options = {}) { + const args = [ + 'wrangler', + 'd1', + 'execute', + DB_NAME, + '--remote', + '--env', + 'production', + '--yes', + '--command', + command, + ]; + + return runCommand('pnpm', args, options); +} + +function runWranglerD1ExecuteFile(filePath) { + const args = [ + 'wrangler', + 'd1', + 'execute', + DB_NAME, + '--remote', + '--env', + 'production', + '--yes', + '--file', + filePath, + ]; + + return runCommand('pnpm', args); +} + +function deleteR2Bucket() { + const args = ['wrangler', 'r2', 'bucket', 'delete', BUCKET_NAME, '--env', 'production']; + + return runCommand('pnpm', args, { allowFailure: true }); +} + +function createR2Bucket() { + const args = ['wrangler', 'r2', 'bucket', 'create', BUCKET_NAME, '--env', 'production']; + + return runCommand('pnpm', args, { allowFailure: true }); +} + +function deployWorkers() { + const args = ['wrangler', 'deploy', '--env', 'production']; + return runCommand('pnpm', args); +} + +async function main() { + console.log('=========================================='); + console.log(' Resetting Production D1 Database & R2'); + console.log('=========================================='); + console.log(''); + + try { + // Step 1: Drop existing tables + console.log(''); + console.log('Step 1: Dropping existing tables...'); + const tables = [ + 'mediaFiles', + 'project_members', + 'projects', + 'verification', + 'account', + 'session', + 'user', + ]; + + for (const table of tables) { + console.log(` Dropping table: ${table}`); + runWranglerD1Execute(`DROP TABLE IF EXISTS ${table};`); + } + + // Step 2: Delete and recreate R2 bucket + // console.log(''); + // console.log('Step 2: Clearing R2 bucket...'); + // console.log(` Deleting bucket: ${BUCKET_NAME}`); + // const deleteResult = deleteR2Bucket(); + // if (deleteResult.status !== 0) { + // console.log(` Note: ${deleteResult.stderr || 'Bucket may not exist or already deleted'}`); + // } else { + // console.log(' Bucket deleted'); + // } + + // console.log(` Creating bucket: ${BUCKET_NAME}`); + // const createResult = createR2Bucket(); + // if (createResult.status !== 0) { + // console.log( + // ` Warning: Failed to create bucket: ${createResult.stderr || createResult.stdout}`, + // ); + // throw new Error('Failed to recreate R2 bucket'); + // } else { + // console.log(' Bucket created'); + // } + + // Step 3: Run migration + console.log(''); + console.log('Step 3: Running migration...'); + const migrationPath = join(ROOT, 'migrations', '0001_init.sql'); + runWranglerD1ExecuteFile(migrationPath); + console.log(' Migration completed'); + + // Step 4: Deploy workers + console.log(''); + console.log('Step 4: Deploying workers...'); + deployWorkers(); + console.log(' Workers deployed'); + + console.log(''); + console.log('=========================================='); + console.log(' Database reset, R2 cleared, and workers deployed!'); + console.log('=========================================='); + } catch (err) { + console.error(''); + console.error('Error:', err.message || err); + process.exit(1); + } +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/packages/workers/scripts/reset-db-prod.sh b/packages/workers/scripts/reset-db-prod.sh deleted file mode 100755 index f8c854bdc..000000000 --- a/packages/workers/scripts/reset-db-prod.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -# Reset production D1 database and redeploy workers -# Usage: ./scripts/reset-db-prod.sh - -set -e - -echo "==========================================" -echo " Resetting Production D1 Database" -echo "==========================================" -echo "" - -cd "$(dirname "$0")/.." - -echo "" -echo "Step 1: Dropping existing tables..." -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS mediaFiles;" -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS project_members;" -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS projects;" -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS verification;" -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS account;" -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS session;" -npx wrangler d1 execute corates-db-prod --remote --yes --command "DROP TABLE IF EXISTS user;" - -echo "" -echo "Step 2: Running migration..." -npx wrangler d1 execute corates-db-prod --remote --yes --file=migrations/0001_init.sql - -echo "" -echo "Step 3: Deploying workers..." -npx wrangler deploy --env production - -echo "" -echo "==========================================" -echo " Database reset and workers deployed!" -echo "==========================================" diff --git a/packages/workers/src/middleware/cors.js b/packages/workers/src/middleware/cors.js index ed7c8b176..d3453eb63 100644 --- a/packages/workers/src/middleware/cors.js +++ b/packages/workers/src/middleware/cors.js @@ -11,7 +11,7 @@ import { isOriginAllowed, STATIC_ORIGINS } from '../config/origins.js'; * @returns {Function} Hono middleware */ export function createCorsMiddleware(env) { - return cors({ + const corsHandler = cors({ origin: origin => { if (isOriginAllowed(origin, env)) { return origin; @@ -22,6 +22,7 @@ export function createCorsMiddleware(env) { allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: [ 'Content-Type', + 'Content-Length', 'Authorization', 'X-File-Name', 'X-Requested-With', @@ -32,4 +33,15 @@ export function createCorsMiddleware(env) { credentials: true, maxAge: 86400, // Cache preflight for 24 hours }); + + // Wrap CORS handler to skip WebSocket upgrade requests + return async (c, next) => { + // Skip CORS for WebSocket upgrade requests - they need special handling + // Use raw headers to match how Durable Objects check for upgrades + const upgradeHeader = c.req.raw.headers.get('Upgrade'); + if (upgradeHeader === 'websocket') { + return next(); + } + return corsHandler(c, next); + }; } diff --git a/packages/workers/src/middleware/securityHeaders.js b/packages/workers/src/middleware/securityHeaders.js index eae2b1168..bc576d8e5 100644 --- a/packages/workers/src/middleware/securityHeaders.js +++ b/packages/workers/src/middleware/securityHeaders.js @@ -11,6 +11,12 @@ export function securityHeaders() { return async (c, next) => { await next(); + // Skip security headers for WebSocket upgrade responses (status 101) + // WebSocket upgrades require special handling and cannot have standard HTTP headers + if (c.res.status === 101) { + return; + } + // Enforce HTTPS for future requests (only meaningful over HTTPS) try { const url = new URL(c.req.url); diff --git a/packages/workers/src/routes/pdfs.js b/packages/workers/src/routes/pdfs.js index 722fb34f4..42f2d7eff 100644 --- a/packages/workers/src/routes/pdfs.js +++ b/packages/workers/src/routes/pdfs.js @@ -212,19 +212,11 @@ pdfRoutes.post('/', async c => { return c.json(error, error.statusCode); } - // Check if file with same name already exists - const key = `projects/${projectId}/studies/${studyId}/${fileName}`; - const existing = await c.env.PDF_BUCKET.head(key); - if (existing) { - const error = createDomainError( - FILE_ERRORS.ALREADY_EXISTS, - { fileName, key }, - `File "${fileName}" already exists. Rename or remove the existing copy.`, - ); - return c.json(error, error.statusCode); - } - // Store in R2 + // Note: Frontend already checks for duplicate filenames before uploading, + // so we skip the R2 head() call to reduce latency. Race conditions are + // unlikely and would just result in an overwrite. + const key = `projects/${projectId}/studies/${studyId}/${fileName}`; await c.env.PDF_BUCKET.put(key, pdfData, { httpMetadata: { diff --git a/packages/workers/wrangler.jsonc b/packages/workers/wrangler.jsonc index 54ce20590..67274dbe3 100644 --- a/packages/workers/wrangler.jsonc +++ b/packages/workers/wrangler.jsonc @@ -85,6 +85,16 @@ "zone_name": "corates.org" } ], + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "persist": true, + "invocation_logs": true + } + }, "vars": { "ENVIRONMENT": "production", "AUTH_BASE_URL": "https://api.corates.org", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91c893205..0c5905e8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,9 +276,18 @@ importers: puppeteer: specifier: ^24.34.0 version: 24.34.0(typescript@5.9.3) + rollup-plugin-visualizer: + specifier: ^6.0.5 + version: 6.0.5(rolldown@1.0.0-beta.51)(rollup@4.53.5) + source-map-explorer: + specifier: ^2.5.3 + version: 2.5.3 tailwindcss: specifier: ^4.1.18 version: 4.1.18 + terser: + specifier: ^5.44.1 + version: 5.44.1 vite: specifier: ^7.3.0 version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) @@ -4533,6 +4542,14 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + btoa@1.2.1: + resolution: + { + integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==, + } + engines: { node: '>= 0.4.0' } + hasBin: true + buffer-crc32@0.2.13: resolution: { @@ -4759,6 +4776,12 @@ packages: } engines: { node: '>=18' } + cliui@7.0.4: + resolution: + { + integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==, + } + cliui@8.0.1: resolution: { @@ -4898,6 +4921,12 @@ packages: } engines: { node: '>= 0.6' } + convert-source-map@1.9.0: + resolution: + { + integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==, + } + convert-source-map@2.0.0: resolution: { @@ -5618,6 +5647,14 @@ packages: integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, } + ejs@3.1.10: + resolution: + { + integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==, + } + engines: { node: '>=0.10.0' } + hasBin: true + electron-to-chromium@1.5.267: resolution: { @@ -6133,6 +6170,12 @@ packages: integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==, } + filelist@1.0.4: + resolution: + { + integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==, + } + fill-range@7.1.1: resolution: { @@ -6221,6 +6264,12 @@ packages: } engines: { node: '>= 0.8' } + fs.realpath@1.0.0: + resolution: + { + integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, + } + fsevents@2.3.3: resolution: { @@ -6351,6 +6400,13 @@ packages: } hasBin: true + glob@7.2.3: + resolution: + { + integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, + } + deprecated: Glob versions prior to v9 are no longer supported + globals@14.0.0: resolution: { @@ -6385,6 +6441,13 @@ packages: integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, } + gzip-size@6.0.0: + resolution: + { + integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==, + } + engines: { node: '>=10' } + gzip-size@7.0.0: resolution: { @@ -6599,6 +6662,13 @@ packages: } engines: { node: '>=12' } + inflight@1.0.6: + resolution: + { + integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==, + } + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: { @@ -6827,6 +6897,14 @@ packages: integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, } + jake@10.9.4: + resolution: + { + integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==, + } + engines: { node: '>=10' } + hasBin: true + jiti@1.21.7: resolution: { @@ -7422,6 +7500,12 @@ packages: } engines: { node: '>=16 || 14 >=14.17' } + minimist@1.2.8: + resolution: + { + integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, + } + minipass@7.1.2: resolution: { @@ -7442,6 +7526,13 @@ packages: integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==, } + mkdirp@0.5.6: + resolution: + { + integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==, + } + hasBin: true + mlly@1.8.0: resolution: { @@ -7674,6 +7765,13 @@ packages: integrity: sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==, } + open@7.4.2: + resolution: + { + integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==, + } + engines: { node: '>=8' } + open@8.4.2: resolution: { @@ -7762,6 +7860,13 @@ packages: } engines: { node: '>=8' } + path-is-absolute@1.0.1: + resolution: + { + integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, + } + engines: { node: '>=0.10.0' } + path-key@3.1.1: resolution: { @@ -8297,6 +8402,14 @@ packages: } engines: { iojs: '>=1.0.0', node: '>=0.10.0' } + rimraf@2.6.3: + resolution: + { + integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==, + } + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + robust-predicates@3.0.2: resolution: { @@ -8670,6 +8783,14 @@ packages: peerDependencies: solid-js: ^1.7 + source-map-explorer@2.5.3: + resolution: + { + integrity: sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==, + } + engines: { node: '>=12' } + hasBin: true + source-map-js@1.2.1: resolution: { @@ -8938,6 +9059,13 @@ packages: } engines: { node: '>=18' } + temp@0.9.4: + resolution: + { + integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==, + } + engines: { node: '>=6.0.0' } + terracotta@1.0.6: resolution: { @@ -9941,6 +10069,13 @@ packages: engines: { node: '>= 14.6' } hasBin: true + yargs-parser@20.2.9: + resolution: + { + integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==, + } + engines: { node: '>=10' } + yargs-parser@21.1.1: resolution: { @@ -9948,6 +10083,13 @@ packages: } engines: { node: '>=12' } + yargs@16.2.0: + resolution: + { + integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==, + } + engines: { node: '>=10' } + yargs@17.7.2: resolution: { @@ -12543,6 +12685,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + btoa@1.2.1: {} + buffer-crc32@0.2.13: {} buffer-crc32@1.0.0: {} @@ -12658,6 +12802,12 @@ snapshots: is-wsl: 3.1.0 is64bit: 2.0.0 + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -12727,6 +12877,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -13065,6 +13217,10 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.267: {} emoji-regex-xs@1.0.0: {} @@ -13534,6 +13690,10 @@ snapshots: file-uri-to-path@1.0.0: {} + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -13584,6 +13744,8 @@ snapshots: fresh@2.0.0: {} + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true @@ -13674,6 +13836,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + globals@14.0.0: {} globals@16.5.0: {} @@ -13691,6 +13862,10 @@ snapshots: graceful-fs@4.2.11: {} + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + gzip-size@7.0.0: dependencies: duplexer: 0.1.2 @@ -13826,6 +14001,11 @@ snapshots: indent-string@5.0.0: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} inline-style-parser@0.2.7: {} @@ -13928,6 +14108,12 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.4 + picocolors: 1.1.1 + jiti@1.21.7: {} jiti@2.6.1: {} @@ -14283,6 +14469,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} minizlib@3.1.0: @@ -14291,6 +14479,10 @@ snapshots: mitt@3.0.1: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -14488,6 +14680,11 @@ snapshots: regex: 5.1.1 regex-recursion: 5.1.1 + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -14554,6 +14751,8 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -14831,6 +15030,10 @@ snapshots: reusify@1.1.0: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + robust-predicates@3.0.2: {} rolldown@1.0.0-beta.51: @@ -15145,6 +15348,21 @@ snapshots: dependencies: solid-js: 1.9.10 + source-map-explorer@2.5.3: + dependencies: + btoa: 1.2.1 + chalk: 4.1.2 + convert-source-map: 1.9.0 + ejs: 3.1.10 + escape-html: 1.0.3 + glob: 7.2.3 + gzip-size: 6.0.0 + lodash: 4.17.21 + open: 7.4.2 + source-map: 0.7.6 + temp: 0.9.4 + yargs: 16.2.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -15295,6 +15513,11 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + terracotta@1.0.6(solid-js@1.9.10): dependencies: solid-js: 1.9.10 @@ -16055,8 +16278,20 @@ snapshots: yaml@2.8.2: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/scripts/loc-report.mjs b/scripts/loc-report.mjs new file mode 100644 index 000000000..1647b51ee --- /dev/null +++ b/scripts/loc-report.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/* global console, process */ +/** + * LOC (Lines of Code) report script + * + * Usage: + * node scripts/loc-report.mjs # total + per package in packages/ + * node scripts/loc-report.mjs packages # only packages/* breakdown + * node scripts/loc-report.mjs web # only the "web" top-level dir + */ + +import { spawnSync } from 'node:child_process'; +import { writeFileSync, unlinkSync, mkdtempSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +function checkCommand(command) { + const result = spawnSync('which', [command], { encoding: 'utf8' }); + if (result.status !== 0) { + if (command === 'cloc') { + console.error(`cloc not found in PATH. Install it: https://github.com/AlDanial/cloc`); + } else { + console.error(`${command} not found in PATH`); + } + process.exit(2); + } +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + cwd: ROOT, + ...options, + }); + + if (result.status !== 0 && !options.allowFailure) { + const stderr = (result.stderr || '').trim(); + const stdout = (result.stdout || '').trim(); + const message = stderr || stdout || `${command} exited with code ${result.status}`; + throw new Error(message); + } + + return { + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + status: result.status, + }; +} + +function getTrackedFiles() { + // Get tracked files, excluding lock files + const result = runCommand('git', ['ls-files', '-z']); + const files = result.stdout + .split('\0') + .filter(file => { + // Exclude lock files + return !/^(.+\/)?(pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$/.test(file); + }) + .filter(Boolean); + + return files; +} + +function printHeader(title) { + console.log(''); + console.log('==========================================='); + console.log(title); + console.log('==========================================='); +} + +function runCloc(fileList) { + if (fileList.length === 0) { + return; + } + + // Create temporary file with file list + const tmpDir = mkdtempSync(join(tmpdir(), 'loc-report-')); + const tmpFile = join(tmpDir, 'filelist.txt'); + writeFileSync(tmpFile, fileList.join('\n')); + + try { + const result = runCommand('cloc', ['--list-file=' + tmpFile, '--quiet'], { + allowFailure: true, + }); + if (result.status === 0) { + console.log(result.stdout); + } + } finally { + try { + unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + } +} + +function runSubset(label, fileList) { + if (fileList.length === 0) { + return; + } + console.log(`\n--- ${label} (${fileList.length} files) ---`); + runCloc(fileList); +} + +function main() { + // Check required commands + checkCommand('git'); + checkCommand('cloc'); + + const args = process.argv.slice(2); + const filter = args[0]; // Optional filter: 'packages' or specific dir + + // Get tracked files + let trackedFiles = getTrackedFiles(); + + if (trackedFiles.length === 0) { + console.error('No tracked files found.'); + process.exit(1); + } + + // Apply filter if specified + if (filter) { + if (filter === 'packages') { + trackedFiles = trackedFiles.filter(file => file.startsWith('packages/')); + } else { + trackedFiles = trackedFiles.filter(file => file.startsWith(`${filter}/`)); + } + } + + // Total (tracked files) + printHeader('Total (Git-tracked files)'); + runCloc(trackedFiles); + + // Per-package breakdown (packages/*) + const packageFiles = trackedFiles.filter(file => file.startsWith('packages/')); + if (packageFiles.length > 0) { + printHeader('Per package (packages/*)'); + + // Group files by package + const packages = new Map(); + for (const file of packageFiles) { + const parts = file.split('/'); + if (parts.length >= 2 && parts[0] === 'packages') { + const pkg = parts[1]; + if (!packages.has(pkg)) { + packages.set(pkg, []); + } + packages.get(pkg).push(file); + } + } + + // Sort packages and run cloc for each + const sortedPackages = Array.from(packages.keys()).sort(); + for (const pkg of sortedPackages) { + const pkgFiles = packages.get(pkg); + runSubset(`packages/${pkg}`, pkgFiles); + } + } else { + console.log(''); + console.log('No packages/ directory detected or no tracked files under packages/.'); + } +} + +try { + main(); +} catch (err) { + console.error('Error:', err.message || err); + process.exit(1); +} diff --git a/scripts/loc-report.sh b/scripts/loc-report.sh deleted file mode 100644 index ac33a197e..000000000 --- a/scripts/loc-report.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# LOC report script -# Usage: -# ./scripts/loc-report.sh # total + per top-level + per package in packages/ -# ./scripts/loc-report.sh packages # only packages/* breakdown -# ./scripts/loc-report.sh web # only the "web" top-level dir - -command -v git >/dev/null 2>&1 || { echo "git not found in PATH" >&2; exit 2; } -command -v cloc >/dev/null 2>&1 || { echo "cloc not found in PATH. Install it: https://github.com/AlDanial/cloc" >&2; exit 2; } - -TMP_TRACKED=$(mktemp) -trap 'rm -f "$TMP_TRACKED"' EXIT - -# Gather tracked files (one per line), excluding common lock files -# (pnpm-lock.yaml, package-lock.json, yarn.lock) which are tracked but not source -git ls-files -z | tr '\0' '\n' | grep -vE '(^|/)(pnpm-lock.yaml|package-lock.json|yarn.lock)$' > "$TMP_TRACKED" - -if [ ! -s "$TMP_TRACKED" ]; then - echo "No tracked files found." >&2 - exit 1 -fi - -print_header() { - echo - echo "===========================================" - echo "$1" - echo "===========================================" -} - -# Total (tracked files) -print_header "Total (Git-tracked files)" -cloc --list-file="$TMP_TRACKED" --quiet || true - -# Helper to run cloc on a subset list -run_subset() { - local label="$1" filelist="$2" - if [ ! -s "$filelist" ]; then - return - fi - printf "\n--- %s (%s files) ---\n" "$label" "$(wc -l < "$filelist")" - cloc --list-file="$filelist" --quiet || true -} - -# Only show per-package breakdown (packages/*) — avoid per-top-level dirs like docs -if grep -q '^packages/' "$TMP_TRACKED"; then - print_header "Per package (packages/*)" - packages=$(awk -F/ '$1=="packages"{print $2}' "$TMP_TRACKED" | sort -u) - for p in $packages; do - subset=$(mktemp) - grep -E "^packages/$p/" "$TMP_TRACKED" > "$subset" - if [ -s "$subset" ]; then - run_subset "packages/$p" "$subset" - fi - rm -f "$subset" - done -else - echo - echo "No packages/ directory detected or no tracked files under packages/." -fi - -exit 0