一人团队的 Linear 式项目管理系统开源实现。
Involute bundles a GraphQL API, a kanban web app, and a CLI that can export one Linear team, import it into Involute, verify the result, and then let you visually accept it in the board UI.
packages/server— GraphQL API, Prisma-backed data model, import pipeline, validation helperspackages/web— React + Vite kanban UIpackages/cli—involuteCLI for config, import/export, teams, issues, labels, and commentspackages/shared— shared TypeScript utilitiesdocs/vision.md— current product visiondocs/milestones.md— active milestones and sequencing
Create a repo-root .env file based on .env.example:
DATABASE_URL=postgresql://involute:[email protected]:5434/involute?schema=public
AUTH_TOKEN=changeme-set-your-token
VIEWER_ASSERTION_SECRET=compose-viewer-secret
APP_ORIGIN=http://localhost:4201
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:4200/auth/google/callback
ADMIN_EMAIL_ALLOWLIST=[email protected]
PORT=4200Required server variables:
DATABASE_URL— PostgreSQL connection stringAPP_ORIGIN— browser origin used for cookie/CORS handling and post-login redirectsPORT— API port (defaults to4200)
Optional but recommended server variables:
AUTH_TOKEN— trusted bearer token used by the CLI and local/dev bootstrap flowsVIEWER_ASSERTION_SECRET— HMAC secret used to verify signed viewer assertions for trusted impersonationGOOGLE_OAUTH_CLIENT_ID— Google OAuth client id for browser sign-inGOOGLE_OAUTH_CLIENT_SECRET— Google OAuth client secretGOOGLE_OAUTH_REDIRECT_URI— Google callback URL handled by the API serverADMIN_EMAIL_ALLOWLIST— comma-separated allowlist of emails that should becomeADMINSESSION_TTL_SECONDS— browser session lifetime in secondsSEED_DEFAULT_ADMIN— dev/test-only switch to seed[email protected]; keep thisfalseoutside local acceptance flowsPRISMA_BASELINE_EXISTING_SCHEMA— one-time upgrade switch for pre-migration databases that already have the schema but no_prisma_migrationshistory
Compatibility note:
GOOGLE_OAUTH_ADMIN_EMAILSis still accepted as a legacy alias, but new deployments should useADMIN_EMAIL_ALLOWLIST
Optional web runtime variables:
VITE_INVOLUTE_GRAPHQL_URL— override the web app GraphQL endpoint (default:http://localhost:4200/graphql)VITE_INVOLUTE_AUTH_TOKEN— trusted local/dev bearer token for bypassing browser loginVITE_INVOLUTE_VIEWER_ASSERTION— signed viewer assertion to act as a specific user without exposing the server secret
pnpm install
cp .env.example .env
pnpm compose:upSmoke check:
curl http://localhost:4200/health
curl http://localhost:4201Then open http://localhost:4201 in your browser.
If Google OAuth is configured, the web nav will expose Sign in with Google and use session cookies. If it is not configured, the browser can still talk to the API with VITE_INVOLUTE_AUTH_TOKEN for trusted local development.
Compose defaults:
- API:
http://localhost:4200 - Web:
http://localhost:4201 - Postgres:
127.0.0.1:5434 - CLI export mount: tracked
.tmp/on the host is available as/exportsin theclicontainer - Compose uses the
web-devDocker target for the live Vite UI; the publishedinvolute-webimage uses the productionwebtarget server-initnow applies Prisma migrations withprisma migrate deploybefore seeding
Stop the stack with:
pnpm compose:downThis is the recommended first production path: one VPS, Docker Compose, Postgres, the Node API, the static web container, and Caddy terminating HTTPS on a single domain.
Files involved:
Assumptions:
- a fresh host with Docker and Docker Compose installed
- a DNS record for
APP_DOMAINalready points at the VPS - a fresh Postgres volume; no legacy schema upgrade path is needed
- Copy the repo to the VPS and create the production env file:
cp .env.production.example .env.production- Fill at least these values in
.env.production:
APP_DOMAIN=involute.example.com
APP_ORIGIN=https://involute.example.com
POSTGRES_PASSWORD=...
AUTH_TOKEN=...
VIEWER_ASSERTION_SECRET=...
ADMIN_EMAIL_ALLOWLIST=[email protected]
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...
GOOGLE_OAUTH_REDIRECT_URI=https://involute.example.com/auth/google/callback- Bring the stack up:
pnpm compose:prod:up- Smoke check it:
docker compose --env-file .env.production -f docker-compose.prod.yml ps
curl -I https://involute.example.com
curl https://involute.example.com/health- If you need to re-assert the first admin explicitly:
docker compose --env-file .env.production -f docker-compose.prod.yml run --rm \
--entrypoint /bin/sh server -lc \
'pnpm --filter @involute/server run admin:bootstrap [email protected]'Operational notes:
- production compose keeps Postgres internal; only Caddy exposes
80/443 server-initrunsprisma migrate deploybefore the API startsSEED_DATABASEdefaults tofalsein production; turn it on only for a fresh demo seed- the web container is the static production build, not the Vite dev server
Backups:
sh scripts/postgres-backup.shThis writes a gzipped SQL dump to .backups/.
Manual SSH deployment is no longer the intended path. The repo now includes an Ansible workflow under ops/ansible.
Available playbooks:
ops/ansible/playbooks/bootstrap-host.yml— install Docker/Compose and prepare the hostops/ansible/playbooks/deploy.yml— sync the repo, render env, run compose, and verify health
Tailscale-specific deployment reuses docker-compose.yml and drives bind addresses through the rendered env file. Only 4200 and 4201 bind to the Tailscale IP; Postgres stays on 127.0.0.1.
Typical flow:
- Copy the example inventory:
cp ops/ansible/inventory/hosts.yml.example ops/ansible/inventory/hosts.yml-
Fill the target host, bind address, and secrets.
-
Prepare the host:
pnpm deploy:bootstrap- Deploy the Tailscale stack:
pnpm deploy:tailscaleFor the current Tailscale-only test phase, use:
involute_stack_profile: tailscaleinvolute_bind_address: <tailscale-ip>involute_app_origin: http://<tailscale-ip>:4201
When the public domain and OAuth are ready, switch the inventory to production and use docker-compose.prod.yml.
GitHub Actions can run the same deployment path from .github/workflows/deploy.yml. Configure these repository secrets before enabling it:
DEPLOY_HOSTDEPLOY_KNOWN_HOSTSDEPLOY_USERDEPLOY_SSH_PRIVATE_KEYINVOLUTE_APP_ORIGININVOLUTE_AUTH_TOKENINVOLUTE_VIEWER_ASSERTION_SECRETINVOLUTE_BIND_ADDRESSfortailscaleINVOLUTE_APP_DOMAINandINVOLUTE_POSTGRES_PASSWORDforproduction- optional:
INVOLUTE_ADMIN_EMAIL_ALLOWLIST,INVOLUTE_GOOGLE_OAUTH_CLIENT_ID,INVOLUTE_GOOGLE_OAUTH_CLIENT_SECRET,INVOLUTE_GOOGLE_OAUTH_REDIRECT_URI
Recommended repository variables:
INVOLUTE_DEPLOY_ON_MAIN=falseto keep deploy manual by defaultINVOLUTE_DEPLOY_PROFILE=tailscalefor the current private test phase
Set your Linear token in the shell:
export LINEAR_TOKEN='lin_api_xxx'Run the end-to-end team import inside the compose CLI container:
docker compose run --rm cli import team --token "$LINEAR_TOKEN" --team SON --keep-export --output /exports/son-exportWhat this does:
- exports one Linear team into
.tmp/son-export - imports the exported data into Involute
- runs
import verify - writes
.tmp/son-export/involute-import-summary.json
After it completes, open http://localhost:4201 and visually check the imported team in the board.
Recommended acceptance checks:
- the target team appears in the board
- issue count looks complete for that team
- a few issues have the expected state, labels, assignee, and comments
- the latest imported issues are visible in the board, not hidden behind the first page
Start the API:
DATABASE_URL="postgresql://involute:[email protected]:5434/involute?schema=public" AUTH_TOKEN="changeme-set-your-token" VIEWER_ASSERTION_SECRET="compose-viewer-secret" APP_ORIGIN="http://127.0.0.1:4201" GOOGLE_OAUTH_REDIRECT_URI="http://127.0.0.1:4200/auth/google/callback" pnpm --filter @involute/server exec tsx src/index.tsStart the web app:
VITE_INVOLUTE_AUTH_TOKEN="changeme-set-your-token" VITE_INVOLUTE_GRAPHQL_URL="http://127.0.0.1:4200/graphql" pnpm --filter @involute/web exec vite --host 127.0.0.1 --port 4201Run the CLI against that local API:
pnpm --filter @involute/cli exec node dist/index.js import team --token "$LINEAR_TOKEN" --team SON --keep-export --output .tmp/son-exportIf you need the CLI or web UI to act as a specific user, mint a short-lived viewer assertion with a trusted secret and persist it:
export INVOLUTE_VIEWER_ASSERTION_SECRET=compose-viewer-secret
pnpm --filter @involute/cli exec node dist/index.js auth viewer-assertion create [email protected] --ttl 3600
pnpm --filter @involute/cli exec node dist/index.js config set viewer-assertion SIGNED_ASSERTION_HEREThe web UI can use the same signed assertion via VITE_INVOLUTE_VIEWER_ASSERTION or localStorage key involute.viewerAssertion.
- Browser auth now supports Google OAuth plus session cookies.
AUTH_TOKENand viewer assertions remain available for trusted CLI/dev flows.- System admins can be bootstrapped through
ADMIN_EMAIL_ALLOWLISTorpnpm --filter @involute/server admin:bootstrap [email protected]. - Teams now have
PUBLIC/PRIVATEvisibility. - Team edits are gated by membership role:
EDITORorOWNER. - Team access management is available in the web UI at
/settings/accessand through GraphQL mutations:teamUpdateAccess,teamMembershipUpsert, andteamMembershipRemove.
Use Prisma migrations as the default schema workflow:
pnpm --filter @involute/server prisma:migrate:dev -- --name your_change
pnpm --filter @involute/server prisma:migrate:deployUseful admin/database commands:
pnpm --filter @involute/server admin:bootstrap [email protected]
pnpm --filter @involute/server prisma:migrate:baseline
pnpm --filter @involute/server prisma:migrate:reset
pnpm --filter @involute/server prisma:db:pushGuidance:
- prefer
prisma:migrate:devwhile changing the schema locally - use
prisma:migrate:deployin compose, CI, and production - keep
prisma:db:pushas an explicit development-only escape hatch, not the default deployment path - if you are upgrading an older database that predates
prisma/migrations, runprisma:migrate:baselineonce before the firstprisma:migrate:deploy, or setPRISMA_BASELINE_EXISTING_SCHEMA=truefor a one-time compose bootstrap
Unit and integration checks:
pnpm typecheck
pnpm lint
pnpm test
pnpm buildBrowser E2E:
pnpm e2eThe Playwright suite verifies the core board lifecycle: create, update, comment, delete comment, and delete issue.
This repo ships one multi-target Dockerfile with server, web-dev, web, and cli targets. The Docker Hub publish workflow expects these secrets:
DOCKERHUB_USERNAMEDOCKERHUB_TOKEN
When they are set, .github/workflows/docker-publish.yml pushes:
${DOCKERHUB_USERNAME}/involute-server${DOCKERHUB_USERNAME}/involute-web${DOCKERHUB_USERNAME}/involute-cli
The published involute-web image is a static production build. It bakes VITE_INVOLUTE_GRAPHQL_URL at build time, but it does not bake an auth token into the image. For local development and acceptance, the compose stack remains the reference runtime path and should stay green before publishing.
pnpm --filter @involute/cli exec node dist/index.js teams list
pnpm --filter @involute/cli exec node dist/index.js issues list --team SON
pnpm --filter @involute/cli exec node dist/index.js issues create --team SON --title "My issue"
pnpm --filter @involute/cli exec node dist/index.js comments add SON-1 --body "Hello from Involute"
pnpm --filter @involute/cli exec node dist/index.js export --token "$LINEAR_TOKEN" --team SON --output .tmp/son-export
pnpm --filter @involute/cli exec node dist/index.js import --file .tmp/son-export
pnpm --filter @involute/cli exec node dist/index.js import verify --file .tmp/son-export
pnpm --filter @involute/cli exec node dist/index.js import team --token "$LINEAR_TOKEN" --team SON- Make the current stack deployable on VPS and Railway
- Keep Google OAuth, admin bootstrap, and team RBAC stable while deployment hardens
- Move database changes through Prisma migrations instead of schema push shortcuts
- Keep the compose stack and CI reproducible while the product boundary hardens
See docs/vision.md and docs/milestones.md for the product direction.