diff --git a/.dockerignore b/.dockerignore index 4239f361a..daa14cbc6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -49,6 +49,7 @@ public/sw-version.js # Editor / agent worktrees + caches. .claude +.worktrees .cursor .aider* .vscode diff --git a/CHANGELOG.md b/CHANGELOG.md index 815f047f6..8d7933d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - **Concurrent iOS registrations no longer fail intermittently.** Device and APNs notification-channel reconciliation now share one database lock and transaction, so simultaneous first registration remains idempotent. +- **The production image no longer ships build-time package managers.** pnpm 11 + verifies the lockfile under explicit build-script policy, npm and Corepack + are removed after migrations are prepared, and linked worktrees stay out of + local Docker build contexts. No migrations. No breaking changes. diff --git a/Dockerfile b/Dockerfile index 540a6b0f8..7a4b7312d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # ── Stage 1: Dependencies ────────────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps -RUN corepack enable && corepack prepare pnpm@10.31.0 --activate +RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ @@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile --prod=false # ── Stage 2: Build ───────────────────────────────────────── FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder -RUN corepack enable && corepack prepare pnpm@10.31.0 --activate +RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app # v1.4.43 B11 — version build-arg threaded into the layer cache key. @@ -50,8 +50,7 @@ FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb # `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware # timestamp formatting) resolve to the actual offset instead of silently # falling back to UTC on Alpine images that ship without it. -RUN apk add --no-cache tzdata && \ - corepack enable && corepack prepare pnpm@10.31.0 --activate +RUN apk add --no-cache tzdata WORKDIR /app ENV NODE_ENV=production @@ -127,11 +126,16 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/src/generated ./src/generated -# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree) +# Install the migration CLI plus a pinned launcher for the maintenance scripts +# already shipped in the standalone tree. Then strip npm/Corepack and caches: +# package managers are build-time tools, not runtime surface. RUN mkdir -p /opt/prisma-cli && \ cd /opt/prisma-cli && \ npm init -y && \ - npm install --omit=dev prisma@7.8 @prisma/engines@7.8 + npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 && \ + ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \ + rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \ + rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx # v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN # lookups. The MMDB files live in `/opt/geolite2/` and are read by diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index e96ce18a2..1d318164e 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -84,9 +84,9 @@ pnpm dlx tsx scripts/restore-backup.ts \ /tmp/restored.json ``` -The production standalone image strips `tsx`, so a bare -`pnpm tsx scripts/...` fails inside the container — always invoke -one-shot scripts via `pnpm dlx tsx`. +Run this command from a source checkout with the production backup variables +exported. The script imports the full application dependency graph, which the +minimal production image does not expose as an operator scripting environment. The script downloads the object, decrypts it, and writes the JSON dump to disk. Importing the JSON back into a HealthLog instance is left to diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index ef77cb16c..c1a16cdc8 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -46,24 +46,23 @@ metadata (`medication_schedules`): Always. The default mode only reports and never mutates: ```bash -# inside the app container (or any checkout) — DATABASE_URL must point -# at the target database -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts +# inside the production app container — DATABASE_URL is inherited +healthlog-tsx scripts/repair-intake-anomalies.ts # scoped to one account -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user +healthlog-tsx scripts/repair-intake-anomalies.ts --user + +# source-checkout equivalent +pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts ``` The script is self-contained: its only import is `pg` and it reads `DATABASE_URL` straight from the environment (no dotenv, no `@/` path -alias, no `src/` imports). That matters in the production container — -the standalone image ships no project `node_modules`, so the previous -revision died there with `Cannot find module 'dotenv/config'`. Inside -the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a -checkout it resolves from the project `node_modules`; the -`--package pg --package tsx` pins cover any environment with neither. -Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips -`tsx`. +alias, no `src/` imports). The production image provides the pinned +`healthlog-tsx` launcher and resolves `pg` from its standalone runtime tree +without retaining a package manager. In a source checkout, `pg` resolves from +the project `node_modules`; `pnpm dlx --package pg --package tsx ...` also +works in an environment where neither dependency is installed. The dry-run prints every duplicate group (which row would be kept, which would be tombstoned), a table of implausible rows (id, medication, @@ -74,7 +73,7 @@ would write), and a table of stale-anchor pending rows. ## Applying the fix ```bash -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix +healthlog-tsx scripts/repair-intake-anomalies.ts --fix ``` `--fix` does five things: @@ -92,7 +91,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix in explicitly: ```bash - pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible + healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible ``` `--tombstone-implausible` is refused without `--fix`. @@ -139,7 +138,7 @@ slot anchors: The default run only **reports** the proposals. Apply them explicitly: ```bash -pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras +healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras ``` Review the dry-run table first — the inference is a heuristic. A wrongly diff --git a/eslint.config.mjs b/eslint.config.mjs index ec45d0f14..ad595ad42 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,10 +13,11 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", - // v1.4.41 — ignore local tool worktrees so a worktree on an older - // commit can't poison `pnpm lint` locally. - // CI never sees this path; the rule is for the dev environment. + // v1.31.4 — ignore local tool worktrees so a worktree on another + // commit cannot poison `pnpm lint` locally. + // CI never sees these paths; the rules protect the dev environment. ".claude/**", + ".worktrees/**", // The project-local ESLint rule plugin is CommonJS (require/module // .exports) by ESLint convention; it is not application source and // is not linted by the app's TypeScript ruleset. diff --git a/package.json b/package.json index 38b3eb619..98ee214aa 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "passkey" ], "private": true, - "packageManager": "pnpm@10.31.0", + "packageManager": "pnpm@11.15.1", "scripts": { "prepare": "node scripts/prepare.mjs", "dev": "next dev", @@ -135,18 +135,5 @@ "tw-animate-css": "^1.4.0", "typescript": "^6", "vitest": "^4.1.5" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "prisma", - "@prisma/engines" - ], - "overrides": { - "dompurify@<=3.4.10": "^3.4.11", - "postcss@<8.5.10": "^8.5.10", - "@hono/node-server@<1.19.13": "^1.19.13", - "hono@<4.12.25": "^4.12.25", - "picomatch@<4.0.4": "^4.0.4" - } } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 581a9d5b5..737d7e764 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,20 @@ -ignoredBuiltDependencies: - - sharp - - unrs-resolver +overrides: + "dompurify@<=3.4.10": "^3.4.11" + "postcss@<8.5.10": "^8.5.10" + "@hono/node-server@<1.19.13": "^1.19.13" + "hono@<4.12.25": "^4.12.25" + "picomatch@<4.0.4": "^4.0.4" + +allowBuilds: + "@prisma/engines": true + prisma: true + core-js: false + cpu-features: false + esbuild: false + lefthook: false + msw: false + protobufjs: false + sharp: false + ssh2: false + tesseract.js: false + unrs-resolver: false diff --git a/scripts/README.md b/scripts/README.md index 7834722dc..c4280328d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,11 +4,17 @@ One-shot operator and build scripts. These are not part of the running applicati ## How to run them +From a source checkout: + ```bash pnpm dlx tsx scripts/.ts ``` -Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck. +The production image intentionally removes npm, pnpm, and Corepack. Only +container-safe scripts named by an operator runbook use its pinned +`healthlog-tsx` launcher; scripts that import the full application dependency +graph must run from a source checkout. `scripts/` is excluded from the project +typecheck. ## What lives here diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index 5ed47544f..f826b8f60 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { parse } from "yaml"; import integrationConfig from "../../vitest.integration.config.mts"; +import unitConfig from "../../vitest.config.mts"; function repositoryRoot(): string { const testPath = expect.getState().testPath; @@ -75,6 +76,12 @@ describe("Playwright CI determinism", () => { }); }); +describe("local worktree isolation", () => { + it("keeps nested worktree suites out of the root unit run", () => { + expect(unitConfig.test?.exclude ?? []).toContain(".worktrees/**"); + }); +}); + describe("integration environment isolation", () => { it("registers a worker setup bridge and authoritative test secrets", () => { const test = integrationConfig.test; @@ -114,11 +121,11 @@ describe("container dependency installation", () => { describe("production dependency advisory floors", () => { it("pins vulnerable transitive ranges to compatible patched versions", () => { - const packageJson = JSON.parse(readRepoFile("package.json")) as { - pnpm?: { overrides?: Record }; + const workspace = parse(readRepoFile("pnpm-workspace.yaml")) as { + overrides?: Record; }; - expect(packageJson.pnpm?.overrides).toMatchObject({ + expect(workspace.overrides).toMatchObject({ "dompurify@<=3.4.10": "^3.4.11", "postcss@<8.5.10": "^8.5.10", "@hono/node-server@<1.19.13": "^1.19.13", diff --git a/scripts/__tests__/release-workflows.test.ts b/scripts/__tests__/release-workflows.test.ts index 844573aca..78c0a3354 100644 --- a/scripts/__tests__/release-workflows.test.ts +++ b/scripts/__tests__/release-workflows.test.ts @@ -232,16 +232,28 @@ describe("release container inputs", () => { ); }); - it("uses the packageManager pnpm version instead of a moving tag", () => { + it("pins pnpm for builds and removes package managers from runtime", () => { const dockerfile = readRepoFile("Dockerfile"); const packageJson = JSON.parse(readRepoFile("package.json")) as { packageManager: string; }; - expect(packageJson.packageManager).toBe("pnpm@10.31.0"); + expect(packageJson.packageManager).toBe("pnpm@11.15.1"); expect(dockerfile).not.toContain("pnpm@latest"); expect( - dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g), - ).toHaveLength(3); + dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g), + ).toHaveLength(2); + expect(dockerfile).toContain( + "rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack", + ); + expect(dockerfile).toContain("tsx@4.23.1"); + expect(dockerfile).toContain( + "ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx", + ); + }); + + it("keeps linked worktrees out of the Docker build context", () => { + const dockerignore = readRepoFile(".dockerignore"); + expect(dockerignore.split(/\r?\n/)).toContain(".worktrees"); }); it("bundles Prisma's adapter and verifies only runtime externals", () => { diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 647df1c14..70ef1c500 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -64,32 +64,25 @@ * * SELF-CONTAINED by design (v1.16.0) * ---------------------------------- - * The production standalone image ships neither the project - * `node_modules` tree nor the `@/` path alias, so the previous - * `dotenv/config` + `@/lib/db` import chain died with "Cannot find - * module" inside the container. This script therefore depends on - * exactly ONE package — `pg` — and reads `DATABASE_URL` straight from - * the environment (no dotenv). Inside the production image `pg` resolves - * via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs - * `pg@8.x` there); in a repo checkout it resolves from the project - * `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the - * standalone image strips it) and pins `pg` for any environment where - * neither resolution path exists. + * The production standalone image does not expose the project package + * manager. It provides a pinned `healthlog-tsx` maintenance launcher, while + * `pg` resolves from the standalone runtime tree. In a repo checkout, run the + * same script through `pnpm dlx --package pg --package tsx tsx ...`. * - * Usage - * ----- + * Usage inside the production container + * ------------------------------------- * # dry-run (DATABASE_URL must point at the target database) - * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts + * healthlog-tsx scripts/repair-intake-anomalies.ts * * # scoped dry-run - * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user + * healthlog-tsx scripts/repair-intake-anomalies.ts --user * * # apply - * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix - * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible + * healthlog-tsx scripts/repair-intake-anomalies.ts --fix + * healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible * * # create the proposed schedule revisions + startsOn fixes (defect 5) - * pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras + * healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras */ import { Client, types } from "pg"; @@ -425,7 +418,7 @@ async function main(): Promise { if (!process.env.DATABASE_URL) { console.error( `${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` + - `the command: DATABASE_URL='postgresql://…' pnpm dlx …)`, + `the command: DATABASE_URL='postgresql://…' healthlog-tsx …)`, ); process.exit(1); } diff --git a/scripts/restore-backup.ts b/scripts/restore-backup.ts index 8a302206e..e49f64eba 100644 --- a/scripts/restore-backup.ts +++ b/scripts/restore-backup.ts @@ -14,9 +14,8 @@ * BACKUP_S3_SECRET_KEY=... \ * BACKUP_S3_REGION=auto \ * BACKUP_ENCRYPTION_KEY= \ - * pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json + * pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json */ -import "dotenv/config"; import { writeFileSync } from "node:fs"; import { loadOffhostConfig, @@ -82,7 +81,7 @@ async function main() { ?.slice("--user-id=".length); if (!key) { console.error( - "Usage: pnpm tsx scripts/restore-backup.ts [output-file] [--user-id=]", + "Usage: pnpm dlx tsx scripts/restore-backup.ts [output-file] [--user-id=]", ); process.exit(1); } diff --git a/scripts/test-notifications.ts b/scripts/test-notifications.ts index 8d77b9d57..f5bebc669 100644 --- a/scripts/test-notifications.ts +++ b/scripts/test-notifications.ts @@ -1,11 +1,8 @@ /** * Test script for notification delivery and reminder check. * - * Usage (inside Docker container): - * npx tsx scripts/test-notifications.ts - * - * Or from host: - * docker compose exec app npx tsx scripts/test-notifications.ts + * Usage (from a source checkout with the target environment exported): + * pnpm dlx tsx scripts/test-notifications.ts * * What it does: * 1. Lists all configured notification channels diff --git a/vitest.config.mts b/vitest.config.mts index caee54df7..777007d6f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -31,10 +31,10 @@ export default defineConfig({ "tests/integration/**", // Playwright E2E suite — driven separately via `pnpm e2e`. "e2e/**", - // Live agent worktrees create copies of `src/` under - // `.claude/worktrees/`; vitest would otherwise pick those copies up - // and run their tests twice — possibly against stale snapshots. + // Local agent worktrees create copies of `src/` under `.claude/` + // or `.worktrees/`; vitest must not collect stale duplicate suites. ".claude/worktrees/**", + ".worktrees/**", ], coverage: { provider: "v8",