rename: the server is Inkwell — package, env vars, image, compose, export marker
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 15s
CI & Build / integration (push) Successful in 45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 4m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 7m33s
Desktop (Tauri) / Update manifest (push) Successful in 7s
Android / Kotlin + Rust (APK) (push) Successful in 11m25s

Step 2 of milestone 481. The operator chose a full rename (Scribe note 5071), so
this goes past the display strings into the identities:

- src/thoughtsync → src/inkwell; every import, the Dockerfile and both compose
  commands, alembic env, pyproject
- THOUGHTSYNC_* → INKWELL_* (database URL, secret key, log level, tag/port/bind)
- container data dir /var/thoughtsync → /var/inkwell
- image git.fabledsword.com/bvandeusen/inkwell; Postgres user/db default inkwell;
  CI's integration service follows
- the files the image serves are inkwell.*. fetch-clients.sh still fetches the
  thoughtsync-named release assets, because the lanes that publish them are
  renamed in steps 3 and 4
- exports are written with app "inkwell"

Two deliberate exceptions, both because data rides on them:

- compose volumes are now named explicitly and overridable (INKWELL_DB_VOLUME,
  INKWELL_DATA_VOLUME), so a deployment installed as ThoughtSync points at the
  volumes and DB identity it already has. .env.example says exactly what to set
