diff --git a/.devcontainer/compose.devcontainer.yaml b/.devcontainer/compose.devcontainer.yaml index cc91b72fcb4..1382a6e457d 100644 --- a/.devcontainer/compose.devcontainer.yaml +++ b/.devcontainer/compose.devcontainer.yaml @@ -1,4 +1,19 @@ services: + mysql: + # Smaller InnoDB buffer pool than the baseline compose.dev.yaml (1G). + # Lets the dev stack stay within a 2-core / 8 GB Codespace's budget + # after Ghost + 6 Vite dev servers are running. 256 MB is plenty for + # dev data volumes. + command: --innodb-buffer-pool-size=256M --innodb-log-buffer-size=64M --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT + mem_limit: 512m + restart: unless-stopped + + redis: + restart: unless-stopped + + mailpit: + restart: unless-stopped + ghost-dev: # Mount the full repo so VS Code can edit apps/, e2e/, and root config files. # The original ./ghost:/home/ghost/ghost mount from compose.dev.yaml is diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 649dcec35c7..b260a719efb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,6 +9,24 @@ "shutdownAction": "stopCompose", "remoteUser": "root", + // Installs docker CLI and mounts the host docker socket into ghost-dev so + // developers can run `docker ps / logs / inspect / exec` against peer + // compose services (mysql, redis, mailpit, gateway) from inside the dev + // container. Without this, diagnosing any compose-peer failure requires + // SSHing to the Codespaces host, which our image doesn't support out of + // the box. + // + // Security note: mounting the host's docker socket combined with + // `remoteUser: "root"` effectively gives the dev container root-equivalent + // control over the host Docker daemon (spawn privileged containers, mount + // host paths, etc.). This is the standard pattern for local / Codespaces + // dev containers where the host is the developer's own machine, but do + // NOT replicate this pattern in shared build agents, CI runners, or + // environments where untrusted code runs in the container. + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + // Codespaces prebuild step — runs once when the image is built. // Primes the pnpm store so first-open is fast. "onCreateCommand": "corepack enable && corepack prepare --activate && cd /workspaces/Ghost && pnpm install --prefer-offline || true", @@ -16,6 +34,12 @@ // Runs after the workspace mount is ready on every container create. "postCreateCommand": ".devcontainer/postCreate.sh", + // Runs whenever VS Code attaches to the container. Fires only inside the + // container, so this is safe (unlike a tasks.json runOn: folderOpen which + // also fires on host VS Code where node_modules doesn't exist yet). + // The script guards against double-starting if the stack is already up. + "postAttachCommand": "bash .devcontainer/start-dev-stack.sh", + "forwardPorts": [ 2368, 3306, diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 62da08d0ff7..6d74af239e1 100755 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -9,3 +9,15 @@ corepack prepare --activate git submodule update --init --recursive pnpm install --prefer-offline + +# Build workspace packages that ghost/core imports at runtime with build +# outputs (not source). @tryghost/parse-email-address is the only one today +# — its package.json "main" points at build/index.js, so the backend can't +# import it on a fresh clone until it's compiled. +# On host, `pnpm dev` triggers this via Nx dependsOn cascades; inside the +# devcontainer we invoke `pnpm --filter ghost dev` directly, which bypasses +# those cascades. +# Frontend apps (admin, posts, stats, activitypub, etc.) do NOT need +# pre-building here — their own dev targets handle it when start-dev-stack.sh +# runs `nx run-many -t dev`. +pnpm --filter @tryghost/parse-email-address build diff --git a/.devcontainer/start-dev-stack.sh b/.devcontainer/start-dev-stack.sh new file mode 100755 index 00000000000..40792931c6f --- /dev/null +++ b/.devcontainer/start-dev-stack.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /workspaces/Ghost + +# Skip if backend is already bound to port 2368 — avoids double-starting on +# VS Code reload/re-attach. The subshell isolates bash's noisy +# "connection refused" message on first run when nothing's listening yet. +if (exec 3<>/dev/tcp/127.0.0.1/2368) 2>/dev/null; then + echo "Ghost dev stack already running on :2368, skipping start." + exit 0 +fi + +echo "Starting Ghost dev stack..." + +# Append to log files (don't truncate) so previous crash tails survive a +# restart and the user can still tail them for context. +{ echo "=== $(date -Is) starting backend ==="; } >> /tmp/ghost-backend.log +nohup pnpm --filter ghost dev >> /tmp/ghost-backend.log 2>&1 & +disown + +{ echo "=== $(date -Is) starting frontends ==="; } >> /tmp/ghost-frontends.log +nohup pnpm nx run-many -t dev \ + --projects=@tryghost/admin,@tryghost/portal,@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar \ + >> /tmp/ghost-frontends.log 2>&1 & +disown + +cat <<'MSG' +Ghost dev stack starting in the background. + + Backend log: tail -f /tmp/ghost-backend.log + Frontend log: tail -f /tmp/ghost-frontends.log + Gateway: http://localhost:2368/ + +Give it ~30-60s, then open http://localhost:2368/ghost/ for admin. +MSG diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b1889f04d2..9a0f61cfcd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,8 @@ jobs: - '!ghost/core/core/server/data/tinybird/**/*.md' any-code: - '!**/*.md' + - '!.devcontainer/**' + - '!.vscode/**' - name: Define Node test matrix id: node_matrix diff --git a/.gitignore b/.gitignore index 1131d2a2fdc..11fe9e51b39 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,8 @@ typings/ .vscode/* !.vscode/launch.json !.vscode/settings.json +!.vscode/tasks.json +!.vscode/extensions.json # OSX .DS_Store diff --git a/.nxignore b/.nxignore new file mode 100644 index 00000000000..a94997ad936 --- /dev/null +++ b/.nxignore @@ -0,0 +1,7 @@ +# Paths Nx should exclude from the project graph and `nx --affected` +# computation. Without this, Nx's default "unknown files affect everything" +# behaviour makes Lint, Unit tests, and per-app Build jobs run on PRs that +# only touch dev-tooling files. Pairs with the `any-code` path filter in +# .github/workflows/ci.yml, which also excludes these paths. +.devcontainer/ +.vscode/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000000..894e9d73921 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7659b906987..7034a4dae5a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "**/ghost.map": true, "**/node_modules": true, "ghost/core/core/built/**": true, + ".claude/worktrees/**": true, "**/config.*.json": false, "**/config.*.jsonc": false }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..3958cf9ae28 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,40 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Ghost: Backend (nodemon)", + "type": "shell", + "command": "pnpm --filter ghost dev", + "options": {"cwd": "${workspaceFolder}"}, + "isBackground": true, + "presentation": {"panel": "dedicated", "group": "dev"}, + "problemMatcher": [] + }, + { + "label": "Ghost: Frontend dev servers", + "type": "shell", + "command": "pnpm nx run-many -t dev --projects=@tryghost/admin,@tryghost/portal,@tryghost/comments-ui,@tryghost/signup-form,@tryghost/sodo-search,@tryghost/announcement-bar", + "options": {"cwd": "${workspaceFolder}"}, + "isBackground": true, + "presentation": {"panel": "dedicated", "group": "dev"}, + "problemMatcher": [] + }, + { + "label": "Ghost: Full dev stack", + "dependsOn": [ + "Ghost: Backend (nodemon)", + "Ghost: Frontend dev servers" + ], + "dependsOrder": "parallel", + "problemMatcher": [], + "group": {"kind": "build", "isDefault": true} + }, + { + "label": "Ghost: Reset data (empty)", + "type": "shell", + "command": "node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123", + "options": {"cwd": "${workspaceFolder}/ghost/core"}, + "problemMatcher": [] + } + ] +} diff --git a/ghost/core/core/server/lib/get-inbox-links.ts b/ghost/core/core/server/lib/get-inbox-links.ts index 0e7d6528502..1bc6ae1b1c4 100644 --- a/ghost/core/core/server/lib/get-inbox-links.ts +++ b/ghost/core/core/server/lib/get-inbox-links.ts @@ -61,18 +61,19 @@ const buildUrl = (baseHref: string, key: string, value: string): string => { return result.toString(); }; -const encodeRecipientForGmailUrl = (recipient: string) => ( - encodeURIComponent(recipient).replaceAll('%40', '@') -); - const PROVIDERS: ReadonlyArray = [ { name: 'gmail', domains: ['gmail.com', 'googlemail.com', 'google.com'], + // Gmail's `/mail/u//` path expects a numeric account index. Passing a + // raw email only resolves when that account happens to be signed in at + // that slot; Workspace accounts and signed-out users hit a 404 before + // the `#search` fragment runs. `authuser` is Gmail's own account + // resolver and falls through to sign-in instead of erroring. getDesktopLink: ({recipient, sender}) => ( - `https://mail.google.com/mail/u/${encodeRecipientForGmailUrl( + `https://mail.google.com/mail/u/0/?authuser=${encodeURIComponent( recipient - )}/#search/from%3A(${encodeURIComponent( + )}#search/from%3A(${encodeURIComponent( sender )})+in%3Aanywhere+newer_than%3A1h` ), diff --git a/ghost/core/test/unit/server/lib/get-inbox-links.test.ts b/ghost/core/test/unit/server/lib/get-inbox-links.test.ts index a7c7470c305..688e06fafa9 100644 --- a/ghost/core/test/unit/server/lib/get-inbox-links.test.ts +++ b/ghost/core/test/unit/server/lib/get-inbox-links.test.ts @@ -41,8 +41,8 @@ describe('getInboxLinks', function () { dnsResolver: resolverThatShouldNeverBeUsed }); assert.equal(result?.provider, 'gmail'); - assert(result?.desktop.startsWith('https://mail.google.com/')); - assert(result?.desktop.includes(recipient)); + assert(result?.desktop.startsWith('https://mail.google.com/mail/u/0/')); + assert(result?.desktop.includes(`authuser=${encodeURIComponent(recipient)}`)); assert(result?.desktop.includes(encodeURIComponent('sender@example.com'))); assert(result?.android.startsWith('intent:')); assert(result?.android.includes('com.google.android.gm')); @@ -54,7 +54,7 @@ describe('getInboxLinks', function () { sender: 'sendér@example.com', dnsResolver: resolverThatShouldNeverBeUsed }); - assert(nonAsciiResult?.desktop.includes('exampl%C3%A9@gmail.com')); + assert(nonAsciiResult?.desktop.includes(`authuser=${encodeURIComponent('examplé@gmail.com')}`)); assert(nonAsciiResult?.desktop.includes(encodeURIComponent('sendér@example.com'))); });