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
4 changes: 4 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Dripnex",
"install": "bash .cursor/install.sh"
}
118 changes: 118 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
#
# Cloud Agent install script for Dripnex.
#
# Non-obvious constraints this script exists to satisfy:
#
# 1. node:sqlite FTS5 — @dripnex/mcp-server uses Node's built-in `node:sqlite`
# (DatabaseSync) and asserts FTS5 is compiled in. The node binary the
# Cursor exec daemon ships (/exec-daemon/node) has FTS5 DISABLED, so a
# plain `node`/`pnpm test` would fail. We select an nvm-managed Node whose
# bundled SQLite has FTS5 enabled and make it the default `node` for every
# shell by symlinking the toolchain into a PATH dir that precedes
# /exec-daemon.
#
# 2. better-sqlite3 (desktop) — apps/desktop's postinstall runs
# `electron-builder install-app-deps`, which rebuilds better-sqlite3
# against Electron's ABI. That is required for `pnpm dev` and the
# Playwright+Electron e2e suite to launch. (This is why `pnpm test`
# excludes @dripnex/storage-sqlite — see CLAUDE.md.)
#
set -euo pipefail

cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
echo "==> Dripnex install (repo: $REPO_ROOT)"

# Dependabot-regenerated lockfiles can resolve GitHub git deps over SSH; CI
# rewrites them to HTTPS so the tarball install works without a deploy key.
git config --global 'url.https://github.com/.insteadOf' 'git@github.com:' || true

# --- 1. Select an FTS5-capable Node and make it the default ----------------
has_fts5() {
"$1" -e 'const{DatabaseSync}=require("node:sqlite");const d=new DatabaseSync(":memory:");process.exit(d.prepare("SELECT sqlite_compileoption_used(\x27ENABLE_FTS5\x27) v").get().v===1?0:1)' >/dev/null 2>&1
}

export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
NODE_BIN=""
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1091
. "$NVM_DIR/nvm.sh"
# Prefer an already-installed Node >= 22 that has FTS5; otherwise install 22.
for ver in $(nvm ls --no-colors 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -Vr | uniq); do
cand="$NVM_DIR/versions/node/$ver/bin/node"
if [ -x "$cand" ] && has_fts5 "$cand"; then NODE_BIN="$cand"; break; fi
done
if [ -z "$NODE_BIN" ]; then
echo "==> No FTS5-capable Node found; installing Node 22 via nvm"
nvm install 22 >/dev/null
cand="$(nvm which 22 2>/dev/null || true)"
if [ -n "$cand" ] && has_fts5 "$cand"; then NODE_BIN="$cand"; fi
fi
fi

if [ -z "$NODE_BIN" ]; then
echo "ERROR: could not locate a Node build with node:sqlite FTS5 enabled." >&2
exit 1
fi
NODE_BIN_DIR="$(dirname "$NODE_BIN")"
echo "==> Using Node $("$NODE_BIN" -v) (FTS5 enabled) from $NODE_BIN_DIR"

# Make the FTS5 node the default `node`/`npx` for every shell.
#
# The Cursor daemon's runtime PATH places /exec-daemon (which ships an
# FTS5-less node) AHEAD of the nvm bin, so a plain `node` would be wrong at
# agent runtime. Crucially, the install-time PATH can differ from the agent
# runtime PATH (install may run with nvm already ahead), so we cannot skip
# based on the current ordering — we ALWAYS place shims. We symlink the
# toolchain into every writable PATH dir that precedes /exec-daemon, plus
# /usr/local/cargo/bin (world-writable in the base image and consistently
# ahead of /exec-daemon at runtime). Symlinks live on disk, so they survive
# into environment builds/snapshots and win over /exec-daemon's node.
declare -a SHIM_CANDIDATES=()
IFS=':' read -r -a _path_entries <<< "$PATH"
for d in "${_path_entries[@]}"; do
case "$d" in */exec-daemon*) break ;; esac
SHIM_CANDIDATES+=("$d")
done
SHIM_CANDIDATES+=("/usr/local/cargo/bin")

