尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Free, open-source, self-hosted wardrobe organizer. Catalog clothes, build outfits, PWA. Docker one-liner deploy.
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗A private, self-hosted household wardrobe PWA. Catalog garments with photos (backgrounds removed automatically), compose outfits, plan them on a calendar, and share a wardrobe between users.
A fork of Libre Closet by Lazztech LLC, licensed under the AGPL-3.0.
Closet needs a PostgreSQL 13+ database (production runs 18).
services:
closet:
image: ghcr.io/agandhi4/closet:latest
ports:
- '3000:3000'
volumes:
- closet_data:/app/data
environment:
# Required: signs the session cookies. openssl rand -hex 32
ACCESS_TOKEN_SECRET: '<secret>'
# The household's time zone: decides "today" on the calendar.
APP_TIMEZONE: America/New_York
PWA_ENABLED: 'true'
# Required when PWA_ENABLED is true: npx web-push generate-vapid-keys
PUBLIC_VAPID_KEY: '<public key>'
PRIVATE_VAPID_KEY: '<private key>'
DATA_PATH: /app/data
DATABASE_HOST: postgres
DATABASE_SCHEMA: closet
DATABASE_USER: closet
DATABASE_PASS: '<password>'
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: closet
POSTGRES_PASSWORD: '<password>'
POSTGRES_DB: closet
volumes:
- closet_pg:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U closet -d closet']
interval: 5s
retries: 10
restart: unless-stopped
volumes:
closet_data:
closet_pg:
Postgres 18 is current and is what production runs. The example keeps postgres:17-alpine because the 18 image expects its volume at /var/lib/postgresql, not /var/lib/postgresql/data, so moving an existing database to 18 means a dump and restore, not an image bump.
Open http://localhost:3000 and register an account: login is always required. Once everyone in the household has signed up, set DISABLE_REGISTRATION=true.
Closet is an MCP server: your own Claude (Claude Code, Claude Desktop) can search your wardrobe, add pieces from product links, build outfits and capsules, plan days, keep track of wears and laundry, and compare your closet with a wardrobe shared with you to talk through what to buy next.
In the app, open Profile › Agent Access (/auth/tokens) and create a token; it asks for your password, since a token keeps working after you sign out. The token is shown once, and the page also shows the command below with it filled in.
Add the server to Claude Code:
claude mcp add --transport http closet https://closet.kashhq.dedyn.io/mcp \
--header "Authorization: Bearer <token>"
Use your own SITE_URL in place of the address. In Claude Desktop, add the same URL and header as a remote MCP server.
The token acts as you, shares included: a wardrobe shared with you read-only stays read-only. Tools that write say so, and none deletes anything. Revoke a token on the same page; changing your password revokes every token. The endpoint is rate-limited per token. The app is reachable only over the tailnet; an agent off the tailnet uses the public endpoint instead: https://closet-mcp.kashhq.com/mcp, the same Authorization: Bearer <token> header, Streamable HTTP (POST, JSON answers). It exists only when the owner deploys the homelab gate (a Caddy that forwards POST /mcp and answers 404 to everything else); the app's pages stay tailnet-only. Anything that can send a bearer header can use it; Claude's hosted connectors (claude.ai) that need OAuth still cannot, because OAuth is not built.
An agent connected this way (Claude, or a styling agent such as Muse) suggests what to buy and how to wear it, as wishlist garments and outfits you decide on in the app.
Muse): the name is how the app tells you who suggested what.get_suggestion_feedback: your choices, purchases and the outfits you loved or declined, with every reason and note) and what stands where (list_suggestions). It reads your style profile, your whole closet (with how often each piece is worn), your sizes per brand and photos where it needs them, and checks what your closet already covers (get_closet_coverage), so it proposes only real gaps and treats pieces marked replace soon as gaps. For each need it opens one on your wishlist ("A navy blazer", with a budget and why) and adds two to five real products as its options (suggest_garment), each with a note, its rank, the price and the size to buy. Then it proposes outfits that mix your clothes with its options (suggest_outfit), and ends the round (finish_round)..env contains committed defaults. Override any value via a .env.local file (gitignored) or by passing real environment variables to Docker.
| Variable | Description | Default | Example |
|---|---|---|---|
APP_NAME | Display name shown in the UI and navbar | Closet | My awesome Closet manager |
APP_TIMEZONE | The household's IANA time zone: what "today" is on the calendar and which week opens by default | America/New_York | Europe/Berlin |
ICON_NAME | Icon file under public/assets/ used for share previews and the photo watermark; the manifest, the install dialog and the home-screen icon use its <name>-192.png and <name>-512.png siblings, which must be there too | icon.png | my-icon.png |
SITE_URL | Public origin, used for absolute links in share previews and as the Web Push (VAPID) contact, which must be https: when PWA_ENABLED=true | http://localhost:3000 | https://closet.example.com |
DATA_PATH | Directory for uploaded files and app.log | ./data | ./closet-data |
DISABLE_REGISTRATION | Disallows user sign ups when true | false | true |
PASSWORD_HASH_ROUNDS | bcrypt cost of every password hash and sign-in comparison; below 12 only outside APP_ENV=production (the test tiers run at 4) | 12 | 4 |
PWA_ENABLED | Enable service worker, PWA install prompt and Web Push notifications | false | true |
WATERMARK_ENABLED | Composite the app icon onto share-link preview images | false | true |
ACCESS_TOKEN_SECRET | Signs session tokens (required, at least 32 characters; the server refuses to start without it) | - | <openssl rand -hex 32> |
Generate the session signing secret (anyone who knows it can sign in as any user, so never reuse an example value; changing it signs everyone out):
openssl rand -hex 32
Generate VAPID keys:
npx web-push generate-vapid-keys
.nvmrc, Node 22) - install via nvm../pgvault-dev compose project: Postgres on
localhost:5432, superuser postgres, trust auth).nvm install && nvm use
npm install
make install # git hooks, once per checkout or worktree
(cd ../pgvault-dev && docker compose up -d --wait)
docker compose -f ../pgvault-dev/docker-compose.yml exec postgres \
psql -U postgres -c 'create database closet_db' # once
cat > .env.local <<ENV # gitignored
ACCESS_TOKEN_SECRET=$(openssl rand -hex 32)
DATABASE_HOST=localhost
DATABASE_SCHEMA=closet_db
DATABASE_USER=postgres
DATABASE_PASS=
ENV
npm run start:dev
The integration tier and the page audit never touch closet_db: each run
creates scratch databases on the server named by TEST_DATABASE_URL
(default postgres://postgres@localhost:5432/postgres, pgvault-dev) and drops
them afterwards.
npm run start:dev # tsc --watch + node --watch + tailwind --watch
npm run build # tsc (type-checked) to dist/, plus Tailwind, the service worker and the cache key
npm run start:prod # node dist/main.js
npm run start:test # the build with background removal stubbed (what Playwright
# and Lighthouse start; no model download)
npm run test # Vitest unit tests (test:watch to rerun on change)
npm run test:int # Vitest integration tests (real app in-process, scratch Postgres database per file)
npm run test:all # both Vitest tiers in one run
npm run test:e2e # build, then Playwright end-to-end (ten critical-path scenarios; make screenshots for the shots)
npm run test:cov # both Vitest tiers with v8 coverage (coverage/); the gate's metrics ratchet holds lines and branches
npm run audit:pages # every page and action measured as the demo persona, see below
npm run generate:icons # regenerate public/assets/icon.png, icon-192.png, icon-512.png and favicon.ico from icon.svg
npm run check # format, lint, types, unit + integration in parallel (check:static is the first three)
npm run verify:push # build + Chromium Playwright (PRs are verified by the gate's e2e job)
npm run maintenance:reconcile [-- --dry-run] [--force]
# one storage reconciliation pass (needs `npm run build`; see below)
npm run user:set-password -- <email>
# set a locked-out user's password (needs `npm run build`; see below)
npm run cutout:fetch-model
# download or verify the background-removal model into MODELS_PATH
# (needs `npm run build`; see Background removal)
The server downloads the model (940 MB) into MODELS_PATH at its first boot,
so start:dev and start:prod fetch it once into ./models; point
MODELS_PATH at an existing copy in .env.local to skip that. npm run test:int includes one test with the real model; it runs only when
MODELS_PATH (or ./models) holds it and is skipped otherwise (CI never
downloads it).
There is no password reset by email. Whoever runs the server sets a new
password for an account with npm run user:set-password -- <email> (in a
container: docker exec -it closet npm run user:set-password -- <email>). It
asks for the password twice without echoing it (or reads one line from piped
stdin), applies the registration rules, and signs out every existing session
of that account. An unknown email changes nothing and exits with status 1.
Neither this nor the storage maintenance command migrates the database: they refuse, with exit status 1, a database the running build has not migrated yet. Start the server of that build first (it migrates at boot).
Every photo is a set of files in storage (<uuid>.webp plus -nobg and
-thumb variants) and one file row. Deleting a garment or an account
removes both, and a nightly job (03:00 in APP_TIMEZONE, MAINTENANCE_ENABLED)
keeps them describing each other: stored photo sets older than a day with no
row are deleted, rows older than a day that no garment references are deleted
with their files, and rows whose original is missing are logged. Run it by
hand, on the NAS with docker exec closet npm run maintenance:reconcile, or
locally after npm run build; -- --dry-run only reports.
A run that would delete anything while the file table is empty, more than
25 photo sets, or more than a fifth of the stored ones (once it is more than 5) deletes nothing: it logs why, reports refused and exits with status 3.
That is the database and the disk disagreeing wholesale (a restore, the wrong
database), not stray uploads. Check with -- --dry-run, then
-- --force deletes anyway.
Every garment photo gets a cutout (its background removed) on the server.
The phone first shrinks a large photo to 1600 px (JPEG) and sends only the
photo. The server queues it (the garment page shows "Removing background…"
and swaps the cutout in when it is ready, or offers "Try again" if it
failed) and runs
BiRefNet 512x512
(MIT) with onnxruntime on the CPU, in a child process, one photo at a time:
about 3 s a photo on a recent 8-core CPU (7 s for the first after a start or
15 idle minutes, which loads the model) and up to ~3.5 GB of RAM while the
model is loaded. It needs a CPU with AVX2 or better. A photo that takes over
60 s is failed; failed ones are retried nightly (03:00 in APP_TIMEZONE) up
to three runs. The mask editor (the pencil on the photo) edits the cutout,
and an edited cutout is never replaced by the server's.
The model is not in the image. It is downloaded into MODELS_PATH at boot
when it is not there yet, from a pinned URL, and refused unless its SHA-256
matches. Give it a local volume so it is fetched once; to seed or repair it:
docker exec closet npm run cutout:fetch-model # download or verify; prints the path
Photos stored before background removal moved to the server (it ran in the browser until 2026-09-26) keep the cutouts the browser made.
npm run audit:pages builds the app, boots it in process on a scratch
Postgres database with the demo persona seeded, and measures every page,
fragment, action, MCP tool and background job as Theo: server time p50/p95,
SQL statements and rows per request, response bytes. It writes
scripts/results/audit.md and .json; -- --compare docs/perf/baseline.json
prints what changed against the committed baseline. How a page PR uses it:
docs/perf/README.md.
The schema is src/db/schema.ts (Drizzle). The app applies the migrations in
drizzle/ on every boot, so a deploy is the migration.
# After editing src/db/schema.ts: writes drizzle/NNNN_<name>.sql and its
# snapshot. Needs no database. Read the SQL before committing it.
npx drizzle-kit generate --name <what-changed>
Databases created before Drizzle took over (built by MikroORM's migrations) are adopted on their first boot: the runner checks that the last MikroORM migration was applied and records the Drizzle baseline without running it.
A migration that converts data refuses to guess: it fails the boot, changes
nothing, and names what to fix. 0004_garment_web (garment colours become a
fixed set, the acquisition date a plain date) stops on a colour outside the
built-in set (older builds let people type their own), an acquisition date
that is not UTC midnight, a blank category, or a share id two rows hold. To
check a database before upgrading:
SELECT DISTINCT unnest(string_to_array(color, ',')) FROM garment; -- built-in names only
SELECT count(*) FROM garment
WHERE (date_aquired AT TIME ZONE 'UTC')::time <> '00:00'
OR btrim(category) = ''; -- 0
# Build image
docker build --no-cache . -t closet:latest
# Cross-compile for linux/amd64 (e.g. building on Apple Silicon for a VPS)
docker buildx build --platform linux/amd64 --no-cache . -t closet:latest
One Node 22 process: plain Fastify 5 serving server-rendered JSX pages with htmx, Drizzle over PostgreSQL, photos on disk under DATA_PATH, and background removal in a child process running an ONNX model. A Workbox service worker makes it an installable PWA, and /mcp exposes the wardrobe to Claude. The source tree and the area index are in AGENTS.md, each piece in full in docs/architecture.md, and the web layer in docs/web-layer.md.
This instance runs on the homelab, and merging to main deploys it: the homelab's linux-box publisher (make publish, every 5 min) pushes ghcr.io/agandhi4/closet:latest and a ship-<date>-<sha7> tag, and the hourly autoupdater pulls the image. The pieces, the production env, deploying by hand and recovery are in docs/operations.md.
For other self-hosters: deploy to a VPS via Coolify or Portainer using the docker-compose above, with local storage. Back up both the Postgres database and DATA_PATH, taken close together: the nightly storage reconciliation deletes photos that no database row references.
src/ area has its own AGENTS.md).Closet is a hard fork and does not track upstream. This project is licensed under AGPL-3.0 - contributions must be compatible with that license.
TRUSTED_PROXIES |
Comma-separated IPs/CIDRs of reverse proxies whose X-Forwarded-* headers are trusted (login rate limits, the cross-site request check, canonical URLs). Behind a reverse proxy it must include the proxy's address |
127.0.0.1,::1 |
172.16.0.0/12 |
LOG_LEVEL | pino level for the console and app.log (trace … fatal, or silent) | info | debug |
LOG_FORMAT | The shape of those lines: json, one object per line with ts, level, version, logger and a request's request_id (a job's job_id) (production; Loki reads it), or text, pino-pretty for a terminal (npm run start:dev sets it) | json | text |
APP_ENV | Where this process runs, as the error tracker's environment: production, dev (npm run start:dev sets it) or gate (the image smoke test). Never derived from NODE_ENV | production | dev |
DATABASE_HOST | Postgres host (required) | - | 192.168.10.5 |
DATABASE_PORT | Postgres port | 5432 | 9867 |
DATABASE_USER | Postgres user (required) | - | postgres |
DATABASE_PASS | Postgres password (required; may be empty for trust auth) | - | 7yfhcn2349cr32f |
DATABASE_SCHEMA | Postgres database name (required) | - | closet |
DATABASE_SSL | Use SSL for Postgres | false | true |
MAINTENANCE_ENABLED | Run the nightly storage reconciliation (03:00 in APP_TIMEZONE); npm run maintenance:reconcile runs it once regardless | true | false |
MAX_HEIC_BYTES | Largest HEIC/HEIF upload accepted; HEIC is decoded in memory before resizing | 41943040 (40 MB) | 20971520 |
MODELS_PATH | Where the background-removal model (940 MB) is kept; downloaded there at boot when missing and checksum-verified. A local disk, not NFS | ./models (/app/models in the image) | /app/models |
CUTOUT_THREADS | CPU threads the background-removal model uses | 4 | 8 |
CUTOUT_POLL_SECONDS | How often an idle cutout queue looks for photos no notification announced (a backstop: writes notify it at once) | 60 | 30 |
WEATHER_ENABLED | Weather forecasts (Open-Meteo, fetched by the server for each user's location rounded to about 1 km) on the page headers, the calendar and the MCP tools. false fetches nothing and stores no location | true | false |
METRICS_ENABLED | Prometheus metrics at GET /metrics (request, job, push, MCP and device timings) for a scraper on the container's network; a request that came through a proxy (X-Forwarded-For) gets a 404 there. Pages then send their timings to POST /metrics/vitals | false | true |
APP_VERSION | The release, set by the image itself (the 7-char sha, baked in by CI); /health/ready reports it as version. Never set it in compose or .env: the image is its only source | the image's | 1a2b3c4 |
SENTRY_DSN | Error tracking: the DSN of a Sentry-compatible tracker (the homelab's Bugsink). Server errors (5xx, failed jobs, push sends, MCP tools) and the signed-in pages' script errors (POST /errors/client) are sent there, scrubbed of cookies and tokens. Empty: off, nothing sent and no script on pages | empty | http://<key>@bug.box/3 |
ORDER_MAIL_JMAP_TOKEN | Order email import: a read-only Fastmail API token (Email scope) for the account order emails are forwarded to. Product links in them become a "From your orders" review list. Empty: off, nothing polled. See docs/operations.md | empty | fmu1-… |
ORDER_MAIL_SENDERS | The owner's own addresses, comma-separated: only mail they forwarded, with DKIM or SPF passing, is read. Required with the token | empty | [email protected],[email protected] |
ORDER_MAIL_OWNER | The sign-in email of the closet account the imported items belong to. Required with the token | empty | [email protected] |
ORDER_MAIL_POLL_MINUTES | How often the order mail account is checked (1-60) | 5 | 10 |
PUBLIC_VAPID_KEY | Web push - required when PWA_ENABLED=true, generate with npx web-push generate-vapid-keys | - | <from web-push> |
PRIVATE_VAPID_KEY | Web push - required when PWA_ENABLED=true, generate with npx web-push generate-vapid-keys | - | <from web-push> |