Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .devcontainer/compose.devcontainer.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 24 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,37 @@
"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",

// 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,
Expand Down
12 changes: 12 additions & 0 deletions .devcontainer/postCreate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions .devcontainer/start-dev-stack.sh
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ typings/
.vscode/*
!.vscode/launch.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/extensions.json

# OSX
.DS_Store
Expand Down
7 changes: 7 additions & 0 deletions .nxignore
Original file line number Diff line number Diff line change
@@ -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/
5 changes: 5 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"recommendations": [
"ms-vscode-remote.remote-containers"
]
}
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"**/ghost.map": true,
"**/node_modules": true,
"ghost/core/core/built/**": true,
".claude/worktrees/**": true,
"**/config.*.json": false,
"**/config.*.jsonc": false
},
Expand Down
40 changes: 40 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -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": []
}
]
}
13 changes: 7 additions & 6 deletions ghost/core/core/server/lib/get-inbox-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Provider> = [
{
name: 'gmail',
domains: ['gmail.com', 'googlemail.com', 'google.com'],
// Gmail's `/mail/u/<X>/` 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`
),
Expand Down
6 changes: 3 additions & 3 deletions ghost/core/test/unit/server/lib/get-inbox-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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')));
});

Expand Down
Loading