From a5da107f70bd9f2226d57f971abe2941cd039f84 Mon Sep 17 00:00:00 2001 From: cori Date: Fri, 11 Sep 2026 16:44:21 +0000 Subject: [PATCH 1/3] feat: add hermes-obsidian-bridge app OpenAI-compatible proxy in front of Hermes api_server with session continuity, vault RAG (Obsidian Local REST API), and cost routing. Image sourced from local Forgejo (forge.407.lol/cori/hermes-obsidian-bridge). Schema-validated against @runtipi/common (114/114 bun test pass). --- apps/hermes-obsidian-bridge/config.json | 92 ++++++++++++++++++ .../docker-compose.json | 33 +++++++ .../metadata/description.md | 29 ++++++ apps/hermes-obsidian-bridge/metadata/logo.jpg | Bin 0 -> 5304 bytes 4 files changed, 154 insertions(+) create mode 100644 apps/hermes-obsidian-bridge/config.json create mode 100644 apps/hermes-obsidian-bridge/docker-compose.json create mode 100644 apps/hermes-obsidian-bridge/metadata/description.md create mode 100644 apps/hermes-obsidian-bridge/metadata/logo.jpg diff --git a/apps/hermes-obsidian-bridge/config.json b/apps/hermes-obsidian-bridge/config.json new file mode 100644 index 0000000..b060217 --- /dev/null +++ b/apps/hermes-obsidian-bridge/config.json @@ -0,0 +1,92 @@ +{ + "$schema": "../app-info-schema.json", + "name": "Hermes Obsidian Bridge", + "id": "hermes-obsidian-bridge", + "available": true, + "short_desc": "OpenAI-compatible proxy in front of Hermes api_server with session continuity and vault RAG", + "author": "cori", + "port": 9120, + "categories": [ + "ai", + "utilities" + ], + "description": "Thin OpenAI-compatible proxy in front of the Hermes api_server. Adds session continuity (X-Session-Id), vault RAG via the Obsidian Local REST API (X-Obsidian-Note-Path), and automatic cost routing between a cheap and strong model based on prompt length. Wire it into any OpenAI-compatible Obsidian plugin (Copilot for Obsidian, Text Generator, Local GPT) for in-note chat with the full Hermes agent. Image: forge.407.lol/cori/hermes-obsidian-bridge:sha-7139662d. Source: https://github.com/cori/hermes-obsidian-bridge.", + "tipi_version": 1, + "version": "sha-7139662d", + "source": "https://github.com/cori/hermes-obsidian-bridge", + "website": "https://github.com/cori/hermes-obsidian-bridge", + "exposable": true, + "supported_architectures": [ + "arm64", + "amd64" + ], + "created_at": 1757610000000, + "updated_at": 1757610000000, + "dynamic_config": true, + "form_fields": [ + { + "type": "password", + "label": "Hermes API Server Key", + "hint": "Bearer token for the Hermes api_server. Must match the API_SERVER_KEY on the gateway host. Required.", + "required": true, + "env_variable": "API_SERVER_KEY", + "default": "" + }, + { + "type": "text", + "label": "Hermes api_server URL", + "hint": "Base URL of the Hermes api_server (e.g. http://192.168.1.10:8642). Used by the bridge for chat and model listing. host.docker.internal works for Docker Desktop; on Linux Runtipi, use the host IP.", + "required": false, + "env_variable": "HERMES_BASE_URL", + "default": "http://host.docker.internal:8642/v1" + }, + { + "type": "text", + "label": "Obsidian Local REST API URL", + "hint": "Base URL of the Obsidian Local REST API plugin. Default 27123. On Tailscale setups, use the Mac's Tailscale IP.", + "required": false, + "env_variable": "OBSIDIAN_REST_URL", + "default": "http://host.docker.internal:27123" + }, + { + "type": "password", + "label": "Obsidian Local REST API Key", + "hint": "API key from the Obsidian Local REST API plugin (Settings → Local REST API → API Key). Leave empty if the plugin is configured for no auth on a trusted network.", + "required": false, + "env_variable": "OBSIDIAN_REST_KEY", + "default": "" + }, + { + "type": "text", + "label": "Cheap Model", + "hint": "Model id to route short prompts to. Default uses umans-flash to burn expiring credits.", + "required": false, + "env_variable": "CHEAP_MODEL", + "default": "umans-flash" + }, + { + "type": "text", + "label": "Strong Model", + "hint": "Model id to route longer prompts to.", + "required": false, + "env_variable": "STRONG_MODEL", + "default": "minimax-m3" + }, + { + "type": "number", + "label": "Cheap Model Char Threshold", + "hint": "Prompts shorter than this (in characters) go to CHEAP_MODEL. Set to a large number to disable cheap-routing entirely.", + "required": false, + "env_variable": "CHEAP_MODEL_CHAR_THRESHOLD", + "default": 500 + }, + { + "type": "text", + "label": "Enable Vault RAG", + "hint": "Set to 'true' to pre-fetch the current note + today's daily + this week's review from Obsidian and prepend as context. Set to 'false' to disable.", + "required": false, + "env_variable": "RAG_ENABLED", + "default": "true" + } + ] +} diff --git a/apps/hermes-obsidian-bridge/docker-compose.json b/apps/hermes-obsidian-bridge/docker-compose.json new file mode 100644 index 0000000..ef287a8 --- /dev/null +++ b/apps/hermes-obsidian-bridge/docker-compose.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 2, + "services": [ + { + "name": "hermes-obsidian-bridge", + "image": "forge.407.lol/cori/hermes-obsidian-bridge:sha-7139662d", + "isMain": true, + "internalPort": 9120, + "command": "python -m bridge", + "environment": [ + { "key": "PORT", "value": "9120" }, + { "key": "API_SERVER_KEY", "value": "${API_SERVER_KEY}" }, + { "key": "HERMES_BASE_URL", "value": "${HERMES_BASE_URL}" }, + { "key": "OBSIDIAN_REST_URL", "value": "${OBSIDIAN_REST_URL}" }, + { "key": "OBSIDIAN_REST_KEY", "value": "${OBSIDIAN_REST_KEY}" }, + { "key": "CHEAP_MODEL", "value": "${CHEAP_MODEL}" }, + { "key": "STRONG_MODEL", "value": "${STRONG_MODEL}" }, + { "key": "CHEAP_MODEL_CHAR_THRESHOLD", "value": "${CHEAP_MODEL_CHAR_THRESHOLD}" }, + { "key": "RAG_ENABLED", "value": "${RAG_ENABLED}" }, + { "key": "LOG_LEVEL", "value": "info" } + ], + "volumes": [ + { "hostPath": "${APP_DATA_DIR}/data", "containerPath": "/data" } + ], + "healthCheck": { + "test": "python -c \"import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:9120/health', timeout=3); sys.exit(0 if r.status==200 else 1)\"", + "interval": "30s", + "timeout": "5s", + "retries": 3 + } + } + ] +} diff --git a/apps/hermes-obsidian-bridge/metadata/description.md b/apps/hermes-obsidian-bridge/metadata/description.md new file mode 100644 index 0000000..7bdd1d0 --- /dev/null +++ b/apps/hermes-obsidian-bridge/metadata/description.md @@ -0,0 +1,29 @@ +# Hermes Obsidian Bridge + +A thin OpenAI-compatible proxy in front of the Hermes `api_server`. Designed to be pointed +at from any client that speaks OpenAI chat-completions (Copilot for Obsidian, Text Generator, +Local GPT, Open WebUI, curl scripts). + +## What it adds + +- **Session continuity.** Pass `X-Session-Id` and the bridge forwards it; conversations resume + across separate HTTP calls instead of starting a fresh session every time. +- **Vault RAG.** Send `X-Obsidian-Note-Path` and the bridge reads the current note + today's + daily + this week's review from the Obsidian Local REST API and prepends them as context. +- **Cost routing.** Prompts shorter than `CHEAP_MODEL_CHAR_THRESHOLD` go to `CHEAP_MODEL`; + longer ones go to `STRONG_MODEL`. Explicit `model` in the request body is always respected. + +## Install + +Install via the Runtipi dashboard. Required field: `API_SERVER_KEY` (the bearer token for +the Hermes `api_server` — must match the value on the gateway host's `~/.hermes/.env`). + +## Wire to Obsidian + +Any OpenAI-compatible community plugin works. Recommended: **Copilot for Obsidian**, with +base URL `http://hermes-obsidian-bridge.407.lol/v1` and any string as the API key. + +## Image source + +Image is built and hosted at `forge.407.lol/cori/hermes-obsidian-bridge` (local Forgejo). +Source repo: https://github.com/cori/hermes-obsidian-bridge. diff --git a/apps/hermes-obsidian-bridge/metadata/logo.jpg b/apps/hermes-obsidian-bridge/metadata/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..911455a5569044b108e2d670425f3969c1dad0e9 GIT binary patch literal 5304 zcmeHKdsvcb7Jm^HQ`0EOu2O16x|@?-EX(MIq)iv9$u2f@H>=4kyJ+f{PGw;mnVQv9 z6yi@iDeh7*-W#+bdyNROidvn5cuAGX~#A`pQmkio0fEWL6V=JVp;OmR)GjnSo$l!D(yc44TW1=bW_Cg%qi3vbI@s8BpmlWUNc)c$ECf_L zGbS?04Omf0WGV^X1Pp}JhV*`cUlz##b+C6pH(c%ntVm?Cl{MLn8r>a>eh1c6 zo6da)kF)K~o4Gk4ySrOTFwt_WMXK61mxO`D^lzu&ffN8-+1{Cz2@Y5UU; z96TgCaWdn_Q>V|Iy?80>@|EnXIk`8AZr-|mr}*x@vPb2Qf0k7|sZ>0#QNDOt`>O7> zO8vG)qkX5-x0>TJ$Jw_285kihDvHa>+L~-lF~>!++JuZuwYKRy*tYXHE@e?f7dM}H zJKFf<Oo<2rMQ+R% z2*wJWuxG>WK)^VG_dIn++G5vSi<3hzRjc=)!k9XCtP2DkAJxcPtv9cSfFx$=OpNBI zWBegF#z|x~_o#tDP#U$7TpQ%|N~&8UbcNuddy!DrF#>`+uOQe_r)n};jUEZXRvE(> zmvsLE!;i~jLqNdAAp-w)!B%}4ztx+7ibaGiQa4Z@qz0E_?NGG??vlQ6Or)qFsU0do zJK*_wq_-KrMmm$4Cuk+A@S9S85APis|55CO+wV%=dSoRmOI@81XzLJEbk^yZ>X80a z4aJxy?Fm7}Is*hjuG^kA6e^h_{&5=`ckBm?bumOjaCd$gtAXN-r-lSSNNeFWK7ycM z_#_A}^kTJgBWB%0?1DUDa3U_nrkB`4eA! zD*1*~tAXHTN^o*8fnKRsmiTYd8X-8?1(!phtuA0o#M1B*eKM=joTu;VmGch7FuNgd zi$Czm%&jl#89|r=LCV%mdm2FL9F)7(Q_I z?dYaPJp?`jOi~CMuf-(sBI9Jz4*9_<4o+q10)|Q&Sr<%_Mn)Z#eF;m+HZ2ja&&&!-d9;*Icww zrlWCvHutQtjXz%>O{vJr<7GuXGF_>jl6PLo*3TG!=TC)eN0r|TDw#f_}_wNcu-TwijqardUbMKO%N zwWdiz{TxITTAW|V(YE@N^npA>xL)Jr*!sT3^C z(a1iAf)&MK<)|N~3Gh;$TPeq5i%p4Gs*ti?PlVtenm|^F^Ax^~TOe@YJhx3#2z2QC zOJknCMAd?YMdb~jsE(BnTz}-iYd_fy4WVXEDrg5nH1evq_|jVO zoMb~_%9bRD6)DEv;4{xr3D1f-1g*_&vTR%Ks_*e2`ClMt7#WMeIM3AXSch0+DOLqZ zrd&*UzK)?KCnCWf)nWwtl8!k9Vf)SJCUKbGEj<1-8~c$LQYR}7y`PhnCQ9?MKkTzNV4VA&n~(jN zWZq5JuVndimDqRn(R(gwW2>iWxBopwza=wojXL7Ui4pbjb#p(SF55nblx;{dk*D(^ z__`qHkwjFIzCO&0?YWdbVp*Tv37p=$&PGnRmF^t9NV54-F<$kvrgvzAAz#?&kTXF2 zZpp@{%9>FD5i)Yw#b2hv-&t?lroK)yoHFQm1r4{&LSE}x2Z^+*%^_(>4 z#Q`TubaRR2VUtDVzj&TnUBk^g`sPj4pf#s9?w|W|U6Xs}{UL*u`vN_VW_dJiv>r;- Q< Date: Fri, 18 Sep 2026 15:15:29 +0000 Subject: [PATCH 2/3] add: karakeep self-hosted bookmark/notes app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Karakeep (formerly Hoarder) is a bookmark-everything app: links, notes, images, PDFs. Native iOS app + Android app connect to your server. Use cases here: track favored brands with historical notes, restaurants worth returning to, etc. Share-sheet capture on iOS is the capture-side killer feature; tag-based full-text search via Meilisearch is the lookup-side match for the request. Three-service stack: web (ghcr.io/karakeep-app/karakeep:v0.33.2), karakeep-chrome for page archival, meilisearch for full-text search. Healthchecks on each so Traefik gates the router correctly. Form fields: - NEXTAUTH_SECRET, MEILI_MASTER_KEY: auto-generated random on install - OPENAI_API_KEY: optional - OLLAMA_BASE_URL: optional, points at local Ollama (e.g. 100.126.40.64) - DISABLE_SIGNUPS: default false, flip to true after first account Port 8643 keeps separation from the official store's stale 'hoarder' wrapper (8685) in case that's ever installed side-by-side. Logo reused from the upstream karakeep/hoarder assets (same project, rename). Pinned to v0.33.2 (2026-08-11 release); Renovate regex doesn't match ghcr.io/karakeep-app/karakeep — bump manually for now. --- apps/karakeep/config.json | 62 +++++++++++++++++++++ apps/karakeep/docker-compose.json | 76 ++++++++++++++++++++++++++ apps/karakeep/metadata/description.md | 60 ++++++++++++++++++++ apps/karakeep/metadata/logo.jpg | Bin 0 -> 5084 bytes 4 files changed, 198 insertions(+) create mode 100644 apps/karakeep/config.json create mode 100644 apps/karakeep/docker-compose.json create mode 100644 apps/karakeep/metadata/description.md create mode 100644 apps/karakeep/metadata/logo.jpg diff --git a/apps/karakeep/config.json b/apps/karakeep/config.json new file mode 100644 index 0000000..8a288a1 --- /dev/null +++ b/apps/karakeep/config.json @@ -0,0 +1,62 @@ +{ + "$schema": "../app-info-schema.json", + "name": "Karakeep", + "id": "karakeep", + "available": true, + "short_desc": "Self-hostable bookmark/notes/images app with AI tagging and iOS + Android apps", + "author": "karakeep-app", + "port": 8643, + "categories": [ + "utilities", + "ai" + ], + "description": "Karakeep (formerly Hoarder) is a self-hostable bookmark-everything app: save links, notes, images, and PDFs, with optional AI auto-tagging. Includes native iOS and Android apps that connect to your server, plus Chrome/Firefox/Safari extensions. Capture from the share sheet on iOS, tag and search later, keep historical notes on products you liked, restaurants worth returning to, etc.", + "tipi_version": 1, + "min_tipi_version": "4.5.0", + "version": "0.33.2", + "source": "https://github.com/karakeep-app/karakeep", + "website": "https://karakeep.app", + "exposable": true, + "dynamic_config": true, + "supported_architectures": [ + "arm64", + "amd64" + ], + "created_at": 1726064280917, + "updated_at": 1726064280917, + "form_fields": [ + { + "type": "random", + "label": "NextAuth Secret", + "min": 32, + "env_variable": "NEXTAUTH_SECRET" + }, + { + "type": "random", + "label": "Meilisearch Master Key", + "min": 32, + "env_variable": "MEILI_MASTER_KEY" + }, + { + "type": "text", + "label": "OpenAI API Key", + "required": false, + "env_variable": "OPENAI_API_KEY" + }, + { + "type": "text", + "label": "Ollama Base URL", + "hint": "Optional: point at your local Ollama (e.g. http://100.126.40.64:11434) for local AI tagging. Leave blank to disable AI tagging or use OpenAI only.", + "required": false, + "env_variable": "OLLAMA_BASE_URL" + }, + { + "type": "text", + "label": "Disable Signups", + "hint": "Set to 'true' after creating your account to block new registrations.", + "required": false, + "env_variable": "DISABLE_SIGNUPS", + "default": "false" + } + ] +} diff --git a/apps/karakeep/docker-compose.json b/apps/karakeep/docker-compose.json new file mode 100644 index 0000000..0204712 --- /dev/null +++ b/apps/karakeep/docker-compose.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://schemas.runtipi.io/v2/dynamic-compose.json", + "schemaVersion": 2, + "services": [ + { + "name": "karakeep", + "image": "ghcr.io/karakeep-app/karakeep:v0.33.2", + "isMain": true, + "internalPort": 3000, + "environment": [ + { "key": "MEILI_ADDR", "value": "http://karakeep-meilisearch:7700" }, + { "key": "BROWSER_WEB_URL", "value": "http://karakeep-chrome:9222" }, + { "key": "DATA_DIR", "value": "/data" }, + { "key": "NEXTAUTH_SECRET", "value": "${NEXTAUTH_SECRET}" }, + { "key": "MEILI_MASTER_KEY", "value": "${MEILI_MASTER_KEY}" }, + { "key": "NEXTAUTH_URL", "value": "${APP_PROTOCOL:-http}://${APP_DOMAIN}" }, + { "key": "OPENAI_API_KEY", "value": "${OPENAI_API_KEY}" }, + { "key": "OLLAMA_BASE_URL", "value": "${OLLAMA_BASE_URL}" }, + { "key": "DISABLE_SIGNUPS", "value": "${DISABLE_SIGNUPS}" } + ], + "volumes": [ + { + "hostPath": "${APP_DATA_DIR}/data/app", + "containerPath": "/data" + } + ], + "healthCheck": { + "test": "wget -q --spider http://localhost:3000/api/health || exit 1", + "interval": "30s", + "timeout": "10s", + "retries": 10 + }, + "dependsOn": [ + "karakeep-meilisearch", + "karakeep-chrome" + ] + }, + { + "name": "karakeep-chrome", + "image": "ghcr.io/karakeep-app/karakeep-chrome:v0.33.2", + "command": [ + "--disable-gpu", + "--disable-dev-shm-usage", + "--hide-scrollbars", + "--disable-blink-features=AutomationControlled", + "--window-size=1440,900" + ], + "healthCheck": { + "test": "curl -fsS http://localhost:9222/json/version || exit 1", + "interval": "30s", + "timeout": "10s", + "retries": 5 + } + }, + { + "name": "karakeep-meilisearch", + "image": "getmeili/meilisearch:v1.21.0", + "environment": [ + { "key": "MEILI_NO_ANALYTICS", "value": "true" }, + { "key": "MEILI_MASTER_KEY", "value": "${MEILI_MASTER_KEY}" } + ], + "volumes": [ + { + "hostPath": "${APP_DATA_DIR}/data/meili_data", + "containerPath": "/meili_data" + } + ], + "healthCheck": { + "test": "curl -fsS http://localhost:7700/health || exit 1", + "interval": "30s", + "timeout": "10s", + "retries": 5 + } + } + ] +} diff --git a/apps/karakeep/metadata/description.md b/apps/karakeep/metadata/description.md new file mode 100644 index 0000000..77590d8 --- /dev/null +++ b/apps/karakeep/metadata/description.md @@ -0,0 +1,60 @@ +# Karakeep + +A self-hostable bookmark-everything app: links, notes, images, PDFs. With optional AI-based automatic tagging (OpenAI or local Ollama) and full-text search via Meilisearch. Native iOS and Android apps talk directly to your server; share-sheet capture on iOS is the killer feature. + +Use cases: keep historical notes on brands you like (e.g. a particular blue cheese), restaurants worth returning to (the killer cubano at X), products, recipes, anything that benefits from a quick tag and search later. Karakeep replaces your brain's "I should remember that" stack. + +## Features + +- Bookmark links, take simple notes, store images and PDFs +- Automatic fetching for link titles, descriptions, images +- Sort bookmarks into lists, full-text search across everything +- Optional AI auto-tagging (OpenAI cloud or local Ollama) +- Chrome, Firefox, and Safari extensions for quick bookmarking +- Native iOS app (https://apps.apple.com/us/app/karakeep-app/id6479258022) and Android app +- Full page archival (via monolith) to protect against link rot +- Bulk actions, dark mode, multi-user +- Self-hosting first + +## Components + +- `karakeep` (main web app, Next.js) — port 3000 internally +- `karakeep-chrome` (headless Chrome for page archival) — separate container +- `karakeep-meilisearch` (search index) — separate container + +## Setup + +After install: + +1. Open the app at the assigned domain +2. Sign up for the first account (signups are enabled by default) +3. Flip the `DISABLE_SIGNUPS` form field to `true` so no one else can register +4. Optional: configure AI tagging by setting `OPENAI_API_KEY` (cloud) or `OLLAMA_BASE_URL` (local) +5. Install the iOS app, point it at your server URL +6. Install the browser extension if you want quick captures from your desktop browser + +## AI Tagging + +Two options, both optional: + +- **OpenAI**: paste an API key into the `OpenAI API Key` form field +- **Local Ollama**: point `Ollama Base URL` at any reachable Ollama instance (e.g. `http://100.126.40.64:11434`). No data leaves your network. + +Either way, Karakeep auto-tags new bookmarks so you don't have to maintain a taxonomy manually. Tags are suggestions — edit at will. + +## Versioning Note + +Pinned to upstream `v0.33.2` (2026-08-11 release). Renovate does not currently auto-bump Karakeep because the image registry pattern doesn't match the regex in `renovate.json` — bump manually by editing `apps/karakeep/{config.json,docker-compose.json}` and opening a PR. + +## Volumes + +All persistent data lives under `${APP_DATA_DIR}/data/`: + +- `app/` — Karakeep's data dir (bookmarks DB, uploads, etc.) +- `meili_data/` — Meilisearch index data + +Back these up together to fully restore. + +## Security + +`NEXTAUTH_SECRET` and `MEILI_MASTER_KEY` are auto-generated as random hex strings on first install. Save them somewhere safe (1Password) — losing them invalidates all sessions and the search index. diff --git a/apps/karakeep/metadata/logo.jpg b/apps/karakeep/metadata/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0477833ac2a6d1696b37341f3ee50e2f528e79e1 GIT binary patch literal 5084 zcmeHL3pAAL8ve!%<1%5!ZKz?$h>03W7s}+$Aa`3xE>RRANu@C^F%&Y`$Ti9(LXuo+ zD}>#oT%#O?a_ufEV&)hl)joUe-8pORv(8@Yd^2n2f8YPTJn!?p|GU=rGkci>K#-(o zs0Sbr03Zt=z+?a^?L%HZer|^kyY0og9K(A0xb0K&b8}r%GW$R{fGsaBE>12kZur9D zM)0r}LI}Z+SojJdg#`r!g^`kCA|hgvGMJSZ89WY$-?(9;j*bIl*&p_w1z^?#BsXLV zG6jXm01hMsii9v50cN2N2;hLSs##h*+)x-N7n}pad3($!sT3@s)>N%rqDn3y(JKkQh&D| z{T1zkH-NvFXmu?63fkk`T>zE3XL{@VaPUdbpE_aLN2%_7BjOIeW&j&I;$u6byr!A&~MC22|B5C_} zi9(GZngOW5PApj5pieLg+a@w!kZlvBwskQXXy~M}<7lXevvWC*H?ng{DMdT6C;)`1 zAvPWXEvnSn->7U)1yjCP@mHW;gs>QZET{9)+?gWbUQPk%yxDT-ddrTHPX>D43XwIzefy?Q8F@n+0GB9oYnbtQer3_5=+yLX7?QK5xi| z%+5B`05}dW1jm>FItQQ$<5` zbG5dmi?1IgBsBiYZBa1`Yg%Ia($t0!0iz9(s+Lki`8AQV$yeN2a4_2gFW14ly)l)} zZJMF(sLFfTTMKcnJHio?$B~#$I~6AbuZ(XU^*vwl1{sC=D*HjdQB5Y^JlxYVr3fr>47Khf*mCi7`ZXI zUN?j3v8c<}tqHARqg|bMI^&-rG(GTH75ozZYVP6{S7C1}ZNlfC4AXg=pAz)aG^?*0 z(kvp%xqRyNC_)IpdFm;R0&g!-XSUHhK4a@U*Iv{j8MtlQ9$o?iI&ldZTkNRz>*ZBw?+k-++8xo~;zbz0vv=^bZR36jABNA7B%h@@-duY$nR+{t zRT4#$t2e_i*t(LvTjMkqXNQdxf_G?28Z zuV=1m5Z`yi649%1(nS7^Zi!ya+x}xK#*+1u9=Nq-s7BM0aIVDy(8o$KRu^V>V4kQ` zjZ%Ha*R7VS;||`??yM7nl0~<5QK{ynw)5E+V%zjNf776a7cpbkT6zi<%oNOE$m}rB zki3YVc>Y6PcfGN#bzTjQje4Sit(|b?)SHy5b<*+|eTHN&8m|5*7%1^O@bsY;y%3Hm zLR?Tuu8xe)BTjED_ISM#N@7sSCnx*-ejJ_@5z0N^O!>W?@WC6fL7cXBM+7%ms_uz` zT237vyHKlz{9rU7-*;%w|tSBbo?wfOw>sCzQ`0DO3*dguiO z;`o7&Mu+F!5}9oc>^-{u&|6YQk;iS`8Kkrj zRsE53S+AygN|*4JoGqPk6Wb`nW@+G^+C=@$9@L}(f?zeA(-C??^XkL9?(aHv8C__4 zL~y4;99)-6j_?SXaDqv15r#6I^Ff%)R;YI{^YbnEUHR8Otoe`6UY^Xl`H`+9)yN> z>Izh&1kzV5HjF-xy-!c`h=*ZpM4NRQAVpjtDLuTrOjHNtGy`tREx5*4N6k$vpUJ$V!U>X3Xf~c zPWw9s7Gf(jwKW+rCI>%m3nPAcq-2?(D&HY2E5eH~-Lo~)d~qUAzxf5*e`b4<-nl2@ zXXK^9l=S%gk)jG&K35TKM+eejkudZXw@G1|U_Wo0%LqALxtVV?VyCl=XJl?$|_~lOKELjr3x~8?BpsMSNw7X%~x=P(g-W&Xt5ct5nF5 zsC_S}ZKcLGzKO=TKAs8wQ5PMUs&f1K^eIhy13DuNRB)z&q)TaMA*G@j1qZ~+>mPEPQYBZ?J&l1SPld)wTJQM`@8*pG5Hbk+f(RGI$MP$j{V@+*QhW(%qF@$+2qL*&r*);^%{S zA>pPtgP*##{e7;yjvwlt_{G=x&JD|C{zhVN#I=I+8d!|!KHKv3*258fBR->VI{I?k zXRw3L7%945CLLu(N;H%mm;I1%OEX5~hLv83h}lr~DcpWpd8vCy_bPhS3Y=$R{Y?Qr zzq1+m=<+M`AN Date: Sun, 20 Sep 2026 02:38:50 +0000 Subject: [PATCH 3/3] feat: add InvenTree app (v1.5.5) 4-service multi-container app: postgres, redis, server, worker. Dropped Caddy proxy (Traefik handles TLS/routing). Pinned to inventree/inventree:1.5.5. Subnet: 10.128.21.0/24. Port: 8390, domain: inventree.407.lol. Categories: data, utilities. --- apps/inventree/config.json | 69 +++++++++++++ apps/inventree/docker-compose.json | 130 +++++++++++++++++++++++++ apps/inventree/metadata/description.md | 22 +++++ apps/inventree/metadata/logo.jpg | Bin 0 -> 15993 bytes 4 files changed, 221 insertions(+) create mode 100644 apps/inventree/config.json create mode 100644 apps/inventree/docker-compose.json create mode 100644 apps/inventree/metadata/description.md create mode 100644 apps/inventree/metadata/logo.jpg diff --git a/apps/inventree/config.json b/apps/inventree/config.json new file mode 100644 index 0000000..1f60b09 --- /dev/null +++ b/apps/inventree/config.json @@ -0,0 +1,69 @@ +{ + "$schema": "../app-info-schema.json", + "name": "InvenTree", + "id": "inventree", + "available": true, + "short_desc": "Open-source inventory management for parts, components, BOMs, and stock control", + "author": "InvenTree", + "port": 8000, + "categories": ["data", "utilities"], + "description": "InvenTree is an open-source inventory management system providing intuitive parts management, stock control, BOM management, and supplier tracking. Designed for makers, hobbyists, and small businesses. Features hierarchical categories, custom fields, build orders, REST API, mobile app with barcode scanning, and a plugin ecosystem.", + "tipi_version": 1, + "version": "1.5.5", + "source": "https://github.com/inventree/InvenTree", + "website": "https://inventree.org", + "exposable": true, + "supported_architectures": ["arm64", "amd64"], + "created_at": 1726700000000, + "updated_at": 1726700000000, + "dynamic_config": true, + "form_fields": [ + { + "type": "random", + "label": "Database Password", + "hint": "Password for the PostgreSQL database (auto-generated)", + "required": true, + "env_variable": "INVENTREE_DB_PASSWORD", + "encoding": "hex" + }, + { + "type": "random", + "label": "Secret Key", + "hint": "Django secret key for cryptographic signing (auto-generated)", + "required": true, + "env_variable": "INVENTREE_SECRET_KEY", + "encoding": "hex" + }, + { + "type": "text", + "label": "Admin User", + "hint": "Username for the InvenTree superuser account", + "required": true, + "env_variable": "INVENTREE_ADMIN_USER", + "default": "admin" + }, + { + "type": "password", + "label": "Admin Password", + "hint": "Password for the InvenTree superuser account", + "required": true, + "env_variable": "INVENTREE_ADMIN_PASSWORD" + }, + { + "type": "text", + "label": "Admin Email", + "hint": "Email for the InvenTree superuser account", + "required": false, + "env_variable": "INVENTREE_ADMIN_EMAIL", + "default": "admin@localhost" + }, + { + "type": "text", + "label": "Site URL", + "hint": "The external URL for this InvenTree instance (used for links, CSRF, etc.)", + "required": true, + "env_variable": "INVENTREE_SITE_URL", + "default": "https://inventree.407.lol" + } + ] +} \ No newline at end of file diff --git a/apps/inventree/docker-compose.json b/apps/inventree/docker-compose.json new file mode 100644 index 0000000..074c239 --- /dev/null +++ b/apps/inventree/docker-compose.json @@ -0,0 +1,130 @@ +{ + "$schema": "../dynamic-compose-schema.json", + "schemaVersion": 2, + "services": [ + { + "name": "inventree-db", + "image": "postgres:17", + "environment": [ + { "key": "PGDATA", "value": "/var/lib/postgresql/data/pgdb" }, + { "key": "POSTGRES_USER", "value": "inventree" }, + { "key": "POSTGRES_PASSWORD", "value": "${INVENTREE_DB_PASSWORD}" }, + { "key": "POSTGRES_DB", "value": "inventree" } + ], + "volumes": [ + { "hostPath": "${APP_DATA_DIR}/pgdb", "containerPath": "/var/lib/postgresql/data" } + ], + "healthCheck": { + "test": "pg_isready -U inventree -d inventree", + "interval": "15s", + "timeout": "5s", + "retries": 10 + } + }, + { + "name": "inventree-cache", + "image": "redis:7-alpine", + "command": [ + "redis-server", + "--save", + "", + "--appendonly", + "no" + ], + "volumes": [ + { "hostPath": "${APP_DATA_DIR}/redis", "containerPath": "/data" } + ], + "healthCheck": { + "test": "redis-cli ping", + "interval": "15s", + "timeout": "5s", + "retries": 10 + } + }, + { + "name": "inventree-server", + "image": "inventree/inventree:1.5.5", + "isMain": true, + "internalPort": "8000", + "dependsOn": [ + "inventree-db", + "inventree-cache" + ], + "environment": [ + { "key": "INVENTREE_DB_ENGINE", "value": "postgresql" }, + { "key": "INVENTREE_DB_NAME", "value": "inventree" }, + { "key": "INVENTREE_DB_HOST", "value": "inventree-db" }, + { "key": "INVENTREE_DB_PORT", "value": "5432" }, + { "key": "INVENTREE_DB_USER", "value": "inventree" }, + { "key": "INVENTREE_DB_PASSWORD", "value": "${INVENTREE_DB_PASSWORD}" }, + { "key": "INVENTREE_CACHE_ENABLED", "value": "True" }, + { "key": "INVENTREE_CACHE_HOST", "value": "inventree-cache" }, + { "key": "INVENTREE_CACHE_PORT", "value": "6379" }, + { "key": "INVENTREE_SECRET_KEY", "value": "${INVENTREE_SECRET_KEY}" }, + { "key": "INVENTREE_SITE_URL", "value": "${INVENTREE_SITE_URL}" }, + { "key": "INVENTREE_WEB_PORT", "value": "8000" }, + { "key": "INVENTREE_LOG_LEVEL", "value": "WARNING" }, + { "key": "INVENTREE_PLUGINS_ENABLED", "value": "True" }, + { "key": "INVENTREE_AUTO_UPDATE", "value": "True" }, + { "key": "INVENTREE_ADMIN_USER", "value": "${INVENTREE_ADMIN_USER}" }, + { "key": "INVENTREE_ADMIN_PASSWORD", "value": "${INVENTREE_ADMIN_PASSWORD}" }, + { "key": "INVENTREE_ADMIN_EMAIL", "value": "${INVENTREE_ADMIN_EMAIL}" }, + { "key": "INVENTREE_USE_X_FORWARDED_HOST", "value": "True" }, + { "key": "INVENTREE_USE_X_FORWARDED_PORT", "value": "True" }, + { "key": "INVENTREE_USE_X_FORWARDED_PROTO", "value": "True" }, + { "key": "INVENTREE_GUNICORN_TIMEOUT", "value": "90" }, + { "key": "TZ", "value": "${TZ}" } + ], + "volumes": [ + { "hostPath": "${APP_DATA_DIR}/data", "containerPath": "/home/inventree/data" } + ], + "healthCheck": { + "test": "invoke server-health --address http://localhost:8000", + "interval": "20s", + "timeout": "5s", + "retries": 10 + } + }, + { + "name": "inventree-worker", + "image": "inventree/inventree:1.5.5", + "command": [ + "invoke", + "worker" + ], + "dependsOn": [ + "inventree-server" + ], + "environment": [ + { "key": "INVENTREE_DB_ENGINE", "value": "postgresql" }, + { "key": "INVENTREE_DB_NAME", "value": "inventree" }, + { "key": "INVENTREE_DB_HOST", "value": "inventree-db" }, + { "key": "INVENTREE_DB_PORT", "value": "5432" }, + { "key": "INVENTREE_DB_USER", "value": "inventree" }, + { "key": "INVENTREE_DB_PASSWORD", "value": "${INVENTREE_DB_PASSWORD}" }, + { "key": "INVENTREE_CACHE_ENABLED", "value": "True" }, + { "key": "INVENTREE_CACHE_HOST", "value": "inventree-cache" }, + { "key": "INVENTREE_CACHE_PORT", "value": "6379" }, + { "key": "INVENTREE_SECRET_KEY", "value": "${INVENTREE_SECRET_KEY}" }, + { "key": "INVENTREE_SITE_URL", "value": "${INVENTREE_SITE_URL}" }, + { "key": "INVENTREE_WEB_PORT", "value": "8000" }, + { "key": "INVENTREE_LOG_LEVEL", "value": "WARNING" }, + { "key": "INVENTREE_PLUGINS_ENABLED", "value": "True" }, + { "key": "INVENTREE_AUTO_UPDATE", "value": "True" }, + { "key": "INVENTREE_USE_X_FORWARDED_HOST", "value": "True" }, + { "key": "INVENTREE_USE_X_FORWARDED_PORT", "value": "True" }, + { "key": "INVENTREE_USE_X_FORWARDED_PROTO", "value": "True" }, + { "key": "TZ", "value": "${TZ}" } + ], + "volumes": [ + { "hostPath": "${APP_DATA_DIR}/data", "containerPath": "/home/inventree/data" } + ], + "healthCheck": { + "test": "invoke worker-health", + "interval": "60s", + "timeout": "10s", + "retries": 3 + } + } + ] +} \ No newline at end of file diff --git a/apps/inventree/metadata/description.md b/apps/inventree/metadata/description.md new file mode 100644 index 0000000..3d6acc0 --- /dev/null +++ b/apps/inventree/metadata/description.md @@ -0,0 +1,22 @@ +# InvenTree + +InvenTree is an open-source inventory management system for parts, components, and stock control. It provides hierarchical categories, BOM management, supplier tracking, build orders, custom fields, a REST API, and a mobile app with barcode scanning. + +## Features + +- **Parts Management**: Hierarchical categories, custom fields, part images, datasheet attachments +- **Stock Control**: Multi-location stock tracking, serialized items, stock history +- **BOM Management**: Multi-level BOMs with substitutions, BOM import/export +- **Build Orders**: Consume stock to build assemblies, track build progress +- **Supplier Management**: Multi-vendor pricing, supplier part URLs, purchase orders +- **REST API**: Full CRUD API for integration with external tools +- **Mobile App**: iOS/Android with barcode scanning +- **Plugin System**: 50+ community plugins for integrations and custom workflows + +## Setup + +On first launch, InvenTree runs database migrations and creates the admin account from the form fields. This can take 1-2 minutes. The server is ready when the health check passes. + +## Usage + +Ideal for tracking electronic components, CNC tooling, consumables, PCB materials, and any physical inventory that needs structured organization with supplier links and BOM support. \ No newline at end of file diff --git a/apps/inventree/metadata/logo.jpg b/apps/inventree/metadata/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e07a790ca5799e9a4873d227e60de522eb24196c GIT binary patch literal 15993 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