_shimmed=""
for d in "${SHIM_CANDIDATES[@]}"; do
[ "$d" = "$NODE_BIN_DIR" ] && continue # never clobber the node bin itself
case " $_shimmed " in *" $d "*) continue ;; esac # dedupe
mkdir -p "$d" 2>/dev/null || sudo mkdir -p "$d" 2>/dev/null || true
[ -d "$d" ] && [ -w "$d" ] || continue
for b in node npm npx corepack pnpm pnpx yarn yarnpkg; do
src="$NODE_BIN_DIR/$b"; dest="$d/$b"
[ -e "$src" ] && [ "$src" != "$dest" ] && ln -sfn "$src" "$dest" 2>/dev/null || true
done
echo "==> Linked node toolchain into $d"
_shimmed="$_shimmed $d"
done

# Ensure the rest of THIS script uses the FTS5 node too.
export PATH="$NODE_BIN_DIR:$PATH"
hash -r || true

# --- 2. Install workspace dependencies (runs postinstall scripts) ----------
# postinstall: lefthook install (git hooks) + electron-builder install-app-deps
# (rebuilds better-sqlite3 for Electron) + downloads the Electron binary.
echo "==> pnpm install --frozen-lockfile"
pnpm install --frozen-lockfile

# Belt-and-suspenders: ensure the Electron binary is materialized for e2e.
if [ -f apps/desktop/node_modules/electron/install.js ]; then
echo "==> Materializing Electron binary"
( cd apps/desktop && node node_modules/electron/install.js )
fi

# --- 3. Build workspace packages (source-derived; needed by typecheck/e2e) -
echo "==> pnpm build"
pnpm build

# --- 4. Verify the critical invariant --------------------------------------
echo "==> Verifying node:sqlite FTS5"
node -e 'const{DatabaseSync}=require("node:sqlite");const v=new DatabaseSync(":memory:").prepare("SELECT sqlite_compileoption_used(\x27ENABLE_FTS5\x27) v").get().v;if(v!==1){console.error("FTS5 missing");process.exit(1)}console.log("node",process.version,"FTS5 OK")'

echo "==> Install complete."
14 changes: 12 additions & 2 deletions .github/workflows/automerge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,21 @@ jobs:
disable-automerge:
runs-on: ubuntu-latest
permissions:
# Only the auto-merge state changes here; no contents write needed.
# `pull-requests: write` alone returned FORBIDDEN ("Resource not
# accessible by integration") on PR #571. The narrower scope came from
# a least-privilege review; the mutation appears to want contents too.
# If FORBIDDEN comes back with both scopes granted, the cause is a
# repository or org restriction on GITHUB_TOKEN, not this file.
contents: write
pull-requests: write
# Only where a queued merge could still land somewhere unprotected: a
# draft, or a base that is neither develop nor main. A PR retargeted onto
# main is a release promotion, and main is protected — there is nothing
# dangerous to undo, so firing there was pure noise.
if: >-
github.event.pull_request.draft == true ||
github.event.pull_request.base.ref != 'develop'
(github.event.pull_request.base.ref != 'develop' &&
github.event.pull_request.base.ref != 'main')
steps:
- name: Disable auto-merge for drafts and non-develop bases
env:
Expand Down
56 changes: 43 additions & 13 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,23 +218,53 @@ jobs:
if: needs.publish.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Create sync PR
- uses: actions/checkout@v5
with:
fetch-depth: 0

# A `develop <- main` PR cannot merge: develop requires the head branch
# to be up to date, and main is behind develop the moment anything lands
# after the release. Push a branch descended from develop with main
# merged into it instead — that satisfies the rule and, unlike a squash,
# actually makes main an ancestor of develop so the next promotion is
# not stuck at BEHIND.
- name: Open the back-merge PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ needs.meta.outputs.tag }}
run: |
set -euo pipefail
if ! pr_count="$(gh pr list --base develop --head main --repo "$GITHUB_REPOSITORY" --json number --jq 'length')"; then
echo "::error::Unable to check for an existing sync PR."
exit 1
git fetch origin main develop

if git merge-base --is-ancestor origin/main origin/develop; then
echo "main is already an ancestor of develop; nothing to sync."
exit 0
fi
if [ "$pr_count" -eq 0 ]; then
gh pr create \
--base develop \
--head main \
--title "chore: sync release $TAG_NAME back to develop" \
--body "Auto sync of release commit and changelog from $TAG_NAME." \
--repo "$GITHUB_REPOSITORY"
else
echo "Sync PR already open; nothing to create."