- import still accepts app "thoughtsync", because exports written before the
  rename are backups. Tested both ways

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:18:49 -04:00
co-authored by Claude Opus 5.5
parent f806e35d41
commit a706644455
78 changed files with 231 additions and 168 deletions
+24 -5
View File
@@ -1,4 +1,4 @@
# ThoughtSync production settings. Copy to `.env` and edit:
# Inkwell production settings. Copy to `.env` and edit:
#
# cp .env.example .env
#
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
#
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
# check which branch it actually landed on before assuming a bug.
#THOUGHTSYNC_TAG=latest
#INKWELL_TAG=latest
# The host port the app is published on.
#THOUGHTSYNC_PORT=5000
#INKWELL_PORT=5000
# Which interface to bind. The default (all interfaces) is what lets desktop
# clients on your network reach the server. Behind a reverse proxy, set this to
# 127.0.0.1 so only the proxy can talk to it.
#THOUGHTSYNC_BIND=0.0.0.0
#INKWELL_BIND=0.0.0.0
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
@@ -47,12 +47,31 @@ POSTGRES_PASSWORD=
# How much the app says. Credential events (sign-ins, failures, throttles, new
# accounts, device tokens issued) are logged at INFO and read with
# `docker compose logs app`.
#THOUGHTSYNC_LOG_LEVEL=INFO
#INKWELL_LOG_LEVEL=INFO
# Database identity. Changing these AFTER the first start does not rename anything
# that already exists — the volume keeps whatever the first run created.
#POSTGRES_USER=inkwell
#POSTGRES_DB=inkwell
# --- Upgrading from ThoughtSync ---------------------------------------------
#
# Inkwell was called ThoughtSync, and a deployment installed under that name has
# its data in volumes and a database named for it. Point at them instead of
# renaming anything. Leaving these unset on such a deployment starts Inkwell
# against EMPTY volumes; the old data is untouched, but you would not see it.
#
# Use the full names `docker volume ls` prints — compose prefixes them with the
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
#
# And the database identity the first start created, which a volume keeps:
#POSTGRES_USER=thoughtsync
#POSTGRES_DB=thoughtsync
#
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
# names are no longer read.
# --- a note on HTTPS --------------------------------------------------------
#
+5 -5
View File
@@ -54,7 +54,7 @@ permissions:
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
IMAGE: git.fabledsword.com/bvandeusen/inkwell
jobs:
# Should this push build an image now, or is the Android lane about to publish a
@@ -215,11 +215,11 @@ jobs:
# Postgres it will actually meet.
image: postgres:16-alpine
env:
POSTGRES_USER: thoughtsync
POSTGRES_USER: inkwell
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: thoughtsync_test
POSTGRES_DB: inkwell_test
options: >-
--health-cmd "pg_isready -U thoughtsync"
--health-cmd "pg_isready -U inkwell"
--health-interval 10s
--health-timeout 5s
--health-retries 10
@@ -243,7 +243,7 @@ jobs:
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
# no bash /dev/tcp — so use the Python that is always present here.
/opt/venv/bin/python - "$PG_IP" <<'PY'
+3 -3
View File
@@ -20,7 +20,7 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
# points at /app/src so the runtime imports this source tree (with static/ present),
# not the pip-installed copy.
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
COPY --from=build-frontend /build/dist/ src/inkwell/static/
COPY alembic.ini .
COPY alembic/ alembic/
@@ -41,7 +41,7 @@ COPY alembic/ alembic/
# that step never ran. An image with no clients — or with some and not others — is
# a supported state: the server advertises what it has and the web UI hides the
# rest (client_dist.py).
COPY client/ src/thoughtsync/client/
COPY client/ src/inkwell/client/
ENV PYTHONPATH=/app/src
@@ -52,4 +52,4 @@ EXPOSE 5000
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
# slow/unready database from crash-looping the container. Family convention
# (rule 82): schema is built by real migrations, never metadata.create_all.
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+2 -2
View File
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
## Layout
```
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
alembic/ async migrations (schema built via `alembic upgrade head`)
tests/ DB-free unit tests (pytest)
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
@@ -28,7 +28,7 @@ Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
```sh
pip install -e ".[dev]"
alembic upgrade head
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
```
Frontend (proxies `/api` to `:5000`):
+2 -2
View File
@@ -5,8 +5,8 @@ script_location = %(here)s/alembic
prepend_sys_path = . src
path_separator = os
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
[loggers]
+3 -3
View File
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from thoughtsync.models import Base
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
from inkwell.models import Base
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
config = context.config
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
config.set_main_option(
"sqlalchemy.url",
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
)
target_metadata = Base.metadata
+1 -1
View File
@@ -1,6 +1,6 @@
//! Recurring-reminder math: where a reminder goes when it is marked done.
//!
//! A deliberate port of the server's `src/thoughtsync/notes/recurrence.py`, kept
//! A deliberate port of the server's `src/inkwell/notes/recurrence.py`, kept
//! behaviourally identical rather than merely similar. The same note can be
//! completed from the web (server code) or from the desktop and Android (this
//! code), and the two must land on the same instant — otherwise completing a
+11 -11
View File
@@ -14,15 +14,15 @@ services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: thoughtsync
POSTGRES_PASSWORD: thoughtsync
POSTGRES_DB: thoughtsync
POSTGRES_USER: inkwell
POSTGRES_PASSWORD: inkwell
POSTGRES_DB: inkwell
volumes:
- thoughtsync-dev-db:/var/lib/postgresql/data
- inkwell-dev-db:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
test: ["CMD-SHELL", "pg_isready -U inkwell"]
interval: 5s
timeout: 5s
retries: 10
@@ -34,22 +34,22 @@ services:
db:
condition: service_healthy
environment:
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:thoughtsync@db:5432/thoughtsync
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:inkwell@db:5432/inkwell
PYTHONPATH: /app/src
volumes:
- ./pyproject.toml:/app/pyproject.toml
- ./alembic.ini:/app/alembic.ini
- ./alembic:/app/alembic
- ./src:/app/src
- thoughtsync-dev-data:/var/thoughtsync
- inkwell-dev-data:/var/inkwell
ports:
- "5000:5000"
# Install deps, wait for the DB, run migrations, then serve with live reload.
command: >
sh -c "pip install --quiet -e . &&
python -m thoughtsync.dbwait &&
python -m inkwell.dbwait &&
alembic upgrade head &&
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --reload"
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --reload"
web:
image: node:22-alpine
@@ -67,5 +67,5 @@ services:
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
volumes:
thoughtsync-dev-db:
thoughtsync-dev-data:
inkwell-dev-db:
inkwell-dev-data:
+27 -21
View File
@@ -1,4 +1,4 @@
# ThoughtSync — PRODUCTION stack (app + Postgres).
# Inkwell — PRODUCTION stack (app + Postgres).
#
# This is the default compose file: `docker compose up -d` runs a real deployment
# from the published image. Development lives in docker-compose.dev.yml (hot-reload,
@@ -10,15 +10,15 @@
# Per family rule 12 the agent does NOT start this — run it yourself.
#
# Upgrades: docker compose pull && docker compose up -d
# Rollback: set THOUGHTSYNC_TAG to a commit sha in .env, then the same two commands.
# Rollback: set INKWELL_TAG to a commit sha in .env, then the same two commands.
# Every push publishes an immutable :<sha> image for exactly this.
#
# Schema migrations run automatically at container start (see the Dockerfile CMD),
# so an upgrade is just a pull and a restart. Take a backup first anyway:
#
# docker compose exec -T db pg_dump -U thoughtsync thoughtsync > backup.sql
# docker compose exec -T db pg_dump -U inkwell inkwell > backup.sql
#
# Attachments are files, not rows — they live in the `thoughtsync-data` volume and
# Attachments are files, not rows — they live in the `inkwell-data` volume and
# a pg_dump does NOT contain them. Back up both or you'll restore notes whose images
# are gone.
@@ -27,22 +27,21 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-thoughtsync}
POSTGRES_USER: ${POSTGRES_USER:-inkwell}
# No default on purpose. A production compose that ships a known password is
# how self-hosted databases end up in search engines; compose fails fast here
# instead, with the message below.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env — see .env.example}
POSTGRES_DB: ${POSTGRES_DB:-thoughtsync}
POSTGRES_DB: ${POSTGRES_DB:-inkwell}
volumes:
# Volume names kept from the previous compose file so an existing deployment
# upgrades in place. Renaming them would silently start against an empty
# database while the old one sat there, orphaned and looking like data loss.
- thoughtsync-db:/var/lib/postgresql/data
# Which volume this resolves to is set at the bottom of this file, and an
# existing deployment overrides it from .env rather than renaming anything.
- inkwell-db:/var/lib/postgresql/data
# Deliberately NOT published to the host. The app reaches Postgres over the
# compose network; exposing 5432 only widens the attack surface. If you need
# psql, `docker compose exec db psql -U thoughtsync` gets you there without it.
# psql, `docker compose exec db psql -U inkwell` gets you there without it.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-thoughtsync} -d ${POSTGRES_DB:-thoughtsync}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-inkwell} -d ${POSTGRES_DB:-inkwell}"]
interval: 10s
timeout: 5s
retries: 10
@@ -56,9 +55,9 @@ services:
max-file: "3"
app:
# :latest tracks `main`. Set THOUGHTSYNC_TAG=dev in .env to follow the
# :latest tracks `main`. Set INKWELL_TAG=dev in .env to follow the
# development line instead, or a commit sha to pin exactly.
image: git.fabledsword.com/bvandeusen/thoughtsync:${THOUGHTSYNC_TAG:-latest}
image: git.fabledsword.com/bvandeusen/inkwell:${INKWELL_TAG:-latest}
restart: unless-stopped
depends_on:
db:
@@ -66,11 +65,11 @@ services:
environment:
# The only required application setting. Everything else a person might want
# to tune lives in the admin Settings UI, backed by the database (rule 25).
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-thoughtsync}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-thoughtsync}
INKWELL_DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-inkwell}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-inkwell}
volumes:
# Uploaded attachments. /var/thoughtsync is fixed in the app (Config.DATA_DIR),
# Uploaded attachments. /var/inkwell is fixed in the app (Config.DATA_DIR),
# not configurable — mount it or lose every image on container recreation.
- thoughtsync-data:/var/thoughtsync
- inkwell-data:/var/inkwell
# WHERE THE APP IS REACHABLE FROM. Three shapes, and the right answer is
# different for each — the default serves the first.
#
@@ -86,14 +85,14 @@ services:
# and publishing one is a second, unauthenticated way in that bypasses the
# proxy — including whatever the proxy is doing about TLS and auth.
#
# 3. Reverse proxy on the HOST (not in Docker). Set THOUGHTSYNC_BIND=127.0.0.1 in
# 3. Reverse proxy on the HOST (not in Docker). Set INKWELL_BIND=127.0.0.1 in
# .env, so the port exists but only the host itself can reach it.
#
# If you are exposing this to the internet, you want 2 or 3. Leaving it at 1
# means the app is reachable directly on port 5000, past everything your proxy
# does.
ports:
- "${THOUGHTSYNC_BIND:-0.0.0.0}:${THOUGHTSYNC_PORT:-5000}:5000"
- "${INKWELL_BIND:-0.0.0.0}:${INKWELL_PORT:-5000}:5000"
healthcheck:
# python rather than curl: the runtime image is python:3.12-slim and carries no
# HTTP client binary. Hits the app's own /api/health.
@@ -113,5 +112,12 @@ services:
logging: *logging
volumes:
thoughtsync-db:
thoughtsync-data:
# Named explicitly so the name is one a person can type, and overridable so a
# deployment that predates the rename to Inkwell keeps the volumes it already has
# (see "Upgrading from ThoughtSync" in .env.example). Pointing these at the wrong
# name does not fail: it starts against an EMPTY database and the old one sits
# there untouched, which looks exactly like data loss.
inkwell-db:
name: ${INKWELL_DB_VOLUME:-inkwell-db}
inkwell-data:
name: ${INKWELL_DATA_VOLUME:-inkwell-data}
+1 -1
View File
@@ -20,7 +20,7 @@ an older release than the desktop app for months. So the wire protocol is
versioned **separately from either program's release version**, and each side
declares two numbers: what it speaks, and the oldest counterpart it accepts.
| | server (`src/thoughtsync/sync.py`) | client (`desktop/src-tauri/src/sync/compat.rs`) |
| | server (`src/inkwell/sync.py`) | client (`desktop/src-tauri/src/sync/compat.rs`) |
|---|---|---|
| speaks | `SYNC_PROTOCOL_VERSION` | `CLIENT_PROTOCOL_VERSION` |
| accepts down to | `MIN_CLIENT_PROTOCOL_VERSION` | `MIN_SERVER_PROTOCOL_VERSION` |
+1 -1
View File
@@ -75,7 +75,7 @@ export function parseInline(text: string): InlineToken[] {
}
// A checklist item: the third implementation of one grammar, alongside
// core/src/local/derive.rs and src/thoughtsync/notes/checklist.py. A difference
// core/src/local/derive.rs and src/inkwell/notes/checklist.py. A difference
// between any two of them is a checklist that changes shape when it syncs (M304).
//
// `-` and `*` only, deliberately, even though the `ul` matcher below also takes `+`.
+10 -10
View File
@@ -37,7 +37,7 @@ case "$channel" in dev|stable) : ;; *)
esac
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/inkwell}"
# The channel's release tag, not its name — `dev` lives on `dev-rolling`
# (packaging/channel-tag.sh, Scribe #2184).
tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")"
@@ -88,15 +88,15 @@ echo "==> Collecting the $channel clients"
# Its sidecar is published whole by the Android lane — an APK keeps its version in
# a binary AXML manifest, so the values are recorded where they were already known.
# Copied verbatim rather than rebuilt here.
if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then
echo " android $(field "$dest/thoughtsync-android.json" version_name)"
if fetch "$BASE/thoughtsync.apk" "$dest/inkwell.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/inkwell-android.json"; then
echo " android $(field "$dest/inkwell-android.json" version_name)"
else
# Both or neither. Half a pair is worse than none: the server would read a
# sidecar describing an APK that is not there, or an APK it cannot state a
# version for.
echo "::warning::No Android client on the $channel channel — this image ships without one."
rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json"
rm -f "$dest/inkwell.apk" "$dest/inkwell-android.json"
fi
# --- desktop -----------------------------------------------------------------
@@ -132,10 +132,10 @@ bake_desktop() {
#
# `<platform id>|<published name>|<name on disk>`
for row in \
"linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe"
"linux-deb|ThoughtSync_${key}_amd64.deb|inkwell.deb" \
"linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|inkwell.pkg.tar.zst" \
"linux-appimage|ThoughtSync_${key}_amd64.AppImage|inkwell.AppImage" \
"windows|ThoughtSync_${key}_x64-setup.exe|inkwell-setup.exe"
do
id="${row%%|*}"; rest="${row#*|}"
remote="${rest%%|*}"; local_name="${rest#*|}"
@@ -158,7 +158,7 @@ bake_desktop() {
fi
fi
sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key"
sidecar "$dest/$local_name" "$dest/inkwell-$id.json" "$name" "$key"
echo " $id $(bytes "$dest/$local_name") bytes"
done
}
+1 -1
View File
@@ -32,7 +32,7 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}"
REPO="${GITHUB_REPOSITORY:-bvandeusen/inkwell}"
artifact="${1:?usage: guard-forward.sh <desktop|android> <dev|stable>}"
+1 -1
View File
@@ -3,7 +3,7 @@ requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "thoughtsync"
name = "inkwell"
version = "0.2.0"
description = "Self-hosted personal thought-capture web app (FabledSword family)"
requires-python = ">=3.12"
@@ -1,4 +1,4 @@
"""ThoughtSync — self-hosted personal thought-capture web app (FabledSword family)."""
"""Inkwell — self-hosted personal thought-capture web app (FabledSword family)."""
# PACKAGING METADATA, and nothing else. Not the version any running server reports.
#
@@ -32,7 +32,7 @@ from .sync import bp as sync_bp, protocol_advertisement
# `force=False` (the default) so a host that has already configured logging keeps its
# own setup; LOG_LEVEL lets an operator turn it up without a code change.
logging.basicConfig(
level=os.environ.get("THOUGHTSYNC_LOG_LEVEL", "INFO").upper(),
level=os.environ.get("INKWELL_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
@@ -38,8 +38,8 @@ wins — would mean replacing one client silently retracts the other four.
Two files, and both must be present:
- the artifact, at a FIXED name (`thoughtsync.deb`, not
`ThoughtSync_2026.08.30.0307_amd64.deb`)
- the artifact, at a FIXED name (`inkwell.deb`, not
`Inkwell_2026.08.30.0307_amd64.deb`)
- its sidecar, `{version_name, version_code, size, sha256}`
**Fixed names, and the version only in the sidecar.** A version-stamped filename
@@ -53,7 +53,7 @@ an APK keeps it in a binary AXML manifest Python cannot parse without the Androi
build tools, and a `.deb` or an AppImage would each need a different unpacker. CI
writes the sidecar where the real value is already known.
The AppImage needs a THIRD file, `thoughtsync.AppImage.sig`. That is the minisign
The AppImage needs a THIRD file, `inkwell.AppImage.sig`. That is the minisign
signature the desktop updater verifies before replacing the running binary, and a
bundle that cannot be verified cannot be offered as an update — so a missing
signature makes the AppImage absent rather than merely unsigned.
@@ -132,31 +132,31 @@ PLATFORMS: tuple[Platform, ...] = (
# exact names, CI bakes them in under them, and clients in the field poll
# `/api/client/android`. Renaming them to match the pattern below would
# buy tidiness and strand every installed phone.
artifact="thoughtsync.apk",
sidecar="thoughtsync-android.json",
artifact="inkwell.apk",
sidecar="inkwell-android.json",
mimetype="application/vnd.android.package-archive",
),
Platform(
id="linux-deb",
label="Debian / Ubuntu",
artifact="thoughtsync.deb",
sidecar="thoughtsync-linux-deb.json",
artifact="inkwell.deb",
sidecar="inkwell-linux-deb.json",
mimetype="application/vnd.debian.binary-package",
code_is_int=False,
),
Platform(
id="linux-pacman",
label="Arch / CachyOS",
artifact="thoughtsync.pkg.tar.zst",
sidecar="thoughtsync-linux-pacman.json",
artifact="inkwell.pkg.tar.zst",
sidecar="inkwell-linux-pacman.json",
mimetype="application/zstd",
code_is_int=False,
),
Platform(
id="linux-appimage",
label="Other Linux (AppImage)",
artifact="thoughtsync.AppImage",
sidecar="thoughtsync-linux-appimage.json",
artifact="inkwell.AppImage",
sidecar="inkwell-linux-appimage.json",
# No registered type for an AppImage, and guessing one buys nothing: it is
# served as an attachment either way, and octet-stream is the answer that
# cannot be wrong.
@@ -167,8 +167,8 @@ PLATFORMS: tuple[Platform, ...] = (
Platform(
id="windows",
label="Windows",
artifact="thoughtsync-setup.exe",
sidecar="thoughtsync-windows.json",
artifact="inkwell-setup.exe",
sidecar="inkwell-windows.json",
mimetype="application/vnd.microsoft.portable-executable",
code_is_int=False,
),
@@ -7,26 +7,26 @@ from pathlib import Path
class Config:
"""Bootstrap configuration.
For a basic install, ``THOUGHTSYNC_DATABASE_URL`` is the ONLY required env var —
For a basic install, ``INKWELL_DATABASE_URL`` is the ONLY required env var —
every other tunable lives in the DB-backed Settings UI (rule 25). The only other
env var is an optional "break-glass" item:
- ``THOUGHTSYNC_SECRET_KEY`` — optional override for the cookie-signing secret.
- ``INKWELL_SECRET_KEY`` — optional override for the cookie-signing secret.
If unset, a key is generated and persisted in the DB (see
``thoughtsync.settings.load_or_create_secret_key``), so sessions survive
``inkwell.settings.load_or_create_secret_key``), so sessions survive
restarts with no volume required.
Uploaded media lives under ``DATA_DIR`` — a fixed, authoritative path
(``/var/thoughtsync``), intentionally NOT configurable (a mutable data path only
(``/var/inkwell``), intentionally NOT configurable (a mutable data path only
invites breakage). Mount a volume there if you want uploads to persist across
container recreation; a text-notes-only install never writes to it.
"""
DATA_DIR = "/var/thoughtsync"
DATA_DIR = "/var/inkwell"
DATABASE_URL = os.environ.get(
"THOUGHTSYNC_DATABASE_URL",
"postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync",
"INKWELL_DATABASE_URL",
"postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell",
)
@classmethod
@@ -50,5 +50,5 @@ class Config:
@classmethod
def secret_key_env(cls) -> str | None:
"""Optional break-glass override for the cookie-signing secret."""
return os.environ.get("THOUGHTSYNC_SECRET_KEY") or None
return os.environ.get("INKWELL_SECRET_KEY") or None
@@ -12,7 +12,7 @@ from . import Base
class Group(Base):
"""A named set of users. Sharing a resource with a group grants it to every
member (see thoughtsync.acl.visible_to_user)."""
member (see inkwell.acl.visible_to_user)."""
__tablename__ = "groups"
@@ -10,7 +10,7 @@ from . import Base
class Setting(Base):
"""A single persisted key/value setting. Values are JSON-encoded text; the
typed defaults + metadata live in the code registry (thoughtsync.settings), so
typed defaults + metadata live in the code registry (inkwell.settings), so
an empty table means "all defaults" (rule 26)."""
__tablename__ = "settings"
@@ -6,7 +6,7 @@ small text/query helpers (`helpers`), and export/import (`import_export`). The
route handlers themselves stay here so blueprint registration is in one place, and
`bp` is defined in `_bp` so every module can import it without a cycle.
External callers import from `thoughtsync.notes` (see `__all__`); those names are
External callers import from `inkwell.notes` (see `__all__`); those names are
re-exported here so the package is a drop-in replacement for the old module."""
from __future__ import annotations
@@ -237,7 +237,7 @@ async def export_notes():
).all()
payload: dict = {
"app": "thoughtsync",
"app": "inkwell",
"version": 1,
"exported_at": datetime.now(timezone.utc).isoformat(),
"labels": [{"name": lb.name, "color": lb.color} for lb in all_labels],
@@ -278,7 +278,7 @@ async def export_notes():
data,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="thoughtsync-export-{stamp}.zip"',
"Content-Disposition": f'attachment; filename="inkwell-export-{stamp}.zip"',
},
)
@@ -15,7 +15,7 @@ THE GRAMMAR IS SHARED. Three implementations exist and they have to agree, becau
difference between any two of them is a checklist that changes shape when it syncs:
core/src/local/derive.rs the native clients (desktop + Android)
src/thoughtsync/notes/checklist.py this file, the server
src/inkwell/notes/checklist.py this file, the server
frontend/src/notes/markdown.ts the browser
optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
@@ -1,5 +1,5 @@
"""Export/import helpers (the non-route logic). Export renders each note as Markdown;
import normalizes a ThoughtSync export OR a Google Keep Takeout zip into a common spec
import normalizes an Inkwell export OR a Google Keep Takeout zip into a common spec
and materializes notes — with a decompression-size budget so an import zip bomb can't
exhaust memory/disk."""
from __future__ import annotations
@@ -58,7 +58,13 @@ def _note_markdown(note: Note, labels: list) -> str:
return "\n".join(fm) + "\n"
# --- Import: ThoughtSync's own export (round-trip) OR a Google Keep Takeout zip ---
# --- Import: Inkwell's own export (round-trip) OR a Google Keep Takeout zip ---
# The `app` value an export's notes.json carries. "thoughtsync" is what every export
# written before the rename to Inkwell says (Scribe note 5071), and those zips are
# people's backups, so they keep importing. That is the one reason the old name is
# still read here; nothing writes it any more.
NATIVE_APP_MARKERS = ("inkwell", "thoughtsync")
# Reverse of ALLOWED_IMAGE_MIMES, for inferring an attachment's mime from its
# filename when the source didn't record one (Keep usually does; be defensive).
@@ -75,7 +81,7 @@ def _usec_to_dt(usec: object) -> datetime | None:
def _native_spec(n: dict) -> dict:
"""Normalize one note from a ThoughtSync export's notes.json into the common
"""Normalize one note from an Inkwell export's notes.json into the common
import spec consumed by _create_imported_note."""
return {
"title": n.get("title"),
@@ -186,9 +192,10 @@ class _ImportBudget:
def _read_import_specs(zf: zipfile.ZipFile, budget: _ImportBudget) -> tuple[list[dict], str]:
"""Detect the archive format and return (specs, source). A ThoughtSync export
is recognized by its notes.json (app == thoughtsync); otherwise each Keep-shaped
<note>.json is imported. Returns ([], "") when nothing importable is found."""
"""Detect the archive format and return (specs, source). An Inkwell export
is recognized by its notes.json (app in NATIVE_APP_MARKERS); otherwise each
Keep-shaped <note>.json is imported. Returns ([], "") when nothing importable
is found."""
names = zf.namelist()
for name in names:
if posixpath.basename(name) == "notes.json":
@@ -196,9 +203,9 @@ def _read_import_specs(zf: zipfile.ZipFile, budget: _ImportBudget) -> tuple[list
doc = json.loads(budget.read(zf, name))
except (ValueError, KeyError):
continue
if isinstance(doc, dict) and doc.get("app") == "thoughtsync":
if isinstance(doc, dict) and doc.get("app") in NATIVE_APP_MARKERS:
specs = [_native_spec(n) for n in (doc.get("notes") or []) if isinstance(n, dict)]
return specs, "thoughtsync"
return specs, "inkwell"
keep_specs: list[dict] = []
keep_keys = ("textContent", "listContent", "isPinned", "isArchived", "isTrashed", "userEditedTimestampUsec")
@@ -255,7 +262,7 @@ async def _create_imported_note(
body = spec.get("body") or ""
# An imported title becomes the note's FIRST BODY LINE.
#
# ThoughtSync has no title field any more (M13 step 3), but the things people
# Inkwell has no title field any more (M13 step 3), but the things people
# import from do — Keep notes carry one, and so does any export taken before this.
# Dropping it would silently lose text someone wrote; folding it into the body puts
# it exactly where a name now lives, so the note comes in named the way it was.
+2 -2
View File
@@ -1,12 +1,12 @@
import pytest
from thoughtsync.config import Config
from inkwell.config import Config
@pytest.fixture(autouse=True)
def _isolated_data_dir(tmp_path, monkeypatch):
"""Keep any media writes on an isolated temp dir rather than the fixed
/var/thoughtsync. DB-free unit tests never actually hit it (create_app takes its
/var/inkwell. DB-free unit tests never actually hit it (create_app takes its
signing key from env-or-random, not a file), but this stays defensive."""
monkeypatch.setattr(Config, "DATA_DIR", str(tmp_path / "data"))
yield
+2 -2
View File
@@ -1,7 +1,7 @@
import uuid
from thoughtsync.acl import visible_to_user
from thoughtsync.models.user import User
from inkwell.acl import visible_to_user
from inkwell.models.user import User
def test_visible_to_user_builds_owner_or_shared_predicate():
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest
from thoughtsync.app import create_app
from inkwell.app import create_app
@pytest.fixture
+2 -2
View File
@@ -1,6 +1,6 @@
import pytest
from thoughtsync.app import create_app
from inkwell.app import create_app
@pytest.fixture
@@ -61,7 +61,7 @@ async def test_no_version_in_the_environment_reports_unknown(monkeypatch):
happens to hold, so bumping the packaging version cannot make this pass for the
wrong reason.
"""
from thoughtsync import __version__
from inkwell import __version__
monkeypatch.delenv("APP_VERSION", raising=False)
reported = await reported_version()
+14 -10
View File
@@ -3,9 +3,9 @@ from pathlib import Path
import pytest
from thoughtsync import client_dist
from thoughtsync.app import create_app
from thoughtsync.client_dist import (
from inkwell import client_dist
from inkwell.app import create_app
from inkwell.client_dist import (
APK_NAME,
BY_ID,
MANIFEST_NAME,
@@ -15,7 +15,7 @@ from thoughtsync.client_dist import (
release,
releases,
)
from thoughtsync.config import Config
from inkwell.config import Config
# DB-free, like the rest of this suite — the test lane runs no Postgres. That is
# why the advertisement is asserted through `advertisement()` rather than through
@@ -61,7 +61,7 @@ def coded(platform_id: str, value: int):
def _empty_baked_client(tmp_path, monkeypatch):
"""Point the baked-in copy at an empty directory.
In a source checkout `src/thoughtsync/client/` does not exist, so these tests
In a source checkout `src/inkwell/client/` does not exist, so these tests
would pass anyway — but only by accident of where they are run. A built image
has real clients there, and a test that silently depends on which tree it is in
is one that will eventually lie.
@@ -118,13 +118,17 @@ def test_every_platform_has_its_own_filenames():
assert not set(artifacts) & set(sidecars)
def test_the_android_names_are_the_ones_already_published():
"""Pinned because renaming them is a tidy-up that strands every installed phone.
def test_the_android_names_are_the_ones_the_image_build_writes():
"""Pinned against `packaging/fetch-clients.sh`, which writes the APK and its
sidecar under these names when it bakes them into the image. The two are a pair
with nothing checking them against each other, so a rename on one side reads as
"no Android client" on the other rather than as an error.
The Android lane publishes these exact names and CI bakes them in under them.
Phones never ask for these names; they poll `/api/client/android`. The names
changed once, with the rename to Inkwell (Scribe note 5071).
"""
assert APK_NAME == "thoughtsync.apk"
assert MANIFEST_NAME == "thoughtsync-android.json"
assert APK_NAME == "inkwell.apk"
assert MANIFEST_NAME == "inkwell-android.json"
# --- absence is an ordinary answer -------------------------------------------
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest
from thoughtsync.app import create_app
from inkwell.app import create_app
@pytest.fixture
+17 -17
View File
@@ -22,20 +22,20 @@ import pytest
import pytest_asyncio
from sqlalchemy import select, text
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel
from thoughtsync.models.note import Note
from thoughtsync.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title
from thoughtsync.models.note_link_preview import NoteLinkPreview
from thoughtsync.models.note_revision import NoteRevision
from thoughtsync.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from thoughtsync.unfurl_queue import _unfurl_new_urls, detect_urls
from inkwell import ratelimit
from inkwell.app import create_app
from inkwell.db import dispose_engine, session_scope
from inkwell.models.label import NoteLabel
from inkwell.models.note import Note
from inkwell.models.user import User
from inkwell.notes.tags import _lift_and_reconcile_tags
from inkwell.settings import get_setting, live, refresh_live, reset_live, set_settings
from inkwell.notes.checklist import parse_items, set_item_checked
from inkwell.notes.helpers import derive_display_title
from inkwell.models.note_link_preview import NoteLinkPreview
from inkwell.models.note_revision import NoteRevision
from inkwell.revisions import REVISION_WINDOW_MINUTES, should_snapshot
from inkwell.unfurl_queue import _unfurl_new_urls, detect_urls
pytestmark = pytest.mark.integration
@@ -305,7 +305,7 @@ async def test_auto_unfurl_stores_a_preview_and_skips_what_is_cached(db, owner,
calls.append(url)
return {"url": url, "title": f"T {url}", "description": None, "image_url": None, "site_name": "example.com"}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
monkeypatch.setattr("inkwell.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, note.body)
assert sorted(calls) == ["https://example.com/a", "https://example.com/b"]
@@ -331,7 +331,7 @@ async def test_auto_unfurl_drops_a_preview_whose_url_left_the_body(db, owner, mo
# Simulate the body changing while the request was in the air.
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
monkeypatch.setattr("inkwell.unfurl_queue.unfurl", fake_unfurl)
note.body = "changed my mind"
await db.commit()
@@ -352,7 +352,7 @@ async def test_detection_agrees_with_what_gets_stored(db, owner, monkeypatch):
async def fake_unfurl(url):
return {"url": url, "title": "T", "description": None, "image_url": None, "site_name": None}
monkeypatch.setattr("thoughtsync.unfurl_queue.unfurl", fake_unfurl)
monkeypatch.setattr("inkwell.unfurl_queue.unfurl", fake_unfurl)
await _unfurl_new_urls(note.id, body)
stored = {
+3 -3
View File
@@ -2,9 +2,9 @@ import uuid
import pytest
from thoughtsync.app import create_app
from thoughtsync.models.label import Label
from thoughtsync.serialize import serialize_label
from inkwell.app import create_app
from inkwell.models.label import Label
from inkwell.serialize import serialize_label
@pytest.fixture
+35 -8
View File
@@ -1,12 +1,15 @@
import io
import json
import zipfile
from datetime import datetime, timezone
import pytest
from thoughtsync.app import create_app
from thoughtsync.common import coerce_bool, parse_dt
from thoughtsync.colors import NOTE_COLORS
from thoughtsync.models.note import Note
from thoughtsync.notes.checklist import (
from inkwell.app import create_app
from inkwell.common import coerce_bool, parse_dt
from inkwell.colors import NOTE_COLORS
from inkwell.models.note import Note
from inkwell.notes.checklist import (
append_item,
parse_items,
remove_item,
@@ -14,13 +17,15 @@ from thoughtsync.notes.checklist import (
set_item_text,
strip_marker,
)
from thoughtsync.unfurl_queue import detect_urls
from thoughtsync.notes import (
from inkwell.unfurl_queue import detect_urls
from inkwell.notes import (
_ImportBudget,
_attachment_ext,
_header_filename,
_keep_spec,
_native_spec,
_note_markdown,
_read_import_specs,
_safe_filename,
_slugify,
_usec_to_dt,
@@ -32,7 +37,8 @@ from thoughtsync.notes import (
parse_list_items,
parse_tags,
)
from thoughtsync.notes.tags import split_body_tags
from inkwell.notes.import_export import NATIVE_APP_MARKERS
from inkwell.notes.tags import split_body_tags
@pytest.fixture
@@ -672,3 +678,24 @@ def test_native_spec_of_a_pre_m304_export_still_carries_its_items():
# items at all.
old = {"body": "shopping", "items": [{"text": "milk", "checked": True}]}
assert _native_spec(old)["items"] == [{"text": "milk", "checked": True}]
def _export_zip(app_marker: str) -> zipfile.ZipFile:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("notes.json", json.dumps({"app": app_marker, "notes": [{"body": "kept"}]}))
return zipfile.ZipFile(io.BytesIO(buf.getvalue()))
@pytest.mark.parametrize("marker", ["inkwell", "thoughtsync"])
def test_an_export_imports_under_either_name(marker):
# "thoughtsync" is what every export written before the rename to Inkwell says.
# Those zips are backups, so they have to keep restoring as native exports rather
# than falling through to the Keep importer, which would find nothing in them.
specs, source = _read_import_specs(_export_zip(marker), _ImportBudget())
assert source == "inkwell"
assert [s["body"] for s in specs] == ["kept"]
def test_new_exports_are_written_as_inkwell():
assert NATIVE_APP_MARKERS[0] == "inkwell"
+2 -2
View File
@@ -4,8 +4,8 @@ The whole security property is "a caller cannot forge their own address", and it
on counting in from the RIGHT of the header rather than the left. These are the cases
that tell the two apart — pure functions, no request context, no database.
"""
from thoughtsync.proxy import forwarded_for, trusted_entry
from thoughtsync.settings import live
from inkwell.proxy import forwarded_for, trusted_entry
from inkwell.settings import live
PEER = "10.0.0.1" # the socket address: our own proxy, or the caller when unproxied
+4 -4
View File
@@ -12,10 +12,10 @@ import time
import pytest
from thoughtsync import ratelimit
from thoughtsync.settings import live
from thoughtsync.app import create_app
from thoughtsync.ratelimit import SlidingWindow
from inkwell import ratelimit
from inkwell.settings import live
from inkwell.app import create_app
from inkwell.ratelimit import SlidingWindow
def window(limit: int, window_s: float) -> SlidingWindow:
+2 -2
View File
@@ -2,14 +2,14 @@ from datetime import datetime, timedelta, timezone
import pytest
from thoughtsync.retention import (
from inkwell.retention import (
SWEEP_BATCH,
SWEEP_INTERVAL_SECONDS,
SWEEP_STARTUP_DELAY_SECONDS,
expired_before,
sweep_expired_trash,
)
from thoughtsync.settings import REGISTRY, get_public_config, validate_updates
from inkwell.settings import REGISTRY, get_public_config, validate_updates
NOW = datetime(2026, 7, 26, 12, 0, tzinfo=timezone.utc)
+2 -2
View File
@@ -1,7 +1,7 @@
import pytest
from thoughtsync.app import create_app
from thoughtsync.saved_filters import clean_params
from inkwell.app import create_app
from inkwell.saved_filters import clean_params
@pytest.fixture
+1 -1
View File
@@ -1,4 +1,4 @@
from thoughtsync.security import generate_token, hash_password, hash_token, verify_password
from inkwell.security import generate_token, hash_password, hash_token, verify_password
def test_password_roundtrip():
+1 -1
View File
@@ -6,7 +6,7 @@ hook, and this suite has no Postgres.
"""
import pytest
from thoughtsync.app import create_app
from inkwell.app import create_app
@pytest.fixture
+1 -1
View File
@@ -1,4 +1,4 @@
from thoughtsync.settings import REGISTRY, validate_updates
from inkwell.settings import REGISTRY, validate_updates
def test_registry_has_expected_keys():
+2 -2
View File
@@ -2,8 +2,8 @@ from datetime import datetime, timezone
import pytest
from thoughtsync.app import create_app
from thoughtsync.sync import (
from inkwell.app import create_app
from inkwell.sync import (
DEFAULT_LIMIT,
MAX_LIMIT,
MIN_CLIENT_PROTOCOL_VERSION,
+2 -2
View File
@@ -2,8 +2,8 @@ import ipaddress
import pytest
from thoughtsync.app import create_app
from thoughtsync.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url
from inkwell.app import create_app
from inkwell.unfurl import UnfurlError, extract_preview, is_public_ip, validate_url
@pytest.fixture