# The chore/backmerge- prefix keeps this out of the squash
# auto-merge in automerge.yml. Squashing it would defeat the point.
branch="chore/backmerge-main-${TAG_NAME}"

if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
echo "$branch already exists; leaving the open PR alone."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch" origin/develop
git merge origin/main -m "chore(release): merge main into develop"
git push origin "$branch"

gh pr create \
--base develop \
--head "$branch" \
--title "chore(release): merge main into develop" \
--body "Back-merge of $TAG_NAME so main stays an ancestor of develop and the next promotion PR is not stuck at BEHIND.

Merge this with a **merge commit**. A squash replays the content as a new commit and does not establish ancestry." \
--repo "$GITHUB_REPOSITORY"

# Explicitly a merge commit. automerge.yml skips chore/backmerge-*,
# so nothing else will arm this with --squash.
gh pr merge "$branch" --auto --merge --repo "$GITHUB_REPOSITORY"
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { validateThemeTokens } from '@dripnex/plugin-api';
import { OFFICIAL_THEMES } from '../officialThemes';

describe('OFFICIAL_THEMES', () => {
it('registers unique ids with only valid tokens', () => {
const ids = OFFICIAL_THEMES.map(theme => theme.id);
expect(new Set(ids).size).toBe(ids.length);

for (const theme of OFFICIAL_THEMES) {
expect(theme.name.length).toBeGreaterThan(0);
expect(['dark', 'light']).toContain(theme.colorScheme);
const valid = validateThemeTokens(theme.tokens, theme.id);
for (const [token, value] of Object.entries(theme.tokens)) {
expect(valid[token]).toBe(value);
}
expect(valid['--accent-primary']).toBe(theme.tokens['--accent']);
}
});

it('includes Harbor Dusk as a dark official palette', () => {
const harbor = OFFICIAL_THEMES.find(theme => theme.id === 'dripnex-harbor-dusk');
expect(harbor).toMatchObject({
name: 'Harbor Dusk',
colorScheme: 'dark',
pluginId: 'dripnex',
});
expect(harbor?.tokens['--bg-base']).toBe('#141c26');
expect(harbor?.tokens['--accent']).toBe('#5e9a92');
expect(harbor?.tokens['--cm-link']).toBe('#d4a05a');
});
});
36 changes: 36 additions & 0 deletions apps/desktop/src/renderer/themes/officialThemes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,42 @@ export const OFFICIAL_THEMES: ThemeDefinition[] = [
'--status-dropped': '#c44b4b',
},
},
{
id: 'dripnex-harbor-dusk',
name: 'Harbor Dusk',
description: 'Coastal evening. Mist text, muted teal, amber lanterns.',
author: 'Dripnex',
colorScheme: 'dark',
pluginId: 'dripnex',
tokens: {
'--bg-base': '#141c26',
'--bg-surface': '#10161e',
'--bg-elevated': '#1c2633',
'--bg-inset': '#0c1218',
'--bg-hover': 'rgba(205, 214, 222, 0.06)',
'--bg-active': 'rgba(205, 214, 222, 0.1)',
'--text-primary': '#cdd6de',
'--text-secondary': 'rgba(205, 214, 222, 0.74)',
'--text-muted': 'rgba(205, 214, 222, 0.5)',
'--text-faint': 'rgba(205, 214, 222, 0.32)',
'--border': 'rgba(205, 214, 222, 0.1)',
'--border-subtle': 'rgba(205, 214, 222, 0.06)',
'--border-strong': 'rgba(205, 214, 222, 0.16)',
'--accent': '#5e9a92',
'--accent-hover': '#74aea6',
'--accent-muted': 'rgba(94, 154, 146, 0.2)',
'--accent-subtle': 'rgba(94, 154, 146, 0.1)',
'--glass-bg': 'rgba(20, 28, 38, 0.9)',
'--glass-border': 'rgba(205, 214, 222, 0.08)',
'--glass-bg-menu': 'rgba(28, 38, 51, 0.95)',
'--glass-border-menu': 'rgba(205, 214, 222, 0.08)',
'--status-active': '#5e9a92',
'--status-on-hold': '#d4a05a',
'--status-completed': '#7aad8a',
'--status-dropped': '#c46b6b',
'--cm-link': '#d4a05a',
},
},
];

export function registerOfficialThemes(): void {
Expand Down
Loading
Loading