← Commit history
.gitignoreadded+3
@@ -0,0 +1,3 @@+/target+/.env+*.log
BUILD-SKILL.mdadded+75
@@ -0,0 +1,75 @@+---+name: adom-jlcpcb-build+description: Build and release a new version of adom-jlcpcb. Use when the user says "release adom-jlcpcb", "publish adom-jlcpcb", "cut a new adom-jlcpcb version", "bump adom-jlcpcb", "ship adom-jlcpcb", or "push adom-jlcpcb to the wiki".+---++# Release adom-jlcpcb++A release syncs five places to the same version X.Y.Z:+`VERSION`, `Cargo.toml`, `adom-jlcpcb --version`, wiki page `metadata.version`, running `/health` on the service container.++## Steps++1. **Bump** — decide X.Y.Z (semver). Then:+   ```bash+   echo "X.Y.Z" > VERSION+   sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml+   ```++2. **Local build + smoke test**:+   ```bash+   ./build.sh+   adom-jlcpcb --version                    # must print "adom-jlcpcb X.Y.Z"+   adom-jlcpcb health                       # backend reachable+   adom-jlcpcb search "STM32F103RBT6" --limit 1 | jq '.components[0].mouser_pn'+   ```++3. **Ralph loop the UI** (per app-creator): open in pup, drive a real search, screenshot, check `/console` for errors.+   ```bash+   JLCPCB_API=<backend> adom-jlcpcb app --no-tab &+   adom-desktop browser_open_window '{"sessionId":"jlcpcb","profile":"jlcpcb","url":"http://127.0.0.1:8892"}'+   # drive a search, screenshot, verify 0 console errors+   ```++4. **Show the user a screenshot and wait for explicit approval** before publishing. The app-creator skill is hard about this.++5. **Publish**:+   ```bash+   ./publish.sh+   ```+   Runs: git commit + tag `vX.Y.Z`, `git push origin main --tags`, `gh release create`, `adom-wiki asset upload` (replaces the prior `docker_binary`), `adom-wiki page update`, and a final curl of the wiki binary to check `--version` returns X.Y.Z.++6. **Service container picks it up automatically** — `service/watchdog.sh` runs every 2 min, `git fetch`es origin/main, and rebuilds + restarts when HEAD moves. Verify within ~5 min:+   ```bash+   curl -s https://jlcpcb-<slug>.adom.cloud/health | jq '.version'   # → "X.Y.Z"+   ```++7. **Verify on a fresh user container** (optional, but catches gallia regressions):+   ```bash+   # on a fresh container:+   node ~/gallia/install.mjs                 # Tier A block fetches from wiki+   adom-jlcpcb --version                     # → "adom-jlcpcb X.Y.Z"+   ```++## Pre-release checklist++- [ ] `VERSION`, `Cargo.toml`, `adom-jlcpcb --version`, wiki `metadata.version`, `/health` all return X.Y.Z+- [ ] Wiki page at `apps/adom-jlcpcb` renders the new `install_hint` one-liner+- [ ] User has seen a working screenshot of the webview+- [ ] No console errors via `GET /console`+- [ ] `gallia/install.mjs` Tier A block still works (pulls + installs the new version)+- [ ] **Brand audit — in-app icons are monochrome white.** Run:+      ```+      grep -nE 'fill="#00b8b0"|stroke="#00b8b0"|color: var\(--accent\)' src/app/*.html src/app/*.svg 2>/dev/null+      ```+      Expected result: empty, OR only the text-color rules (`.jlcpcb-pn`,+      `<a>`, focus-rings) on non-SVG elements. Any match on an `<svg>` or+      a `.logo` / `.icon` class CSS rule is a bug — the brand skill+      requires monochrome white for in-app icons. See+      `app-creator` §1b and §Checklist.++## Notes++- **Never skip the ralph loop** — a green `cargo build` is not proof the UI works. Fonts, search, card rendering must be seen working.+- **Wiki upload is idempotent** — `adom-wiki asset upload --asset-type docker_binary` replaces the prior binary for this app automatically.+- **Version discipline is load-bearing** — `refresh-wiki-catalog.mjs` uses semver comparison to surface stale installs; inconsistent versions silently break that.
Cargo.lockadded+856
@@ -0,0 +1,856 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adler2"+version = "2.0.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"++[[package]]+name = "adom-jlcpcb"+version = "0.1.0"+dependencies = [+ "base64",+ "clap",+ "clap_complete",+ "serde",+ "serde_json",+ "tiny_http",+ "ureq",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys 0.61.2",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys 0.61.2",+]++[[package]]+name = "ascii"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"++[[package]]+name = "base64"+version = "0.22.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"++[[package]]+name = "cc"+version = "1.2.60"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"+dependencies = [+ "find-msvc-tools",+ "shlex",+]++[[package]]+name = "cfg-if"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"++[[package]]+name = "chunked_transfer"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"++[[package]]+name = "clap"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_complete"+version = "4.6.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb"+dependencies = [+ "clap",+]++[[package]]+name = "clap_derive"+version = "4.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "crc32fast"+version = "1.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"+dependencies = [+ "cfg-if",+]++[[package]]+name = "displaydoc"+version = "0.2.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "find-msvc-tools"+version = "0.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"++[[package]]+name = "flate2"+version = "1.1.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"+dependencies = [+ "crc32fast",+ "miniz_oxide",+]++[[package]]+name = "form_urlencoded"+version = "1.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"+dependencies = [+ "percent-encoding",+]++[[package]]+name = "getrandom"+version = "0.2.17"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"+dependencies = [+ "cfg-if",+ "libc",+ "wasi",+]++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "httpdate"+version = "1.0.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"++[[package]]+name = "icu_collections"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"+dependencies = [+ "displaydoc",+ "potential_utf",+ "utf8_iter",+ "yoke",+ "zerofrom",+ "zerovec",+]++[[package]]+name = "icu_locale_core"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"+dependencies = [+ "displaydoc",+ "litemap",+ "tinystr",+ "writeable",+ "zerovec",+]++[[package]]+name = "icu_normalizer"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"+dependencies = [+ "icu_collections",+ "icu_normalizer_data",+ "icu_properties",+ "icu_provider",+ "smallvec",+ "zerovec",+]++[[package]]+name = "icu_normalizer_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"++[[package]]+name = "icu_properties"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"+dependencies = [+ "icu_collections",+ "icu_locale_core",+ "icu_properties_data",+ "icu_provider",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "icu_properties_data"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"++[[package]]+name = "icu_provider"+version = "2.2.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"+dependencies = [+ "displaydoc",+ "icu_locale_core",+ "writeable",+ "yoke",+ "zerofrom",+ "zerotrie",+ "zerovec",+]++[[package]]+name = "idna"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"+dependencies = [+ "idna_adapter",+ "smallvec",+ "utf8_iter",+]++[[package]]+name = "idna_adapter"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"+dependencies = [+ "icu_normalizer",+ "icu_properties",+]++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "libc"+version = "0.2.185"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"++[[package]]+name = "litemap"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"++[[package]]+name = "log"+version = "0.4.29"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"++[[package]]+name = "memchr"+version = "2.8.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"++[[package]]+name = "miniz_oxide"+version = "0.8.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"+dependencies = [+ "adler2",+ "simd-adler32",+]++[[package]]+name = "once_cell"+version = "1.21.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "percent-encoding"+version = "2.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"++[[package]]+name = "potential_utf"+version = "0.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"+dependencies = [+ "zerovec",+]++[[package]]+name = "proc-macro2"+version = "1.0.106"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.45"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "ring"+version = "0.17.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"+dependencies = [+ "cc",+ "cfg-if",+ "getrandom",+ "libc",+ "untrusted",+ "windows-sys 0.52.0",+]++[[package]]+name = "rustls"+version = "0.23.38"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"+dependencies = [+ "log",+ "once_cell",+ "ring",+ "rustls-pki-types",+ "rustls-webpki",+ "subtle",+ "zeroize",+]++[[package]]+name = "rustls-pki-types"+version = "1.14.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"+dependencies = [+ "zeroize",+]++[[package]]+name = "rustls-webpki"+version = "0.103.12"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"+dependencies = [+ "ring",+ "rustls-pki-types",+ "untrusted",+]++[[package]]+name = "serde"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.228"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.149"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "shlex"+version = "1.3.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"++[[package]]+name = "simd-adler32"+version = "0.3.9"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"++[[package]]+name = "smallvec"+version = "1.15.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"++[[package]]+name = "stable_deref_trait"+version = "1.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"++[[package]]+name = "syn"+version = "2.0.117"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "synstructure"+version = "0.13.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "tiny_http"+version = "0.12.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"+dependencies = [+ "ascii",+ "chunked_transfer",+ "httpdate",+ "log",+]++[[package]]+name = "tinystr"+version = "0.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"+dependencies = [+ "displaydoc",+ "zerovec",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "untrusted"+version = "0.9.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"++[[package]]+name = "ureq"+version = "2.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"+dependencies = [+ "base64",+ "flate2",+ "log",+ "once_cell",+ "rustls",+ "rustls-pki-types",+ "serde",+ "serde_json",+ "url",+ "webpki-roots 0.26.11",+]++[[package]]+name = "url"+version = "2.5.8"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"+dependencies = [+ "form_urlencoded",+ "idna",+ "percent-encoding",+ "serde",+]++[[package]]+name = "utf8_iter"+version = "1.0.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "wasi"+version = "0.11.1+wasi-snapshot-preview1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"++[[package]]+name = "webpki-roots"+version = "0.26.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"+dependencies = [+ "webpki-roots 1.0.7",+]++[[package]]+name = "webpki-roots"+version = "1.0.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"+dependencies = [+ "rustls-pki-types",+]++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.52.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"+dependencies = [+ "windows-targets",+]++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "windows-targets"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"+dependencies = [+ "windows_aarch64_gnullvm",+ "windows_aarch64_msvc",+ "windows_i686_gnu",+ "windows_i686_gnullvm",+ "windows_i686_msvc",+ "windows_x86_64_gnu",+ "windows_x86_64_gnullvm",+ "windows_x86_64_msvc",+]++[[package]]+name = "windows_aarch64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"++[[package]]+name = "windows_aarch64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"++[[package]]+name = "windows_i686_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"++[[package]]+name = "windows_i686_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"++[[package]]+name = "windows_i686_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"++[[package]]+name = "windows_x86_64_gnu"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"++[[package]]+name = "windows_x86_64_gnullvm"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"++[[package]]+name = "windows_x86_64_msvc"+version = "0.52.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"++[[package]]+name = "writeable"+version = "0.6.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"++[[package]]+name = "yoke"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"+dependencies = [+ "stable_deref_trait",+ "yoke-derive",+ "zerofrom",+]++[[package]]+name = "yoke-derive"+version = "0.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zerofrom"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"+dependencies = [+ "zerofrom-derive",+]++[[package]]+name = "zerofrom-derive"+version = "0.1.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+ "synstructure",+]++[[package]]+name = "zeroize"+version = "1.8.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"++[[package]]+name = "zerotrie"+version = "0.2.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"+dependencies = [+ "displaydoc",+ "yoke",+ "zerofrom",+]++[[package]]+name = "zerovec"+version = "0.11.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"+dependencies = [+ "yoke",+ "zerofrom",+ "zerovec-derive",+]++[[package]]+name = "zerovec-derive"+version = "0.11.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "zmij"+version = "1.0.21"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.tomladded+24
@@ -0,0 +1,24 @@+[package]+name = "adom-jlcpcb"+version = "0.1.0"+edition = "2021"+description = "JLCPCB Electronics component search — CLI, backend HTTP service, and Hydrogen webview app"+license = "MIT"++[[bin]]+name = "adom-jlcpcb"+path = "src/main.rs"++[dependencies]+clap = { version = "4", features = ["derive", "env"] }+clap_complete = "4"+serde = { version = "1", features = ["derive"] }+serde_json = "1"+tiny_http = "0.12"+ureq = { version = "2", features = ["json"] }+base64 = "0.22"++[profile.release]+strip = true+lto = "thin"+opt-level = "z"
README.md+54−37
@@ -1,61 +1,78 @@-# adom-jlcpcb+# JLCPCB Parts Library -Search JLCPCB Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:+**Know it's in the JLCPCB library — Basic or Extended — before you commit a board to fab.** -- **CLI verbs** — `adom-jlcpcb search|part|health` return normalized JSON on stdout, ready for `| jq`.-- **Hydrogen webview app** — `adom-jlcpcb app` opens a first-class "JLCPCB Search" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and JLCPCB PN, and a backend-health indicator.-- **Backend HTTP service** — `adom-jlcpcb serve` runs an HTTP proxy (port 8774) with a 10-minute LRU cache. Keeps the JLCPCB API key server-side; consumer containers point `JLCPCB_API` at it.+![Is it in the JLCPCB library? Check Basic vs Extended, LCSC stock and assembly pricing before you fab](/blob/app/adom-jlcpcb/screenshots/hero.png) -![JLCPCB search webview showing ESP32 module results with price breaks](https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/jlcpcb-search-results.png)+`adom-jlcpcb` is the single-vendor JLCPCB / LCSC tool for the Adom ecosystem — one Rust binary,+three faces: -## Install+- **CLI** — `adom-jlcpcb search | part | health` for quick JSON-returning shell use and BOMs.+- **Hydrogen app** — `adom-jlcpcb app` opens a searchable UI as a first-class Hydrogen tab.+- **Backend service** — `adom-jlcpcb serve` runs the shared proxy (port 8779) that powers the+  `service-jlcpcb` container and feeds [`adom-parts-search`](/app/adom-parts-search).  ```bash-curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/adom-jlcpcb \-  -o /tmp/adom-jlcpcb \-  && chmod +x /tmp/adom-jlcpcb \-  && sudo install -m 0755 /tmp/adom-jlcpcb /usr/local/bin/adom-jlcpcb \-  && adom-jlcpcb install+adompkg install adom-jlcpcb+adom-jlcpcb search "0402 10k resistor" ``` -Installs the binary to `/usr/local/bin/adom-jlcpcb`, deploys `~/.claude/skills/adom-jlcpcb/SKILL.md` and `~/.claude/skills/adom-jlcpcb-build/SKILL.md`, and registers bash completions.+## Basic vs Extended — the question that costs money++On a JLCPCB assembly run, **Basic** parts are pre-loaded on the line (no fee), while **Extended**+parts add a per-part feeder setup charge. Picking a Basic equivalent up front can shave real money+off a build. `adom-jlcpcb` shows the library tier on every card, alongside LCSC code, stock and+price, so you design around what's actually cheap to place.++![adom-jlcpcb results for an 0402 10k resistor and an LM358 op-amp, all flagged as Basic parts with LCSC codes and assembly pricing](/blob/app/adom-jlcpcb/screenshots/results-passives.png)++## Catch the Extended part before fab does++Search your MCU or regulator and you'll see at a glance whether your first pick is Basic, and what+the Basic alternative is. Here a popular STM32 is Basic, while a drop-in GD32 equivalent is+Extended — exactly the trade-off you want to make *before* the board goes out.++![adom-jlcpcb results comparing an STM32F103C8T6 Basic part with a GD32 Extended alternative and a Basic MLCC, with LCSC stock and pricing](/blob/app/adom-jlcpcb/screenshots/results-mcu.png) -For gallia containers, this is done automatically by `gallia/install.mjs` — adom-jlcpcb is a **Tier A app** (every Adom user needs component search day-one).+## Scriptable from the first keystroke -## Use+`search` returns JSON; `part` fetches one LCSC code; `health` checks the backend. Pipe it into+`jq`, a BOM, or a board-cost estimator.++![Terminal showing adom-jlcpcb search piped to jq for the first component including its Basic library tier, then a health check](/blob/app/adom-jlcpcb/screenshots/cli.png)  ```bash-# CLI-adom-jlcpcb search "STM32F103RBT6" --limit 5-adom-jlcpcb part C8734 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+adom-jlcpcb search "LM358" | jq '.components[0]'+adom-jlcpcb part "C7950" adom-jlcpcb health+``` -# UI-adom-jlcpcb app        # opens a Hydrogen webview tab+## Install -# Backend-JLCPCB_API_KEY_UNUSED_placeholder=<key> adom-jlcpcb serve    # runs on service-jlcpcb container+```bash+adompkg install adom-jlcpcb ``` -## Architecture+## How it works  ```-  any user container             service-jlcpcb (default-light)-  ──────────────────             ──────────────────────────────-  adom-jlcpcb search       ─┐-  adom-jlcpcb app          ─┼──▶ adom-jlcpcb serve (port 8774)-  adom-jlcpcb health       ─┘    + JLCPCB_API_KEY_UNUSED_placeholder-                                 + 10-min LRU cache-                                 + self-heal cron watchdog-                                          │-                                          ▼-                                api.jlcpcb.com / v2+  any container                          service-jlcpcb+  $ adom-jlcpcb search "LM358"  ──POST──▶  adom-jlcpcb serve  (:8779)+  $ adom-jlcpcb app                        LCSC resolve + cache+    (Hydrogen webview tab)+                                                 │+                                                 ▼+                                          JLCPCB / LCSC parts API ``` -The service container clones only `adom-inc/adom-jlcpcb`; its `service/watchdog.sh` runs every 2 min to (1) keep the service healthy and (2) `git fetch origin main` + rebuild + swap binary when a new release lands. No redeploy step after publishing — publish.sh pushes to main, the service container picks it up within ~5 minutes.+JLCPCB is an assembly-first workflow, so in [`adom-parts-search`](/app/adom-parts-search) it shows+as a library column rather than competing on the buy-now recommendation — but for "can JLCPCB+place this for me, and is it Basic?" it's the fastest answer in the workspace.++## See also -## Source+- [`adom-parts-search`](/app/adom-parts-search) — searches Mouser, DigiKey and JLCPCB at once and picks the best buy. -Source at [adom-inc/adom-jlcpcb](https://github.com/adom-inc/adom-jlcpcb) (private). The public artifact is this wiki page's `docker-binary` asset — source access is not required to install or use the app.+## License -Built following the canonical [adom-app-model](https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-app-model): single private repo for both client + service code, binary published to the wiki, service on its own default-light container, Tier A distribution via `gallia/install.mjs`.+MIT.
SKILL.md+209−4
@@ -1,7 +1,212 @@ --- name: adom-jlcpcb-description: >-  Search JLCPCB's 437K-component parts library (basic/preferred/extended). Rust CLI + Hydrogen webview app + Node.js + SQLite FTS5 backend — no API credentials needed (all local).+description: Search JLCPCB Electronics for components, pricing, stock, and part numbers. Use when the user asks to find components on JLCPCB, check JLCPCB pricing, look up a JLCPCB part number, search for in-stock parts, get price breaks, compare alternatives, or find a datasheet or product page for a specific part. Also use for "open jlcpcb", "jlcpcb app", "jlcpcb search UI". ----# JLCPCB Parts Library Search-Search JLCPCB's 437K-component parts library (basic/preferred/extended). Rust CLI + Hydrogen webview app + Node.js + SQLite FTS5 backend — no API credentials needed (all local).++# JLCPCB Electronics Search++Search JLCPCB's catalog of millions of electronic components from thousands of manufacturers. Real-time pricing, stock, lead times, and product links.++## What you have++A single CLI — `adom-jlcpcb` — with three faces:++1. **One-shot search from the shell**, for when the AI just needs the data:+   ```bash+   adom-jlcpcb search "STM32F103RBT6"+   adom-jlcpcb part C8734+   adom-jlcpcb health+   ```+   Each prints JSON on stdout and an `OK:` / `ERROR:` line on stderr, so `| jq` works cleanly.++2. **A Hydrogen webview app**, for when the user wants to browse interactively:+   ```bash+   adom-jlcpcb app        # opens a "JLCPCB Search" tab in Hydrogen+   ```+   The app has a search bar, in-stock filter, per-result cards with price breaks, MPN/JLCPCB-PN copy buttons, datasheet + product-page links, and hover tooltips on every control.++3. **A backend HTTP service** (port 8774), which is what the shared `service-jlcpcb` container runs:+   ```bash+   adom-jlcpcb serve      # JLCPCB_API_KEY_UNUSED_placeholder must be set+   ```+   The CLI and the app both talk to this backend over HTTP (`POST /` with an `{"action":"search"|"part"|"health", ...}` body, or `GET /search?keyword=...`). It caches responses for 10 min and keeps the API key server-side.++There is no MCP server. All access is through the CLI + HTTP service.++## Commands++### `adom-jlcpcb search <keyword>`++Search by keyword, MPN, or JLCPCB PN.++| Flag | Default | Description |+|---|---|---|+| `--limit N` | 10 | Max results (1–50) |+| `--in-stock-only` | false | Only parts currently in stock |+| `--api URL` / `$JLCPCB_API` | auto | Backend URL. If unset, the CLI tries `http://127.0.0.1:8774`, and if that's also down it falls back to calling JLCPCB directly (requires `JLCPCB_API_KEY_UNUSED_placeholder`) |++Examples:+```bash+adom-jlcpcb search "STM32F103RBT6" --limit 5+adom-jlcpcb search "USB-C connector" --in-stock-only+adom-jlcpcb search "LDO 3.3V 500mA SOT-23"+adom-jlcpcb search "ESP32 WiFi Bluetooth" | jq '.components[].mpn'+```++### `adom-jlcpcb part <part-number>`++Full detail lookup for a specific part. Accepts JLCPCB PN (`C8734`) or MPN (`STM32F103RBT6`). Returns a single-component result with full price-break table and attributes.++```bash+adom-jlcpcb part C8734+adom-jlcpcb part STM32F103RBT6 | jq '.components[0].price_tiers'+```++### `adom-jlcpcb app`++Open the search UI in a Hydrogen webview tab.++| Flag | Default | Description |+|---|---|---|+| `--port N` | 8892 | Local app HTTP server port |+| `--api URL` | auto | Backend to use (same resolution as `search`) |+| `--tab-name S` | "JLCPCB Search" | Hydrogen tab label |+| `--no-tab` | false | Skip opening the tab (test via curl or browser) |+| `--dev` | false | Enable the `/eval` hot-patch channel |++```bash+adom-jlcpcb app                         # normal use+adom-jlcpcb app --dev                   # enable /eval for UI debugging+adom-jlcpcb app --no-tab --port 8892 &  # local-browser mode+```++When `adom-jlcpcb app` is running, these HTTP endpoints are AI-drivable (no UI clicks needed):++```bash+# run a search via HTTP, watch the UI update live+curl -X POST http://127.0.0.1:8892/search \+  -H 'Content-Type: application/json' \+  -d '{"keyword":"ESP32","limit":5,"inStockOnly":false}'++# state + cached last-search+curl http://127.0.0.1:8892/state | jq++# UI-side console logs (what the frontend JS printed)+curl http://127.0.0.1:8892/console | jq '.messages[-10:]'++# backend-health proxy (so the UI doesn't need CORS to 8774)+curl http://127.0.0.1:8892/backend-health | jq++# shutdown cleanly (removes the Hydrogen tab and exits)+curl -X POST http://127.0.0.1:8892/shutdown+```++### `adom-jlcpcb serve`++Run the backend HTTP proxy. This is what the `service-jlcpcb` container runs.++| Flag | Default | Description |+|---|---|---|+| `--port N` / `$JLCPCB_PORT` | 8774 | Port |++Requires `JLCPCB_API_KEY_UNUSED_placeholder`. Endpoints:++```+GET  /health                                  — health + live API ping+GET  /search?keyword=...&limit=10&inStockOnly=false+GET  /part?partNumber=...+POST /              {"action":"search"|"part"|"health", ...}+GET  /              (text/html landing page)+```++### `adom-jlcpcb health`++Check reachability.++```bash+adom-jlcpcb health                 # checks backend (or falls back to JLCPCB direct)+adom-jlcpcb health --api http://127.0.0.1:8774+```++## What's in a result++Each component in `.components[]` has:++- `mouser_pn`    — JLCPCB part number, use this for BOM+- `mpn`          — manufacturer part number, use this for schematic/footprint+- `manufacturer` — manufacturer name+- `description`, `category`+- `stock`        — integer count in JLCPCB's warehouse (live, 10-min cache on backend)+- `stock_text`   — raw JLCPCB text ("In Stock", "Non-Stocked", etc.)+- `popularity`   — `low` / `medium` / `high` heuristic from stock+- `lead_time`    — factory lead time if ordering more+- `unit_price`   — price at qty 1+- `price_tiers`  — array of `{qty, price}` for quantity price breaks+- `min_order`, `mult` — minimum order + order multiple+- `rohs`         — compliance flag+- `lifecycle`    — New Product / Active / NRND / Obsolete+- `datasheet_url`, `product_url`, `image_url`+- `attributes`   — array of `{name, value}` from the product-attribute table++## Typical workflows++### Finding a part from scratch+```bash+adom-jlcpcb search "32-bit ARM Cortex-M3 microcontroller LQFP-48"+# pick one, then:+adom-jlcpcb part <jlcpcb-pn>+```++### Confirming price for a BOM entry+```bash+adom-jlcpcb part STM32F103RBT6 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+```++### Browsing interactively+```bash+adom-jlcpcb app+# then type in the search box, click Product page / Datasheet, etc.+```++## Running the backend++There are two common setups.++### Dedicated `service-jlcpcb` container (shared, has the API key)++The backend proxy runs on a dedicated `default-light` container with `JLCPCB_API_KEY_UNUSED_placeholder` set in `/etc/environment` and a cron watchdog (`service/watchdog.sh`, every 2 min) that (a) keeps the service healthy and (b) auto-pulls `origin/main` + rebuilds when new versions land. Consumer containers point `JLCPCB_API` at its public URL:++```+https://jlcpcb-wela51osctvp.adom.cloud+```++`curl /health` should return `{"ok":true, "api_reachable":true, "version":"X.Y.Z", ...}`. Recover the current URL from `~/service-watcher/services.json` (`jlcpcb` entry, `"primary": true`) if the slug has rotated. To point the CLI at it:++```bash+export JLCPCB_API=https://jlcpcb-wela51osctvp.adom.cloud+adom-jlcpcb search "LM317"+adom-jlcpcb app        # UI reads $JLCPCB_API automatically+```++Gallia users don't need to set `JLCPCB_API` explicitly — `adom-jlcpcb` is a Tier A app (see `adom-app-model`), so it's installed by every `gallia/install.mjs` run from the wiki binary. The CLI autodetects a local `adom-jlcpcb serve` on 127.0.0.1:8774 and falls back to calling JLCPCB directly if `JLCPCB_API_KEY_UNUSED_placeholder` is set locally.++### Local-only (quick testing)++On any container, if `JLCPCB_API_KEY_UNUSED_placeholder` is set and you run `adom-jlcpcb serve &`, the CLI and the app will autodetect `http://127.0.0.1:8774` and use it. This is the dev-loop path.++## Installation++The binary is distributed as a GitHub release from `adom-inc/adom-jlcpcb`. After downloading to `/usr/local/bin/adom-jlcpcb` and making it executable, run:++```bash+adom-jlcpcb install+```++This deploys the skill to `~/.claude/skills/jlcpcb/SKILL.md` and installs bash completions.++## Notes++- Price data does not include shipping or tariffs.+- Stock is live at query time (10-min cache on the backend).+- Results are sorted by JLCPCB's internal relevance algorithm, not strictly by stock.+- `JLCPCB_API_KEY_UNUSED_placeholder` is only ever needed on the backend (`serve`) side. Consumer containers just need `JLCPCB_API` pointing at a running backend.
VERSIONadded+1
@@ -0,0 +1 @@+0.1.0
build.shadded+27
@@ -0,0 +1,27 @@+#!/usr/bin/env bash+# Local build + install for adom-jlcpcb.+set -euo pipefail++cd "$(dirname "$0")"++echo "==> cargo build --release"+cargo build --release++BIN=target/release/adom-jlcpcb+if [ ! -x "$BIN" ]; then+  echo "ERROR: build did not produce $BIN" >&2+  exit 1+fi++DEST=/usr/local/bin/adom-jlcpcb+echo "==> installing $DEST (sudo)"+sudo install -m 0755 "$BIN" "$DEST"++echo "==> adom-jlcpcb install (skill + completions)"+adom-jlcpcb install++echo+echo "Done. Try:"+echo "  adom-jlcpcb --help"+echo "  adom-jlcpcb search 'STM32F103'"+echo "  adom-jlcpcb app"
docs/icon.svgadded+20
@@ -0,0 +1,20 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">+  <!-- Adom brand-teal mark: chip silhouette + magnifying lens -->+  <g fill="none" stroke="#00b8b0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">+    <!-- chip body -->+    <rect x="10" y="14" width="28" height="28" rx="3"/>+    <!-- chip pins (left) -->+    <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36"/>+    <!-- chip pins (right) -->+    <path d="M38 20 L42 20 M38 28 L42 28 M38 36 L42 36"/>+    <!-- chip pins (top) -->+    <path d="M16 10 L16 14 M24 10 L24 14 M32 10 L32 14"/>+    <!-- chip pins (bottom) -->+    <path d="M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>+  </g>+  <!-- magnifying lens overlay (filled accent) -->+  <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="#00b8b0" stroke-width="4"/>+  <line x1="50" y1="48" x2="58" y2="56" stroke="#00b8b0" stroke-width="5" stroke-linecap="round"/>+  <!-- lens inner highlight -->+  <circle cx="39" cy="37" r="3" fill="#00b8b0" opacity="0.6"/>+</svg>
package.json+2−6
@@ -2,10 +2,6 @@   "slug": "adom-jlcpcb",   "version": "1.0.0",   "type": "app",-  "description": "Search JLCPCB's 437K-component parts library (basic/preferred/extended). Rust CLI + Hydrogen webview app + Node.js + SQLite FTS5 backend — no API credentials needed (all local).",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  }+  "description": "Check the JLCPCB / LCSC parts library before you fab — Basic vs Extended tier, LCSC stock and assembly pricing, so you avoid surprise feeder-setup fees. CLI, a Hydrogen app, and a shared backend.",+  "dependencies": {} }
page.json+45−35
@@ -2,47 +2,57 @@   "schema_version": 1,   "type": "app",   "slug": "adom-jlcpcb",-  "title": "JLCPCB Parts Library Search",-  "brief": "Search JLCPCB's 437K-component parts library (basic/preferred/extended). Rust CLI + Hydrogen webview app + Node.js + SQLite FTS5 backend — no API credentials needed (all local).",-  "version": "0.1.0",-  "tags": [],-  "license": "MIT",-  "discovery_triggers": [-    "jlcpcb",-    "jlcpcb parts",-    "jlcpcb search",-    "lcsc",-    "lcsc part number",-    "basic parts",-    "preferred parts",-    "extended parts",-    "jlcpcb assembly",-    "pcba parts",-    "parts library",-    "jlcpcb price breaks",-    "list jlcpcb components",-    "category search jlcpcb"-  ],-  "discovery_pitch": "Search JLCPCB's parts library (437K components, SQLite + FTS5) — basic/preferred/extended classification, price breaks, stock. No API credentials needed.",+  "title": "JLCPCB Parts Library",+  "brief": "Check the JLCPCB / LCSC parts library before you fab — Basic vs Extended tier, LCSC stock and assembly pricing, so you avoid surprise feeder-setup fees. CLI, a Hydrogen app, and a shared backend.",+  "version": "1.0.0",   "install": {     "binary_name": "adom-jlcpcb",     "install_dir": "",-    "install_hint": "",-    "version_cmd": ""+    "install_hint": "adompkg install adom-jlcpcb",+    "version_cmd": "adom-jlcpcb --version"   },-  "readme": "# adom-jlcpcb\n\nSearch JLCPCB Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:\n\n- **CLI verbs** — `adom-jlcpcb search|part|health` return normalized JSON on stdout, ready for `| jq`.\n- **Hydrogen webview app** — `adom-jlcpcb app` opens a first-class \"JLCPCB Search\" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and JLCPCB PN, and a backend-health indicator.\n- **Backend HTTP service** — `adom-jlcpcb serve` runs an HTTP proxy (port 8774) with a 10-minute LRU cache. Keeps the JLCPCB API key server-side; consumer containers point `JLCPCB_API` at it.\n\n![JLCPCB search webview showing ESP32 module results with price breaks](https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/jlcpcb-search-results.png)\n\n## Install\n\n```bash\ncurl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/adom-jlcpcb \\\n  -o /tmp/adom-jlcpcb \\\n  && chmod +x /tmp/adom-jlcpcb \\\n  && sudo install -m 0755 /tmp/adom-jlcpcb /usr/local/bin/adom-jlcpcb \\\n  && adom-jlcpcb install\n```\n\nInstalls the binary to `/usr/local/bin/adom-jlcpcb`, deploys `~/.claude/skills/adom-jlcpcb/SKILL.md` and `~/.claude/skills/adom-jlcpcb-build/SKILL.md`, and registers bash completions.\n\nFor gallia containers, this is done automatically by `gallia/install.mjs` — adom-jlcpcb is a **Tier A app** (every Adom user needs component search day-one).\n\n## Use\n\n```bash\n# CLI\nadom-jlcpcb search \"STM32F103RBT6\" --limit 5\nadom-jlcpcb part C8734 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'\nadom-jlcpcb health\n\n# UI\nadom-jlcpcb app        # opens a Hydrogen webview tab\n\n# Backend\nJLCPCB_API_KEY_UNUSED_placeholder=<key> adom-jlcpcb serve    # runs on service-jlcpcb container\n```\n\n## Architecture\n\n```\n  any user container             service-jlcpcb (default-light)\n  ──────────────────             ──────────────────────────────\n  adom-jlcpcb search       ─┐\n  adom-jlcpcb app          ─┼──▶ adom-jlcpcb serve (port 8774)\n  adom-jlcpcb health       ─┘    + JLCPCB_API_KEY_UNUSED_placeholder\n                                 + 10-min LRU cache\n                                 + self-heal cron watchdog\n                                          │\n                                          ▼\n                                api.jlcpcb.com / v2\n```\n\nThe service container clones only `adom-inc/adom-jlcpcb`; its `service/watchdog.sh` runs every 2 min to (1) keep the service healthy and (2) `git fetch origin main` + rebuild + swap binary when a new release lands. No redeploy step after publishing — publish.sh pushes to main, the service container picks it up within ~5 minutes.\n\n## Source\n\nSource at [adom-inc/adom-jlcpcb](https://github.com/adom-inc/adom-jlcpcb) (private). The public artifact is this wiki page's `docker-binary` asset — source access is not required to install or use the app.\n\nBuilt following the canonical [adom-app-model](https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-app-model): single private repo for both client + service code, binary published to the wiki, service on its own default-light container, Tier A distribution via `gallia/install.mjs`.\n",   "author": {-    "id": "695820315b5f1e4db2fcf602",-    "name": "Kyle Bergstedt",-    "email": "[email protected]"+    "id": "6738b00a4dc87b8adbb0a5a3",+    "name": "John Lauer",+    "email": "[email protected]"   },+  "tags": [+    "jlcpcb",+    "lcsc",+    "basic-part",+    "extended-part",+    "assembly",+    "pcba",+    "electronics"+  ],+  "license": "MIT",   "visibility": {     "public": true   },-  "hero": null,-  "sample_prompts": [],-  "metadata": {},-  "created_at": "2026-05-28T05:28:39.179Z",-  "updated_at": "2026-05-28T05:28:39.179Z",-  "skills": []-}\ No newline at end of file+  "hero": {+    "path": "screenshots/hero.png",+    "type": "image"+  },+  "sample_prompts": [+    "is the LM358 a JLCPCB basic part?",+    "find a basic 0402 10k resistor on JLCPCB",+    "is my STM32 a basic or extended part on JLCPCB"+  ],+  "discovery_triggers": [+    "jlcpcb",+    "lcsc",+    "jlcpcb parts",+    "basic part",+    "extended part",+    "jlcpcb assembly",+    "jlcpcb library",+    "lcsc stock",+    "is it a basic part",+    "feeder fee",+    "pcba parts"+  ],+  "discovery_pitch": "Check the JLCPCB / LCSC parts library for Basic vs Extended tier, stock and assembly pricing before you commit a board to fab — CLI + Hydrogen webview app + shared backend service.",+  "metadata": {+    "platform": "linux"+  }+}
publish.shadded+118
@@ -0,0 +1,118 @@+#!/usr/bin/env bash+# adom-jlcpcb release publisher.+# Syncs VERSION → Cargo.toml → git tag → gh release → wiki asset → wiki page.+# Idempotent: safe to re-run for the same version (adom-wiki upload replaces).++set -euo pipefail+cd "$(dirname "$0")"++VERSION=$(cat VERSION | tr -d '[:space:]')+if [ -z "$VERSION" ]; then+  echo "ERROR: VERSION file is empty" >&2; exit 1+fi++# ANSI only when stdout is a real terminal (adom-cli-design rule:+# no raw escapes when the AI captures stdout).+if [ -t 1 ]; then C_OK='\033[32m'; C_ERR='\033[31m'; C_STEP='\033[36m'; C_RESET='\033[0m'+else C_OK=''; C_ERR=''; C_STEP=''; C_RESET=''+fi+ok()   { printf '%sOK:%s %s\n' "$C_OK" "$C_RESET" "$1"; }+fail() { printf '%sERROR:%s %s\n' "$C_ERR" "$C_RESET" "$1" >&2; exit 1; }+step() { printf '\n%s== %s ==%s\n' "$C_STEP" "$1" "$C_RESET"; }++# ── 0. Sanity: VERSION == Cargo.toml version ───────────────────────+step "0. sanity checks"+CARGO_VERSION=$(grep -m1 '^version =' Cargo.toml | sed -E 's/version = "([^"]+)"/\1/')+if [ "$VERSION" != "$CARGO_VERSION" ]; then+  fail "VERSION ($VERSION) != Cargo.toml ($CARGO_VERSION). Run: sed -i 's/^version = .*/version = \"$VERSION\"/' Cargo.toml"+fi+ok "VERSION + Cargo.toml both at $VERSION"++# ── 1. Build + verify binary --version ─────────────────────────────+step "1. cargo build --release"+cargo build --release+BIN=target/release/adom-jlcpcb+BIN_VER=$("$BIN" --version | awk '{print $NF}')+if [ "$BIN_VER" != "$VERSION" ]; then+  fail "binary --version ($BIN_VER) != VERSION ($VERSION)"+fi+ok "binary prints adom-jlcpcb $VERSION"++# ── 2. Git commit + tag + push ─────────────────────────────────────+step "2. git commit + tag v$VERSION"+git add -A+if ! git diff --cached --quiet; then+  git commit -m "release v$VERSION"+  ok "committed"+else+  ok "nothing to commit"+fi+git tag -f "v$VERSION"+git push origin main+git push origin "v$VERSION" --force+ok "pushed main + v$VERSION tag"++# ── 3. GitHub Release (optional, informational) ────────────────────+step "3. gh release create v$VERSION"+if gh release view "v$VERSION" >/dev/null 2>&1; then+  gh release upload "v$VERSION" "$BIN" --clobber+  ok "updated release v$VERSION binary"+else+  gh release create "v$VERSION" "$BIN" --title "v$VERSION" --generate-notes+  ok "created release v$VERSION"+fi++# ── 4. Wiki page publish/update ────────────────────────────────────+step "4. adom-wiki page publish apps/adom-jlcpcb"+TITLE=$(jq -r '.title' wiki/page.json)+BRIEF=$(jq -r '.brief' wiki/page.json)+adom-wiki page publish apps/adom-jlcpcb \+  --title "$TITLE" \+  --brief "$BRIEF" \+  --body-md wiki/body.md \+  --version "$VERSION" \+  --sample-prompt "Search JLCPCB|find me a USB-C connector on JLCPCB with stock > 1000" \+  --sample-prompt "Part lookup|look up JLCPCB PN C8734" \+  --sample-prompt "Open the app|open the JLCPCB search app in Hydrogen" \+  --sample-prompt "Compare alternatives|find cheaper alternatives to the AMS1117-3.3 on JLCPCB" \+  --sample-prompt "Check stock|is the ESP32-S3-WROOM-1U-N4R2 in stock at JLCPCB?"+ok "wiki page published at apps/adom-jlcpcb v$VERSION"++# ── 5. Wiki asset upload (canonical install surface) ───────────────+step "5. adom-wiki asset upload (docker-binary)"+adom-wiki asset upload apps/adom-jlcpcb --asset-type docker-binary --file "$BIN"+ok "uploaded $BIN to wiki at apps/adom-jlcpcb"++# ── 6. Push metadata (discovery_triggers, install_hint, releases) ──+step "6. adom-wiki page edit --field metadata"+TMP_META=$(mktemp)+jq --arg v "$VERSION" '.metadata | .version = $v' wiki/page.json > "$TMP_META"+adom-wiki page edit apps/adom-jlcpcb --field metadata --body-md "$TMP_META"+rm -f "$TMP_META"+ok "metadata updated"++# ── 7. Verify the wiki actually serves v$VERSION ───────────────────+step "7. verify wiki binary --version"+BINARY_URL=$(jq -r '.metadata.releases.adom_docker.binary_url' wiki/page.json)+if [ -z "$BINARY_URL" ] || [ "$BINARY_URL" = "null" ]; then+  fail "wiki page.json has no binary_url"+fi+CHECK=$(mktemp)+if ! curl -fsSL "$BINARY_URL" -o "$CHECK"; then+  rm -f "$CHECK"+  fail "wiki binary download failed at $BINARY_URL (may need a minute to propagate; re-run step 7)"+fi+chmod +x "$CHECK"+WIKI_VER=$("$CHECK" --version | awk '{print $NF}')+rm -f "$CHECK"+if [ "$WIKI_VER" != "$VERSION" ]; then+  fail "wiki serves $WIKI_VER but we just published $VERSION"+fi+ok "wiki binary returns --version adom-jlcpcb $VERSION"++echo+echo "───────────────────────────────────────────────"+echo "  adom-jlcpcb v$VERSION published."+echo "  Wiki: $BINARY_URL"+echo "  The service-jlcpcb container will auto-update within 2 minutes."+echo "───────────────────────────────────────────────"
screenshots/cli.pngadded
⋯ 1 unchanged line ⋯
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
screenshots/results-mcu.pngadded
⋯ 1 unchanged line ⋯
screenshots/results-passives.pngadded
⋯ 1 unchanged line ⋯
service/backend/cron/update-db.shadded+47
@@ -0,0 +1,47 @@+#!/bin/bash+# Daily JLCPCB database update+#+# Downloads a fresh copy of the CDFER database to a staging path,+# then swaps it in and signals the server to reload.+#+# Install as daily cron:+#   sudo cp cron/update-db.sh /etc/cron.daily/update-jlcpcb-db+#   sudo chmod +x /etc/cron.daily/update-jlcpcb-db+#+# Or add to crontab:+#   0 4 * * * /app/cron/update-db.sh >> /tmp/jlcpcb-update.log 2>&1++set -e++DB_DIR="${JLCPCB_DB_DIR:-/data}"+DB_PATH="${DB_DIR}/jlcpcb-components.sqlite3"+STAGING_PATH="${DB_DIR}/jlcpcb-components-new.sqlite3"+APP_DIR="${APP_DIR:-/app}"++echo "$(date '+%Y-%m-%d %H:%M:%S') Starting JLCPCB database update"++cd "$APP_DIR"++# Download new database to staging path+JLCPCB_DB_PATH="$STAGING_PATH" node setup-db.js --force++# Always rebuild the FTS index against the staged DB before swapping.+# setup-db.js populates it, but we've observed CDFER-shipped FTS tables+# arriving with rowids that don't align with components.rowid (=lcsc),+# which makes MATCH joins land on unrelated rows. This step is the+# fast, authoritative fix — see service/backend/rebuild-fts.cjs.+# Runs against the staging path so a failure doesn't leave the live+# DB in a half-rebuilt state.+echo "$(date '+%Y-%m-%d %H:%M:%S') Rebuilding search_fts against staged DB..."+JLCPCB_DB_PATH="$STAGING_PATH" node "$APP_DIR/rebuild-fts.cjs"++# Atomic swap+mv "$STAGING_PATH" "$DB_PATH"++echo "$(date '+%Y-%m-%d %H:%M:%S') Database swapped. Signaling server to reload..."++# Send SIGHUP to the Node.js server to trigger DB re-open+# (the server.js has a SIGHUP handler that closes and re-opens the DB)+pkill -HUP -f "node server.js" || true++echo "$(date '+%Y-%m-%d %H:%M:%S') Database update complete"
service/backend/package-lock.jsonadded+426
@@ -0,0 +1,426 @@+{+  "name": "gallia-jlcpcb-service",+  "version": "1.0.0",+  "lockfileVersion": 3,+  "requires": true,+  "packages": {+    "": {+      "name": "gallia-jlcpcb-service",+      "version": "1.0.0",+      "dependencies": {+        "better-sqlite3": "^11.0.0"+      }+    },+    "node_modules/base64-js": {+      "version": "1.5.1",+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ]+    },+    "node_modules/better-sqlite3": {+      "version": "11.10.0",+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",+      "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",+      "hasInstallScript": true,+      "dependencies": {+        "bindings": "^1.5.0",+        "prebuild-install": "^7.1.1"+      }+    },+    "node_modules/bindings": {+      "version": "1.5.0",+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",+      "dependencies": {+        "file-uri-to-path": "1.0.0"+      }+    },+    "node_modules/bl": {+      "version": "4.1.0",+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",+      "dependencies": {+        "buffer": "^5.5.0",+        "inherits": "^2.0.4",+        "readable-stream": "^3.4.0"+      }+    },+    "node_modules/buffer": {+      "version": "5.7.1",+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "dependencies": {+        "base64-js": "^1.3.1",+        "ieee754": "^1.1.13"+      }+    },+    "node_modules/chownr": {+      "version": "1.1.4",+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="+    },+    "node_modules/decompress-response": {+      "version": "6.0.0",+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",+      "dependencies": {+        "mimic-response": "^3.1.0"+      },+      "engines": {+        "node": ">=10"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/deep-extend": {+      "version": "0.6.0",+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",+      "engines": {+        "node": ">=4.0.0"+      }+    },+    "node_modules/detect-libc": {+      "version": "2.1.2",+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",+      "engines": {+        "node": ">=8"+      }+    },+    "node_modules/end-of-stream": {+      "version": "1.4.5",+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",+      "dependencies": {+        "once": "^1.4.0"+      }+    },+    "node_modules/expand-template": {+      "version": "2.0.3",+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/file-uri-to-path": {+      "version": "1.0.0",+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="+    },+    "node_modules/fs-constants": {+      "version": "1.0.0",+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="+    },+    "node_modules/github-from-package": {+      "version": "0.0.0",+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="+    },+    "node_modules/ieee754": {+      "version": "1.2.1",+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ]+    },+    "node_modules/inherits": {+      "version": "2.0.4",+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="+    },+    "node_modules/ini": {+      "version": "1.3.8",+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="+    },+    "node_modules/mimic-response": {+      "version": "3.1.0",+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",+      "engines": {+        "node": ">=10"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/minimist": {+      "version": "1.2.8",+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",+      "funding": {+        "url": "https://github.com/sponsors/ljharb"+      }+    },+    "node_modules/mkdirp-classic": {+      "version": "0.5.3",+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="+    },+    "node_modules/napi-build-utils": {+      "version": "2.0.0",+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="+    },+    "node_modules/node-abi": {+      "version": "3.87.0",+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",+      "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",+      "dependencies": {+        "semver": "^7.3.5"+      },+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/once": {+      "version": "1.4.0",+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",+      "dependencies": {+        "wrappy": "1"+      }+    },+    "node_modules/prebuild-install": {+      "version": "7.1.3",+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",+      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",+      "dependencies": {+        "detect-libc": "^2.0.0",+        "expand-template": "^2.0.3",+        "github-from-package": "0.0.0",+        "minimist": "^1.2.3",+        "mkdirp-classic": "^0.5.3",+        "napi-build-utils": "^2.0.0",+        "node-abi": "^3.3.0",+        "pump": "^3.0.0",+        "rc": "^1.2.7",+        "simple-get": "^4.0.0",+        "tar-fs": "^2.0.0",+        "tunnel-agent": "^0.6.0"+      },+      "bin": {+        "prebuild-install": "bin.js"+      },+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/pump": {+      "version": "3.0.4",+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",+      "dependencies": {+        "end-of-stream": "^1.1.0",+        "once": "^1.3.1"+      }+    },+    "node_modules/rc": {+      "version": "1.2.8",+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",+      "dependencies": {+        "deep-extend": "^0.6.0",+        "ini": "~1.3.0",+        "minimist": "^1.2.0",+        "strip-json-comments": "~2.0.1"+      },+      "bin": {+        "rc": "cli.js"+      }+    },+    "node_modules/readable-stream": {+      "version": "3.6.2",+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",+      "dependencies": {+        "inherits": "^2.0.3",+        "string_decoder": "^1.1.1",+        "util-deprecate": "^1.0.1"+      },+      "engines": {+        "node": ">= 6"+      }+    },+    "node_modules/safe-buffer": {+      "version": "5.2.1",+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ]+    },+    "node_modules/semver": {+      "version": "7.7.4",+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",+      "bin": {+        "semver": "bin/semver.js"+      },+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/simple-concat": {+      "version": "1.0.1",+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ]+    },+    "node_modules/simple-get": {+      "version": "4.0.1",+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "dependencies": {+        "decompress-response": "^6.0.0",+        "once": "^1.3.1",+        "simple-concat": "^1.0.0"+      }+    },+    "node_modules/string_decoder": {+      "version": "1.3.0",+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",+      "dependencies": {+        "safe-buffer": "~5.2.0"+      }+    },+    "node_modules/strip-json-comments": {+      "version": "2.0.1",+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",+      "engines": {+        "node": ">=0.10.0"+      }+    },+    "node_modules/tar-fs": {+      "version": "2.1.4",+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",+      "dependencies": {+        "chownr": "^1.1.1",+        "mkdirp-classic": "^0.5.2",+        "pump": "^3.0.0",+        "tar-stream": "^2.1.4"+      }+    },+    "node_modules/tar-stream": {+      "version": "2.2.0",+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",+      "dependencies": {+        "bl": "^4.0.3",+        "end-of-stream": "^1.4.1",+        "fs-constants": "^1.0.0",+        "inherits": "^2.0.3",+        "readable-stream": "^3.1.1"+      },+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/tunnel-agent": {+      "version": "0.6.0",+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",+      "dependencies": {+        "safe-buffer": "^5.0.1"+      },+      "engines": {+        "node": "*"+      }+    },+    "node_modules/util-deprecate": {+      "version": "1.0.2",+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="+    },+    "node_modules/wrappy": {+      "version": "1.0.2",+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="+    }+  }+}
service/backend/package.jsonadded+15
@@ -0,0 +1,15 @@+{+  "name": "adom-jlcpcb-service",+  "version": "1.0.0",+  "private": true,+  "type": "module",+  "dependencies": {+    "better-sqlite3": "^11.0.0"+  },+  "scripts": {+    "start": "node server.js",+    "setup": "node setup-db.js",+    "setup:force": "node setup-db.js --force",+    "setup:rebuild-fts": "node setup-db.js --rebuild-fts"+  }+}
service/backend/rebuild-fts.cjsadded+41
@@ -0,0 +1,41 @@+// Quick FTS index rebuild — the rowid column in search_fts got out of+// sync with components.rowid (=lcsc). Drop + repopulate from scratch.+//+// DB path:+//   1. First positional argument+//   2. $JLCPCB_DB_PATH env var+//   3. ./jlcpcb-components.sqlite3 (cwd-relative fallback)+const Database = require("better-sqlite3");+const dbPath = process.argv[2]+  || process.env.JLCPCB_DB_PATH+  || "./jlcpcb-components.sqlite3";+console.log("opening", dbPath);+const db = new Database(dbPath);+db.pragma("journal_mode = WAL");+console.log("dropping old search_fts...");+db.exec("DROP TABLE IF EXISTS search_fts");+db.exec("CREATE VIRTUAL TABLE search_fts USING fts5(lcsc, mfr, package, description, content=\"\")");+const total = db.prepare("SELECT COUNT(*) as n FROM components").get().n;+const ins = db.prepare("INSERT INTO search_fts(rowid, lcsc, mfr, package, description) VALUES (?, ?, ?, ?, ?)");+console.log("indexing", total, "rows...");+const batch = 10000; let done = 0; let off = 0;+while (off < total) {+  const rows = db.prepare("SELECT lcsc, mfr, package, description, extra FROM components ORDER BY lcsc LIMIT ? OFFSET ?").all(batch, off);+  const tx = db.transaction(() => {+    for (const r of rows) {+      let d = r.description || "";+      if (r.extra) { try {+        const x = JSON.parse(r.extra);+        if (x.description) d = x.description;+        if (x.title && x.title !== d) d += " " + x.title;+        if (x.attributes) d += " " + Object.values(x.attributes).filter(v => typeof v === "string").join(" ");+      } catch {} }+      // CRITICAL: use lcsc as the fts rowid so the join c.rowid = fts.rowid works+      ins.run(r.lcsc, String(r.lcsc), r.mfr || "", r.package || "", d);+    }+  });+  tx();+  off += batch; done += rows.length;+  if (done % 50000 === 0 || done === total) process.stdout.write("\r  "+done+"/"+total);+}+console.log("\ndone");
service/backend/server.jsadded+696
@@ -0,0 +1,696 @@+/**+ * JLCPCB Component Search Server — Shared Service Edition+ *+ * HTTP API backed by a local SQLite database (from CDFER/jlcpcb-parts-database).+ * No upstream dependency at query time — fully self-contained.+ *+ * This runs as a shared service so individual user containers don't need+ * the 1.3GB SQLite database.+ *+ * Port: 8774 (configurable via JLCPCB_PORT)+ * DB:   /data/jlcpcb-components.sqlite3 (configurable via JLCPCB_DB_PATH)+ *+ * Supports SIGHUP for graceful DB reload (used by the daily cron job).+ */++import { createServer } from 'http';+import { existsSync, statSync } from 'fs';+// Also expose as `fs.statSync` so the telemetry block reads naturally.+const fs = { statSync };+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';+import Database from 'better-sqlite3';++const __dirname = dirname(fileURLToPath(import.meta.url));+const PORT = parseInt(process.env.JLCPCB_PORT || '8774', 10);+const DB_PATH = process.env.JLCPCB_DB_PATH || join(__dirname, 'jlcpcb-components.sqlite3');++// ── Database management ─────────────────────────────────────────+let db, ftsTable, allCategories, CATEGORY_MAP;++function openDatabase() {+  if (!existsSync(DB_PATH)) {+    console.error(`[jlcpcb] Database not found at ${DB_PATH}`);+    console.error(`[jlcpcb] Run: node setup-db.js`);+    process.exit(1);+  }++  db = new Database(DB_PATH, { readonly: true });+  db.pragma('journal_mode = WAL');+  db.pragma('cache_size = -64000');++  const hasSearchFTS = Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='search_fts'").get());+  const hasComponentsFTS = Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='components_fts'").get());+  ftsTable = hasSearchFTS ? 'search_fts' : hasComponentsFTS ? 'components_fts' : null;++  if (!ftsTable) {+    console.warn('[jlcpcb] WARNING: No FTS table found. Full-text search will use LIKE (slower).');+  }++  allCategories = db.prepare('SELECT id, category, subcategory FROM categories').all();+  CATEGORY_MAP = buildCategoryMap();++  console.log(`[jlcpcb] Database loaded: ${DB_PATH}`);+  console.log(`[jlcpcb] FTS: ${ftsTable || 'none (LIKE fallback)'}`);+}++function buildCategoryMap() {+  function findCategoryIds(opts) {+    return allCategories+      .filter(c => {+        if (opts.subcategoryLike) return c.subcategory.toLowerCase().includes(opts.subcategoryLike.toLowerCase());+        if (opts.categoryLike) return c.category.toLowerCase().includes(opts.categoryLike.toLowerCase());+        return false;+      })+      .map(c => c.id);+  }++  const map = {+    resistors:              findCategoryIds({ categoryLike: 'resistor' }),+    capacitors:             findCategoryIds({ categoryLike: 'capacitor' }),+    microcontrollers:       findCategoryIds({ subcategoryLike: 'microcontroller' }),+    ldos:                   findCategoryIds({ subcategoryLike: 'LDO' }),+    voltage_regulators:     findCategoryIds({ subcategoryLike: 'voltage regulator' }),+    boost_converters:       findCategoryIds({ subcategoryLike: 'DC-DC' }),+    buck_boost_converters:  findCategoryIds({ subcategoryLike: 'DC-DC' }),+    mosfets:                findCategoryIds({ subcategoryLike: 'MOSFET' }),+    bjt_transistors:        findCategoryIds({ subcategoryLike: 'bipolar transistor' }),+    diodes:                 findCategoryIds({ categoryLike: 'diode' }),+    leds:                   findCategoryIds({ subcategoryLike: 'LED' }),+    led_drivers:            findCategoryIds({ subcategoryLike: 'LED driver' }),+    switches:               findCategoryIds({ subcategoryLike: 'switch' }),+    fuses:                  findCategoryIds({ subcategoryLike: 'fuse' }),+    relays:                 findCategoryIds({ subcategoryLike: 'relay' }),+    headers:                findCategoryIds({ subcategoryLike: 'pin header' }),+    usb_c_connectors:       findCategoryIds({ subcategoryLike: 'USB connector' }),+    jst_connectors:         findCategoryIds({ subcategoryLike: 'wire to board' }),+    wire_to_board_connectors: findCategoryIds({ subcategoryLike: 'wire to board' }),+    io_expanders:           findCategoryIds({ subcategoryLike: 'I/O expander' }),+    fpgas:                  findCategoryIds({ subcategoryLike: 'FPGA' }),+    arm_processors:         findCategoryIds({ subcategoryLike: 'microcontroller' }),+    risc_v_processors:      findCategoryIds({ subcategoryLike: 'microcontroller' }),+    wifi_modules:           findCategoryIds({ subcategoryLike: 'WiFi' }),+    accelerometers:         findCategoryIds({ subcategoryLike: 'accelerometer' }),+    gyroscopes:             findCategoryIds({ subcategoryLike: 'gyroscope' }),+    gas_sensors:            findCategoryIds({ subcategoryLike: 'gas sensor' }),+    adcs:                   findCategoryIds({ subcategoryLike: 'analog to digital' }),+    dacs:                   findCategoryIds({ subcategoryLike: 'digital to analog' }),+    analog_multiplexers:    findCategoryIds({ subcategoryLike: 'analog switch' }),+    analog_switches:        findCategoryIds({ subcategoryLike: 'analog switch' }),+    potentiometers:         findCategoryIds({ subcategoryLike: 'potentiometer' }),+    resistor_arrays:        findCategoryIds({ subcategoryLike: 'resistor network' }),+    battery_holders:        findCategoryIds({ subcategoryLike: 'battery' }),+    lcd_display:            findCategoryIds({ subcategoryLike: 'LCD' }),+    oled_display:           findCategoryIds({ subcategoryLike: 'OLED' }),+  };++  for (const [name, ids] of Object.entries(map)) {+    if (ids.length === 0) console.warn(`[jlcpcb] CATEGORY_MAP: no match for "${name}"`);+  }++  return map;+}++// Open DB on startup+openDatabase();++// SIGHUP handler: gracefully reload the database (used by cron after DB swap)+process.on('SIGHUP', () => {+  console.log('[jlcpcb] SIGHUP received — reloading database...');+  try {+    db.close();+    openDatabase();+    console.log('[jlcpcb] Database reloaded successfully.');+  } catch (err) {+    console.error('[jlcpcb] Failed to reload database:', err.message);+  }+});++// ── Price extraction ────────────────────────────────────────────+function extractPriceTiers(priceField) {+  if (!priceField) return [];+  try {+    const parsed = typeof priceField === 'string' ? JSON.parse(priceField) : priceField;+    if (!Array.isArray(parsed)) return [];+    return parsed.map(t => ({+      qty_from: t.qFrom ?? t.qty_from ?? null,+      qty_to: t.qTo ?? t.qty_to ?? null,+      price_usd: typeof t.price === 'number' ? Math.round(t.price * 10000) / 10000 : null,+    }));+  } catch {+    return [];+  }+}++function unitPrice(priceField) {+  const tiers = extractPriceTiers(priceField);+  if (tiers.length === 0) {+    const n = Number(priceField);+    return Number.isFinite(n) ? Math.round(n * 10000) / 10000 : null;+  }+  return tiers[0]?.price_usd ?? null;+}++// ── Extra field parsing ─────────────────────────────────────────+function parseExtra(extra) {+  if (!extra) return {};+  try {+    return typeof extra === 'string' ? JSON.parse(extra) : extra;+  } catch {+    return {};+  }+}++// ── Normalize a component row ───────────────────────────────────+function normalizeComponent(row) {+  const extra = parseExtra(row.extra);++  const result = {+    lcsc: `C${row.lcsc}`,+    lcsc_id: row.lcsc,+    mfr: row.mfr || null,+    description: extra?.description || row.description || null,+    package: row.package || null,+    stock: row.stock ?? 0,+    is_basic: Boolean(row.basic),+    is_preferred: Boolean(row.preferred),+    joints: row.joints ?? null,+    unit_price_usd: unitPrice(row.price),+    price_tiers: extractPriceTiers(row.price),+    datasheet: row.datasheet || extra?.datasheet?.url || null,+  };++  if (extra) {+    if (extra.manufacturer?.name) result.manufacturer = extra.manufacturer.name;+    if (extra.images) {+      const imgs = Array.isArray(extra.images) ? extra.images : [extra.images];+      const first = imgs[0];+      if (first) result.image_url = first['900x900'] || first['224x224'] || first['96x96'] || null;+      result.images = imgs.map(img => img['900x900'] || img['224x224'] || img['96x96']).filter(Boolean);+    }+    if (extra.attributes) result.attributes = extra.attributes;+    if (extra.url) result.jlcpcb_url = extra.url;+    result.jlcpcb_parts_url = `https://jlcpcb.com/parts/componentSearch?searchTxt=${result.lcsc}`;+  }++  return result;+}++// ── Popularity scoring ──────────────────────────────────────────+function popularityTier(stock) {+  if (stock >= 50000) return 'very_high';+  if (stock >= 10000) return 'high';+  if (stock >= 2000) return 'medium';+  if (stock >= 500) return 'low';+  return 'very_low';+}++function enrichWithPopularity(component) {+  return { ...component, popularity: popularityTier(component.stock) };+}++// ── Build dynamic WHERE for category_ids ────────────────────────+function categoryIdPlaceholders(ids) {+  return ids.map(() => '?').join(',');+}++// ── Action handlers ─────────────────────────────────────────────++function handleSearch({ query, keyword, limit = 20, package: pkg, is_basic, is_preferred }) {+  // Accept both `query` (historical) and `keyword` (matches the rest of+  // the adom ecosystem: adom-mouser, adom-digikey, adom-parts-search all+  // send `keyword`). Either works; `keyword` wins if both are set.+  query = keyword || query;+  const safeLimit = Math.min(Number(limit) || 20, 100);+  const overFetch = safeLimit * 3;+  let rows;++  if (!query || !String(query).trim()) {+    rows = db.prepare('SELECT * FROM components WHERE stock > 0 ORDER BY stock DESC LIMIT ?').all(safeLimit);+  } else if (/^c\d+$/i.test(query.trim())) {+    const lcscNum = parseInt(query.trim().replace(/^c/i, ''), 10);+    const row = db.prepare('SELECT * FROM components WHERE lcsc = ? AND stock > 0').get(lcscNum);+    rows = row ? [row] : [];+  } else if (ftsTable === 'search_fts') {+    // Use FTS5 for full-text search across mfr, package, description+    // Strip characters that break FTS5 MATCH syntax, quote each token+    const ftsQuery = query+      .replace(/['"(){}[\]:!@#$%^&*+=|\\/<>~`]/g, ' ')+      .trim()+      .split(/\s+/)+      .filter(Boolean)+      .map(t => `"${t}"`)+      .join(' ');+    try {+      rows = db.prepare(`+        SELECT c.* FROM components c+        JOIN search_fts fts ON c.rowid = fts.rowid+        WHERE search_fts MATCH ? AND c.stock > 0+        ORDER BY c.stock DESC LIMIT ?+      `).all(ftsQuery, overFetch);+    } catch {+      rows = []; // FTS parse error — fall through to LIKE+    }+    // If FTS returns nothing, fall back to LIKE on mfr+    if (rows.length === 0) {+      const pattern = `%${query}%`;+      rows = db.prepare(`+        SELECT * FROM components+        WHERE mfr LIKE ? AND stock > 0+        ORDER BY stock DESC LIMIT ?+      `).all(pattern, overFetch);+    }+  } else {+    // No FTS table — fall back to LIKE on mfr+    const pattern = `%${query}%`;+    rows = db.prepare(`+      SELECT * FROM components+      WHERE mfr LIKE ? AND stock > 0+      ORDER BY stock DESC LIMIT ?+    `).all(pattern, overFetch);+  }++  if (pkg) rows = rows.filter(r => r.package?.includes(pkg));+  if (is_basic === true || is_basic === 'true') rows = rows.filter(r => r.basic === 1);+  if (is_preferred === true || is_preferred === 'true') rows = rows.filter(r => r.preferred === 1);+  rows = rows.slice(0, safeLimit);++  const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));+  return { count: components.length, components };+}++function handleListComponents({ subcategory, search, keyword, package: pkg, limit = 20, is_basic, is_preferred }) {+  search = keyword || search;+  const safeLimit = Math.min(Number(limit) || 20, 100);+  let sql = 'SELECT c.* FROM components c';+  const params = [];++  if (subcategory) {+    sql += ' JOIN categories cat ON c.category_id = cat.id';+    sql += ' WHERE c.stock > 0 AND cat.subcategory = ?';+    params.push(subcategory);+  } else {+    sql += ' WHERE c.stock > 0';+  }++  if (search) {+    sql += ' AND (c.mfr LIKE ? OR c.description LIKE ? OR CAST(c.lcsc AS TEXT) LIKE ?)';+    const pattern = `%${search}%`;+    params.push(pattern, pattern, pattern);+  }++  if (pkg) { sql += ' AND c.package = ?'; params.push(pkg); }+  if (is_basic === true || is_basic === 'true') sql += ' AND c.basic = 1';+  if (is_preferred === true || is_preferred === 'true') sql += ' AND c.preferred = 1';++  sql += ' ORDER BY c.stock DESC LIMIT ?';+  params.push(safeLimit);++  const rows = db.prepare(sql).all(...params);+  const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));+  return { count: components.length, components };+}++function handleCategorySearch({ category, filters = {}, limit = 20 }) {+  if (typeof filters === 'string') {+    try { filters = JSON.parse(filters); } catch { filters = {}; }+  }++  const categoryIds = CATEGORY_MAP[category];+  if (!categoryIds || categoryIds.length === 0) {+    return handleListComponents({ subcategory: category, limit });+  }++  const safeLimit = Math.min(Number(limit) || 20, 100);+  let sql = `SELECT * FROM components WHERE stock > 0 AND category_id IN (${categoryIdPlaceholders(categoryIds)})`;+  const params = [...categoryIds];++  for (const [key, value] of Object.entries(filters)) {+    if (value === undefined || value === null || value === '') continue;+    if (key === 'package') {+      sql += ' AND package = ?';+      params.push(String(value));+    } else if (key === 'is_basic') {+      sql += ' AND basic = 1';+    } else if (key === 'is_preferred') {+      sql += ' AND preferred = 1';+    } else {+      sql += ' AND (mfr LIKE ? OR description LIKE ?)';+      const pattern = `%${value}%`;+      params.push(pattern, pattern);+    }+  }++  sql += ' ORDER BY stock DESC LIMIT ?';+  params.push(safeLimit);++  const rows = db.prepare(sql).all(...params);+  const components = rows.map(r => enrichWithPopularity(normalizeComponent(r)));+  return { count: components.length, category, components };+}++function handleCategories() {+  const rows = db.prepare(`+    SELECT cat.category, cat.subcategory, COUNT(*) as count+    FROM components c+    JOIN categories cat ON c.category_id = cat.id+    WHERE c.stock > 0+    GROUP BY cat.category, cat.subcategory+    ORDER BY cat.category, count DESC+  `).all();++  const hierarchy = {};+  for (const r of rows) {+    if (!hierarchy[r.category]) hierarchy[r.category] = [];+    hierarchy[r.category].push({ name: r.subcategory, count: r.count });+  }++  return { categories: hierarchy };+}++function handleHealth() {+  try {+    const total = db.prepare('SELECT COUNT(*) as n FROM components').get().n;+    const inStock = db.prepare('SELECT COUNT(*) as n FROM components WHERE stock > 0').get().n;++    // DB freshness telemetry. The file mtime tracks the last atomic+    // swap done by cron/update-db.sh, which pulls from+    // https://cdfer.github.io/jlcpcb-parts-database/+    // Expose age so operators don't need to SSH to answer "is the data+    // recent?". If db_age_hours climbs past ~36 h the nightly cron is+    // probably broken; past ~240 h CDFER's upstream may have stopped+    // publishing (they scrape LCSC; scrapers break).+    let dbLastModifiedIso = null;+    let dbAgeHours = null;+    let dbSizeBytes = null;+    try {+      const stat = fs.statSync(DB_PATH);+      dbLastModifiedIso = stat.mtime.toISOString();+      dbAgeHours = Math.round((Date.now() - stat.mtimeMs) / 36e5 * 10) / 10;+      dbSizeBytes = stat.size;+    } catch { /* DB path unreachable — ok/false already ran */ }++    return {+      ok: true,+      source: 'local_sqlite',+      upstream: 'https://cdfer.github.io/jlcpcb-parts-database/',+      total_components: total,+      in_stock: inStock,+      fts_table: ftsTable || 'none',+      db_path: DB_PATH,+      db_last_modified_iso: dbLastModifiedIso,+      db_age_hours: dbAgeHours,+      db_size_bytes: dbSizeBytes,+      refresh_cron: '0 4 * * *  (daily 04:00 UTC)',+    };+  } catch (err) {+    return { ok: false, source: 'local_sqlite', error: err.message };+  }+}++// ── Landing page ────────────────────────────────────────────────+function landingPage(health, baseUrl) {+  const n = (v) => (v ?? 0).toLocaleString();+  const api = baseUrl;+  return `<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>JLCPCB Component Search API</title>+<style>+  *{box-sizing:border-box;margin:0;padding:0}+  body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;padding:32px 24px}+  .container{max-width:860px;margin:0 auto}+  h1{font-size:24px;font-weight:700;margin-bottom:4px;display:flex;align-items:center;gap:10px}+  h1 .dot{width:10px;height:10px;border-radius:50%;background:${health.ok ? '#3fb950' : '#f85149'};flex-shrink:0}+  .subtitle{color:#8b949e;font-size:14px;margin-bottom:28px}+  .stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:32px}+  .stat{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:14px 18px;min-width:140px;flex:1}+  .stat .val{font-size:22px;font-weight:700;color:#58a6ff}+  .stat .label{font-size:12px;color:#8b949e;margin-top:2px}+  h2{font-size:16px;font-weight:600;margin:28px 0 12px;color:#e6edf3;border-bottom:1px solid #21262d;padding-bottom:8px}+  .endpoint{background:#161b22;border:1px solid #21262d;border-radius:8px;margin-bottom:12px;overflow:hidden}+  .ep-header{padding:12px 16px;display:flex;align-items:center;gap:10px;cursor:pointer}+  .ep-header:hover{background:#1c2129}+  .method{font-size:11px;font-weight:700;padding:3px 8px;border-radius:4px;font-family:monospace}+  .method.get{background:#0d2818;color:#3fb950}+  .method.post{background:#1c1e3a;color:#a371f7}+  .ep-path{font-family:monospace;font-size:14px;color:#e6edf3}+  .ep-desc{color:#8b949e;font-size:13px;margin-left:auto}+  .ep-body{padding:0 16px 14px;display:none}+  .ep-body.open{display:block}+  table{width:100%;border-collapse:collapse;margin-top:8px}+  th{text-align:left;font-size:11px;color:#8b949e;font-weight:600;padding:6px 8px;border-bottom:1px solid #21262d}+  td{font-size:13px;padding:6px 8px;border-bottom:1px solid #161b22;vertical-align:top}+  td:first-child{font-family:monospace;color:#79c0ff;white-space:nowrap}+  .example{background:#0d1117;border:1px solid #21262d;border-radius:6px;padding:10px 14px;font-family:monospace;font-size:13px;color:#8b949e;margin-top:10px;white-space:pre-wrap;word-break:break-all;position:relative}+  .example .kw{color:#ff7b72}+  .example .str{color:#a5d6ff}+  .try-link{font-size:12px;color:#58a6ff;text-decoration:none;margin-left:8px}+  .try-link:hover{text-decoration:underline}+  .footer{margin-top:40px;padding-top:16px;border-top:1px solid #21262d;color:#484f58;font-size:12px;display:flex;justify-content:space-between}+  .ai-examples{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}+  .ai-q{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:10px 14px;font-size:14px;color:#e6edf3;display:flex;align-items:center;gap:10px}+  .ai-q .ai-icon{color:#d2a8ff;font-size:16px;flex-shrink:0}+  a{color:#58a6ff;text-decoration:none}+  a:hover{text-decoration:underline}+</style>+</head>+<body>+<div class="container">+  <h1><span class="dot"></span> JLCPCB Component Search API</h1>+  <p class="subtitle">Shared service for searching the JLCPCB/LCSC component catalog. Backed by a local SQLite database updated daily from <a href="https://github.com/cdfer/jlcpcb-parts-database">cdfer/jlcpcb-parts-database</a>.</p>++  <div class="stats">+    <div class="stat"><div class="val">${n(health.total_components)}</div><div class="label">Total components</div></div>+    <div class="stat"><div class="val">${n(health.in_stock)}</div><div class="label">In stock</div></div>+    <div class="stat"><div class="val">${health.fts_table === 'search_fts' ? 'FTS5' : 'LIKE'}</div><div class="label">Search engine</div></div>+    <div class="stat"><div class="val">Daily</div><div class="label">Update frequency</div></div>+  </div>++  <h2>Endpoints</h2>++  <div class="endpoint">+    <div class="ep-header" onclick="toggle(this)">+      <span class="method get">GET</span>+      <span class="ep-path">/search</span>+      <span class="ep-desc">Full-text component search</span>+    </div>+    <div class="ep-body">+      <table>+        <tr><th>Param</th><th>Type</th><th>Description</th></tr>+        <tr><td>query</td><td>string</td><td>Search term — part number, MFR, description, or LCSC code (e.g. <code>C14663</code>)</td></tr>+        <tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>+        <tr><td>package</td><td>string</td><td>Filter by package (e.g. <code>0402</code>, <code>LQFP-48</code>)</td></tr>+        <tr><td>is_basic</td><td>bool</td><td>Only JLCPCB basic parts</td></tr>+        <tr><td>is_preferred</td><td>bool</td><td>Only JLCPCB preferred parts</td></tr>+      </table>+      <div class="example">curl "${api}/search?query=STM32&amp;limit=5"</div>+    </div>+  </div>++  <div class="endpoint">+    <div class="ep-header" onclick="toggle(this)">+      <span class="method get">GET</span>+      <span class="ep-path">/list</span>+      <span class="ep-desc">List components by subcategory</span>+    </div>+    <div class="ep-body">+      <table>+        <tr><th>Param</th><th>Type</th><th>Description</th></tr>+        <tr><td>subcategory</td><td>string</td><td>Exact subcategory name from /categories</td></tr>+        <tr><td>search</td><td>string</td><td>Filter within subcategory</td></tr>+        <tr><td>package</td><td>string</td><td>Filter by package</td></tr>+        <tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>+        <tr><td>is_basic</td><td>bool</td><td>Only basic parts</td></tr>+        <tr><td>is_preferred</td><td>bool</td><td>Only preferred parts</td></tr>+      </table>+      <div class="example">curl "${api}/list?subcategory=Microcontrollers%20(MCU/MPU/SOC)&amp;limit=5"</div>+    </div>+  </div>++  <div class="endpoint">+    <div class="ep-header" onclick="toggle(this)">+      <span class="method get">GET</span>+      <span class="ep-path">/category_search</span>+      <span class="ep-desc">Search within a named category</span>+    </div>+    <div class="ep-body">+      <table>+        <tr><th>Param</th><th>Type</th><th>Description</th></tr>+        <tr><td>category</td><td>string</td><td>Category key: <code>resistors</code>, <code>capacitors</code>, <code>microcontrollers</code>, <code>mosfets</code>, <code>leds</code>, etc.</td></tr>+        <tr><td>filters</td><td>JSON</td><td>Optional filters object: <code>{"package":"0402","is_basic":true}</code></td></tr>+        <tr><td>limit</td><td>int</td><td>Max results (default 20, max 100)</td></tr>+      </table>+      <div class="example">curl "${api}/category_search?category=capacitors&amp;limit=5"</div>+    </div>+  </div>++  <div class="endpoint">+    <div class="ep-header" onclick="toggle(this)">+      <span class="method get">GET</span>+      <span class="ep-path">/categories</span>+      <span class="ep-desc">List all categories and subcategories</span>+    </div>+    <div class="ep-body">+      <p style="color:#8b949e;font-size:13px;margin-top:4px">Returns a hierarchy of category &rarr; subcategory with component counts.</p>+      <div class="example">curl "${api}/categories"</div>+    </div>+  </div>++  <div class="endpoint">+    <div class="ep-header" onclick="toggle(this)">+      <span class="method get">GET</span>+      <span class="ep-path">/health</span>+      <span class="ep-desc">Service health check</span>+    </div>+    <div class="ep-body">+      <p style="color:#8b949e;font-size:13px;margin-top:4px">Returns JSON with <code>ok</code>, component counts, FTS status, and DB path.</p>+      <div class="example">curl "${api}/health"</div>+    </div>+  </div>++  <h2>AI Usage</h2>+  <p style="color:#8b949e;font-size:13px;margin-bottom:12px">Adom containers with Adom Viewer installed have this service available as an MCP tool. Just ask Claude in natural language:</p>+  <div class="ai-examples">+    <div class="ai-q"><span class="ai-icon">&#9671;</span> Find me a USB-C connector that's a JLCPCB basic part</div>+    <div class="ai-q"><span class="ai-icon">&#9671;</span> Search JLCPCB for STM32 microcontrollers in LQFP-48 package</div>+    <div class="ai-q"><span class="ai-icon">&#9671;</span> Look up LCSC part C14663 — what is it and how much does it cost?</div>+    <div class="ai-q"><span class="ai-icon">&#9671;</span> I need a 3.3V LDO regulator on JLCPCB, show me the cheapest basic parts</div>+    <div class="ai-q"><span class="ai-icon">&#9671;</span> Compare 0402 100nF capacitors on JLCPCB — show me basic parts sorted by price</div>+  </div>++  <h2>POST API</h2>+  <p style="color:#8b949e;font-size:13px;margin-bottom:12px">All endpoints also accept POST with JSON body. Set <code>action</code> to the endpoint name.</p>+  <div class="example"><span class="kw">curl</span> -X POST ${api} \\+  -H <span class="str">"Content-Type: application/json"</span> \\+  -d <span class="str">'{"action":"search","query":"ESP32","limit":5}'</span></div>++  <h2>Response Format</h2>+  <p style="color:#8b949e;font-size:13px;margin-bottom:8px">Each component in the results includes:</p>+  <table>+    <tr><th>Field</th><th>Description</th></tr>+    <tr><td>lcsc</td><td>LCSC part number (e.g. C14663)</td></tr>+    <tr><td>mfr</td><td>Manufacturer part number</td></tr>+    <tr><td>description</td><td>Component description</td></tr>+    <tr><td>package</td><td>Package type (0402, LQFP-48, etc.)</td></tr>+    <tr><td>stock</td><td>Current stock quantity</td></tr>+    <tr><td>is_basic</td><td>JLCPCB basic part (lowest assembly fee)</td></tr>+    <tr><td>is_preferred</td><td>JLCPCB preferred part</td></tr>+    <tr><td>unit_price_usd</td><td>Unit price in USD</td></tr>+    <tr><td>price_tiers</td><td>Array of quantity-based pricing tiers</td></tr>+    <tr><td>popularity</td><td>Stock-based tier: very_high, high, medium, low, very_low</td></tr>+    <tr><td>datasheet</td><td>Datasheet URL (when available)</td></tr>+    <tr><td>jlcpcb_parts_url</td><td>Link to JLCPCB parts page</td></tr>+    <tr><td>attributes</td><td>Key-value component attributes (when available)</td></tr>+  </table>++  <div class="footer">+    <span>JLCPCB Component Search Service &middot; Adom</span>+    <span>Data: <a href="https://github.com/cdfer/jlcpcb-parts-database">cdfer/jlcpcb-parts-database</a></span>+  </div>+</div>+<script>+function toggle(el){el.nextElementSibling.classList.toggle('open')}+// Auto-open first endpoint+document.querySelector('.ep-body').classList.add('open');+</script>+</body>+</html>`;+}++// ── HTTP Server ─────────────────────────────────────────────────+const server = createServer(async (req, res) => {+  res.setHeader('Access-Control-Allow-Origin', '*');+  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');+  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');++  if (req.method === 'OPTIONS') {+    res.writeHead(204);+    res.end();+    return;+  }++  let action, params;++  if (req.method === 'POST') {+    try {+      const body = await new Promise((resolve, reject) => {+        let data = '';+        req.on('data', chunk => data += chunk);+        req.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });+        req.on('error', reject);+      });+      action = body.action;+      params = body;+      delete params.action;+    } catch {+      res.writeHead(400, { 'Content-Type': 'application/json' });+      res.end(JSON.stringify({ error: 'Invalid JSON body' }));+      return;+    }+  } else if (req.method === 'GET') {+    const url = new URL(req.url, `http://localhost:${PORT}`);+    const path = url.pathname.replace(/^\//, '');++    // Serve HTML landing page at root for browsers+    if (!path && req.headers.accept?.includes('text/html')) {+      const health = handleHealth();+      // Derive public base URL from request headers (proxy sets Host / X-Forwarded-*)+      const proto = req.headers['x-forwarded-proto'] || 'https';+      const host = req.headers['x-forwarded-host'] || req.headers.host || `localhost:${PORT}`;+      const prefix = req.headers['x-forwarded-prefix'] || '';+      const baseUrl = host.includes('localhost') || host.includes('127.0.0.1')+        ? `http://${host}${prefix}`+        : `${proto}://${host}${prefix}`;+      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+      res.end(landingPage(health, baseUrl));+      return;+    }++    action = path || 'health';+    params = Object.fromEntries(url.searchParams);+  } else {+    res.writeHead(405, { 'Content-Type': 'application/json' });+    res.end(JSON.stringify({ error: 'Method not allowed' }));+    return;+  }++  try {+    let result;+    switch (action) {+      case 'search':+        result = handleSearch(params);+        break;+      case 'list':+        result = handleListComponents(params);+        break;+      case 'category_search':+        result = handleCategorySearch(params);+        break;+      case 'categories':+        result = handleCategories();+        break;+      case 'health':+        result = handleHealth();+        break;+      default:+        res.writeHead(400, { 'Content-Type': 'application/json' });+        res.end(JSON.stringify({ error: `Unknown action: ${action}` }));+        return;+    }+    res.writeHead(200, { 'Content-Type': 'application/json' });+    res.end(JSON.stringify(result));+  } catch (err) {+    console.error(`[jlcpcb] Error handling ${action}:`, err.message);+    res.writeHead(500, { 'Content-Type': 'application/json' });+    res.end(JSON.stringify({ error: err.message }));+  }+});++server.listen(PORT, '0.0.0.0', () => {+  console.log(`[jlcpcb] JLCPCB search server listening on 0.0.0.0:${PORT}`);+  console.log(`[jlcpcb] Source: local SQLite (${DB_PATH})`);+  console.log(`[jlcpcb] FTS: ${ftsTable || 'none (LIKE fallback)'}`);+});
service/backend/setup-db.jsadded+171
@@ -0,0 +1,171 @@+#!/usr/bin/env node++/**+ * JLCPCB Local Database Setup (Shared Service Edition)+ *+ * Downloads the CDFER/jlcpcb-parts-database SQLite file and prepares it+ * for local querying (indexes, enriched FTS).+ *+ * Usage:+ *   node setup-db.js            # download if not present+ *   node setup-db.js --force    # re-download even if present+ *   node setup-db.js --rebuild-fts  # rebuild FTS index only+ */++import { existsSync, createWriteStream, unlinkSync, mkdirSync } from 'fs';+import { get } from 'https';+import { join, dirname } from 'path';+import { fileURLToPath } from 'url';+import Database from 'better-sqlite3';++const __dirname = dirname(fileURLToPath(import.meta.url));+const DB_PATH = process.env.JLCPCB_DB_PATH || join(__dirname, 'jlcpcb-components.sqlite3');+const DB_URL = 'https://cdfer.github.io/jlcpcb-parts-database/jlcpcb-components.sqlite3';+const force = process.argv.includes('--force');+const rebuildFts = process.argv.includes('--rebuild-fts');++// Ensure parent directory exists (for /data/ path in container)+mkdirSync(dirname(DB_PATH), { recursive: true });++function download(url, dest) {+  return new Promise((resolve, reject) => {+    const follow = (url) => {+      get(url, (res) => {+        if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {+          follow(res.headers.location);+          return;+        }+        if (res.statusCode !== 200) {+          reject(new Error(`HTTP ${res.statusCode}`));+          return;+        }++        const total = parseInt(res.headers['content-length'], 10) || 0;+        let downloaded = 0;+        let lastPct = -1;+        const file = createWriteStream(dest);++        res.on('data', (chunk) => {+          downloaded += chunk.length;+          if (total) {+            const pct = Math.floor((downloaded / total) * 100);+            if (pct !== lastPct && pct % 5 === 0) {+              process.stdout.write(`\r   ${pct}% (${(downloaded / 1e6).toFixed(1)} / ${(total / 1e6).toFixed(1)} MB)`);+              lastPct = pct;+            }+          }+        });++        res.pipe(file);+        file.on('finish', () => { file.close(); console.log(''); resolve(); });+        file.on('error', (err) => { unlinkSync(dest); reject(err); });+      }).on('error', reject);+    };+    follow(url);+  });+}++function buildFTS(db) {+  console.log('   Building enriched FTS index (this takes ~30s)...');++  try { db.exec('DROP TABLE IF EXISTS search_fts'); } catch {}++  db.exec(`+    CREATE VIRTUAL TABLE search_fts USING fts5(+      lcsc, mfr, package, description,+      content=''+    );+  `);++  const batchSize = 10000;+  const total = db.prepare('SELECT COUNT(*) as n FROM components').get().n;+  const insert = db.prepare('INSERT INTO search_fts(rowid, lcsc, mfr, package, description) VALUES (?, ?, ?, ?, ?)');+  let offset = 0;+  let indexed = 0;++  while (offset < total) {+    const rows = db.prepare(`SELECT rowid, lcsc, mfr, package, description, extra FROM components LIMIT ? OFFSET ?`).all(batchSize, offset);+    const tx = db.transaction(() => {+      for (const r of rows) {+        let desc = r.description || '';+        if (r.extra) {+          try {+            const extra = JSON.parse(r.extra);+            if (extra.description) desc = extra.description;+            if (extra.title && extra.title !== desc) desc += ' ' + extra.title;+            if (extra.attributes) {+              const attrVals = Object.values(extra.attributes).filter(v => typeof v === 'string');+              if (attrVals.length) desc += ' ' + attrVals.join(' ');+            }+          } catch {}+        }+        insert.run(r.rowid, String(r.lcsc), r.mfr || '', r.package || '', desc);+      }+    });+    tx();+    offset += batchSize;+    indexed += rows.length;+    process.stdout.write(`\r   ${indexed.toLocaleString()} / ${total.toLocaleString()} rows indexed`);+  }+  console.log('\n   FTS index built.');+}++async function main() {+  console.log('JLCPCB Local Database Setup');+  console.log('===========================\n');++  // 1. Download+  if (existsSync(DB_PATH) && !force) {+    console.log(`1. Database already exists at ${DB_PATH}`);+    console.log('   (use --force to re-download)\n');+  } else {+    if (force && existsSync(DB_PATH)) unlinkSync(DB_PATH);+    console.log(`1. Downloading database...`);+    console.log(`   Source: ${DB_URL}`);+    await download(DB_URL, DB_PATH);+    console.log('   Done.\n');+  }++  // 2. Open and verify+  console.log('2. Preparing database...');+  const db = new Database(DB_PATH);+  db.pragma('journal_mode = WAL');++  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all().map(r => r.name);+  console.log(`   Tables: ${tables.join(', ')}`);++  console.log('   Adding indexes...');+  db.exec('CREATE INDEX IF NOT EXISTS idx_comp_stock ON components(stock DESC)');+  db.exec('CREATE INDEX IF NOT EXISTS idx_comp_catid ON components(category_id)');+  db.exec('CREATE INDEX IF NOT EXISTS idx_comp_basic ON components(basic) WHERE basic = 1');+  db.exec('CREATE INDEX IF NOT EXISTS idx_comp_preferred ON components(preferred) WHERE preferred = 1');+  db.exec('CREATE INDEX IF NOT EXISTS idx_comp_mfr ON components(mfr)');+  console.log('   Indexes ready.');++  const hasSearchFTS = tables.includes('search_fts');+  if (!hasSearchFTS || rebuildFts) {+    buildFTS(db);+  } else {+    console.log('   Enriched FTS index: found (use --rebuild-fts to rebuild)');+  }++  // 3. Stats+  console.log('\n3. Database stats:');+  const total = db.prepare('SELECT COUNT(*) as n FROM components').get().n;+  const inStock = db.prepare('SELECT COUNT(*) as n FROM components WHERE stock > 0').get().n;+  const basic = db.prepare('SELECT COUNT(*) as n FROM components WHERE basic = 1').get().n;+  const preferred = db.prepare('SELECT COUNT(*) as n FROM components WHERE preferred = 1').get().n;+  const cats = db.prepare('SELECT COUNT(DISTINCT category) as n FROM categories').get().n;+  const subcats = db.prepare('SELECT COUNT(DISTINCT subcategory) as n FROM categories').get().n;+  console.log(`   Total components: ${total.toLocaleString()}`);+  console.log(`   In stock:         ${inStock.toLocaleString()}`);+  console.log(`   Basic parts:      ${basic.toLocaleString()}`);+  console.log(`   Preferred parts:  ${preferred.toLocaleString()}`);+  console.log(`   Categories:       ${cats}`);+  console.log(`   Subcategories:    ${subcats}`);++  db.close();+  console.log('\nSetup complete.');+}++main().catch(err => { console.error('Setup failed:', err); process.exit(1); });
service/backend/start-jlcpcb.shadded+24
@@ -0,0 +1,24 @@+#!/bin/bash+# Start the JLCPCB search server if not already running+# Also triggers the daily DB update check via run-parts++JLCPCB_DIR="/home/adom/gallia/services/jlcpcb"+LOG_FILE="${JLCPCB_DIR}/server.log"++# Check if already running+if curl -sf --max-time 2 http://127.0.0.1:8774/health > /dev/null 2>&1; then+  echo "[jlcpcb] Server already running on port 8774"+  exit 0+fi++# Set up database if missing+if [ ! -f "$JLCPCB_DIR/jlcpcb-components.sqlite3" ]; then+  echo "[jlcpcb] Database missing, running setup..."+  cd "$JLCPCB_DIR"+  /usr/bin/node setup-db.js >> "$LOG_FILE" 2>&1+fi++# Start the server+cd "$JLCPCB_DIR"+nohup /usr/bin/node server.js >> "$LOG_FILE" 2>&1 &+echo "[jlcpcb] Server started (PID $!), logging to $LOG_FILE"
service/deploy.shadded+103
@@ -0,0 +1,103 @@+#!/usr/bin/env bash+# adom-jlcpcb service container bootstrap.+# Run ONCE on a freshly created default-light container.+# Usage: bash service/deploy.sh+#+# Unlike adom-mouser / adom-digikey (Rust backends proxying external APIs),+# JLCPCB is a Node.js HTTP server backed by a LOCAL 1.3GB SQLite + FTS5+# database (from CDFER/jlcpcb-parts-database). No API credentials needed —+# all queries run locally against the DB.++set -euo pipefail++REPO_URL="${ADOM_JLCPCB_REPO_URL:-https://github.com/adom-inc/adom-jlcpcb.git}"+REPO_DIR="${ADOM_JLCPCB_REPO:-$HOME/adom-jlcpcb}"+BACKEND_DIR="$REPO_DIR/service/backend"+PORT="${JLCPCB_PORT:-8774}"++ok()   { printf '  \033[32m✓\033[0m %s\n' "$1"; }+info() { printf '  \033[2m%s\033[0m\n' "$1"; }+fail() { printf '  \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; }++echo "== adom-jlcpcb service deploy =="++# 1. Prereqs+if ! command -v node >/dev/null; then+  info "installing node + npm + build-essential + cron..."+  sudo apt-get update -qq+  sudo apt-get install -y -qq nodejs npm build-essential python3 cron jq >/dev/null+  ok "node $(node --version) / npm $(npm --version)"+else+  ok "node already present ($(node --version))"+fi+sudo service cron start >/dev/null 2>&1 || sudo systemctl start cron 2>/dev/null || true++if ! command -v cargo >/dev/null; then+  info "installing rustup (for in-place CLI rebuilds; users pull the binary from wiki)..."+  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \+    sh -s -- -y --default-toolchain stable --profile minimal >/dev/null 2>&1 || \+    info "rustup install failed; CLI will only come from wiki"+  [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"+fi++# 2. Clone repo+if [ -d "$REPO_DIR/.git" ]; then+  ok "repo already cloned at $REPO_DIR"+  cd "$REPO_DIR" && git fetch --quiet origin main && git reset --hard origin/main+else+  info "cloning $REPO_URL..."+  git clone "$REPO_URL" "$REPO_DIR" || fail "git clone failed (is the repo private? set up gh auth first)"+  ok "cloned to $REPO_DIR"+fi++# 3. Build + install Rust CLI (optional)+cd "$REPO_DIR"+if command -v cargo >/dev/null; then+  info "building Rust CLI..."+  cargo build --release >/dev/null 2>&1 && \+    sudo install -m 0755 target/release/adom-jlcpcb /usr/local/bin/adom-jlcpcb && \+    ok "installed /usr/local/bin/adom-jlcpcb ($(adom-jlcpcb --version))" || \+    info "CLI build failed; consumer containers still get the binary from the wiki"+fi++# 4. Node backend deps + SQLite DB+cd "$BACKEND_DIR"+info "npm install (better-sqlite3 compiles natively — 1-2 min)..."+npm install --no-audit --no-fund --omit=dev 2>&1 | tail -1+ok "node_modules installed"++if [ ! -f "$BACKEND_DIR/jlcpcb-components.sqlite3" ]; then+  info "downloading SQLite DB (~1.3GB, 2-5 min)..."+  node setup-db.js+  ok "SQLite DB ready"+else+  ok "SQLite DB already present"+fi++# 5. Crons — watchdog + daily DB refresh + hourly smoke+WATCHDOG_CRON="*/2 * * * * $REPO_DIR/service/watchdog.sh >> /tmp/adom-jlcpcb-watchdog.log 2>&1"+DB_CRON="0 4 * * * JLCPCB_DB_PATH=$BACKEND_DIR/jlcpcb-components.sqlite3 $REPO_DIR/service/backend/cron/update-db.sh >> /tmp/adom-jlcpcb-db-refresh.log 2>&1"+SMOKE_CRON="15 * * * * $REPO_DIR/tests/cron-smoke.sh >> /tmp/adom-jlcpcb-smoke.log 2>&1"++(crontab -l 2>/dev/null | grep -v -E 'adom-jlcpcb|service/backend/cron/update-db' || true; \+ echo "$WATCHDOG_CRON"; \+ echo "$DB_CRON"; \+ echo "$SMOKE_CRON") | crontab -+ok "crons installed (watchdog 2m · DB refresh daily 04:00 · smoke hourly :15)"++chmod +x "$REPO_DIR/service/watchdog.sh" "$REPO_DIR/service/backend/cron/update-db.sh" "$REPO_DIR/tests/cron-smoke.sh" "$REPO_DIR/tests/smoke.sh" 2>/dev/null || true++# 6. First launch + health check+info "running watchdog to start Node server..."+bash "$REPO_DIR/service/watchdog.sh" || true+sleep 5++HEALTH=$(curl -sf --max-time 10 "http://127.0.0.1:$PORT/health" || true)+[ -z "$HEALTH" ] && fail "Node server did not come up — check /tmp/adom-jlcpcb-server.log"+ok "Node backend healthy on port $PORT"+echo "  $(echo "$HEALTH" | head -c 250)"++echo+echo "Next steps (run from your MAIN container):"+echo "  1. adom-cli carbon containers port-add <this-slug> --port $PORT --prefix jlcpcb"+echo "  2. curl the public URL + /health to confirm"
service/service.jsonadded+10
@@ -0,0 +1,10 @@+{+  "name": "adom-jlcpcb",+  "port": 8774,+  "health": "http",+  "health_path": "/health",+  "start": "cd service/backend && node server.js",+  "env_required": [],+  "log": "/tmp/adom-jlcpcb-server.log",+  "description": "JLCPCB parts library search — Node.js HTTP server backed by a local 1.3GB SQLite + FTS5 database (437K components from CDFER/jlcpcb-parts-database). Daily cron refresh."+}
service/watchdog.shadded+60
@@ -0,0 +1,60 @@+#!/usr/bin/env bash+# adom-jlcpcb watchdog — app-local, cron every 2 min.+# Responsibilities:+#   1. Keep the Node /health green (relaunch Node if down).+#   2. Auto-deploy: if origin/main has moved, pull + npm install + restart.+# No gallia dependency. No Rust backend — this watches the Node server.++set -euo pipefail+set -a; [ -f /etc/environment ] && . /etc/environment; set +a++PORT="${JLCPCB_PORT:-8774}"+LOG=/tmp/adom-jlcpcb-server.log+REPO_DIR="${ADOM_JLCPCB_REPO:-$HOME/adom-jlcpcb}"+BACKEND_DIR="$REPO_DIR/service/backend"++# ── 1. Self-update (pull + npm install if deps changed + restart) ──+if [ -d "$REPO_DIR/.git" ]; then+  cd "$REPO_DIR"+  local_sha=$(git rev-parse HEAD 2>/dev/null || echo "")+  if git fetch --quiet origin main 2>/dev/null; then+    remote_sha=$(git rev-parse origin/main 2>/dev/null || echo "")+    if [ -n "$remote_sha" ] && [ "$local_sha" != "$remote_sha" ]; then+      echo "[$(date -Iseconds)] watchdog: updating $local_sha -> $remote_sha" >> "$LOG"+      git reset --hard origin/main >> "$LOG" 2>&1+      # If service/backend/package.json changed since last run, reinstall deps.+      if git diff --name-only "$local_sha" "$remote_sha" 2>/dev/null | grep -q 'service/backend/package'; then+        (cd "$BACKEND_DIR" && npm install --no-audit --no-fund --omit=dev >> "$LOG" 2>&1)+      fi+      # Optional: rebuild Rust CLI if src/ changed+      if command -v cargo >/dev/null && git diff --name-only "$local_sha" "$remote_sha" 2>/dev/null | grep -qE '^(src/|Cargo)'; then+        (cd "$REPO_DIR" && cargo build --release >> "$LOG" 2>&1 && \+          sudo install -m 0755 target/release/adom-jlcpcb /usr/local/bin/adom-jlcpcb) || true+      fi+      # Trigger Node server restart to pick up new server.js+      pkill -f 'node.*service/backend/server.js' 2>/dev/null || true+      echo "[$(date -Iseconds)] watchdog: updated + restarting Node" >> "$LOG"+    fi+  fi+fi++# ── 2. Liveness check ──────────────────────────────────────────────+if curl -s --max-time 3 "http://127.0.0.1:$PORT/health" -o /dev/null; then+  exit 0  # listening; leave alone+fi++echo "[$(date -Iseconds)] watchdog: port $PORT not accepting, (re)launching Node server" >> "$LOG"+pkill -f 'node.*service/backend/server.js' 2>/dev/null || true+sleep 1+cd "$BACKEND_DIR"+export JLCPCB_DB_PATH="$BACKEND_DIR/jlcpcb-components.sqlite3"+nohup node server.js >> "$LOG" 2>&1 &+disown || true++sleep 3+if curl -s --max-time 3 "http://127.0.0.1:$PORT/health" -o /dev/null; then+  echo "[$(date -Iseconds)] watchdog: Node relaunch OK" >> "$LOG"+else+  echo "[$(date -Iseconds)] watchdog: Node relaunch FAILED" >> "$LOG"+  exit 1+fi
src/app/mod.rsadded+329
@@ -0,0 +1,329 @@+//! `adom-jlcpcb app` — opens a Hydrogen webview tab with a searchable+//! JLCPCB UI. The server here is a thin per-session wrapper that proxies+//! the real work to the shared backend (`adom-jlcpcb serve` on 8774, or+//! a remote JLCPCB_API URL).+//!+//! Frontend conventions (per app-creator skill):+//!   - brand fonts/colors+//!   - monochrome white icons inside the UI+//!   - Adom teal accent for primary actions+//!   - 2-way HTTP comms (every UI action has a server endpoint)+//!   - console forwarding + optional eval channel for dev+//!   - relative URLs for every fetch/asset (proxy-safe)+//!   - custom SVG favicon served at /favicon.svg++mod tab;++use crate::{note, ok, warn, AppArgs};+use serde_json::{json, Value};+use std::collections::VecDeque;+use std::sync::{Arc, Mutex};+use std::time::Duration;+use tiny_http::{Header, Method, Response, Server};++const UI_HTML: &str = include_str!("ui.html");+const ICON_SVG: &str = include_str!("../../docs/icon.svg");++const CONSOLE_MAX: usize = 500;++struct AppState {+    api: String,+    dev: bool,+    console: Mutex<VecDeque<Value>>,+    last_query: Mutex<Option<String>>,+    last_results: Mutex<Option<Value>>,+    pending_eval: Mutex<Option<(String, String)>>, // (id, code)+    eval_results: Mutex<std::collections::HashMap<String, Value>>,+}++fn json_resp(code: u16, body: Value) -> Response<std::io::Cursor<Vec<u8>>> {+    Response::from_string(body.to_string())+        .with_status_code(code)+        .with_header(Header::from_bytes("Content-Type", "application/json").unwrap())+        .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap())+}++fn read_body(req: &mut tiny_http::Request) -> Result<Value, String> {+    use std::io::Read;+    let mut body = String::new();+    req.as_reader()+        .read_to_string(&mut body)+        .map_err(|e| format!("read body: {}", e))?;+    if body.is_empty() {+        return Ok(Value::Null);+    }+    serde_json::from_str(&body).map_err(|e| format!("parse JSON: {}", e))+}++fn backend_post(api: &str, body: Value) -> Result<Value, String> {+    let url = if api.ends_with('/') { api.to_string() } else { format!("{}/", api) };+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(30))+        .build();+    let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {+        Ok(r) => r,+        // ureq treats 4xx/5xx as errors; unwrap the response so we can+        // forward the JSON body ({"error":"..."}) to the caller.+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("backend POST failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))+}++fn backend_get(url: &str) -> Result<Value, String> {+    let agent = ureq::AgentBuilder::new()+        .timeout(Duration::from_secs(10))+        .build();+    let res = match agent.get(url).call() {+        Ok(r) => r,+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("backend GET failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("backend JSON parse failed: {}", e))+}++fn resolve_backend(explicit: Option<String>) -> String {+    if let Some(s) = explicit.filter(|s| !s.is_empty()) {+        return s;+    }+    "http://127.0.0.1:8774".into()+}++pub fn run(args: AppArgs) -> Result<(), String> {+    let api = resolve_backend(args.api.clone());+    ok(format!("JLCPCB app starting on port {} (backend: {})", args.port, api));++    let state = Arc::new(AppState {+        api: api.clone(),+        dev: args.dev,+        console: Mutex::new(VecDeque::with_capacity(CONSOLE_MAX)),+        last_query: Mutex::new(None),+        last_results: Mutex::new(None),+        pending_eval: Mutex::new(None),+        eval_results: Mutex::new(std::collections::HashMap::new()),+    });++    let addr = format!("0.0.0.0:{}", args.port);+    let server = Server::http(&addr).map_err(|e| format!("bind {}: {}", addr, e))?;++    // Verify backend reachable up-front; non-fatal, UI shows the error.+    match backend_get(&format!("{}/health", api)) {+        Ok(v) => note!("  backend health: {}", v),+        Err(e) => warn(format!("backend unreachable at {}: {}", api, e)),+    }++    // Open the Hydrogen tab unless --no-tab+    if !args.no_tab {+        let proxy_url = tab::proxy_url(args.port);+        match tab::add_webview_tab(&args.tab_name, &proxy_url, ICON_SVG) {+            Ok(_) => ok(format!("opened Hydrogen tab '{}' → {}", args.tab_name, proxy_url)),+            Err(e) => warn(format!("couldn't open tab via adom-cli: {}. You can still open {} manually.", e, proxy_url)),+        }+    }++    // Ctrl-c cleanup is best-effort via the /shutdown endpoint.+    // If the process is killed abruptly, the user can run:+    //   adom-cli hydrogen workspace remove-tab --name "JLCPCB Search"+    note!("  {}Press ctrl-c or curl POST /shutdown to stop.{}", crate::DIM, crate::RESET);++    for mut req in server.incoming_requests() {+        let url = req.url().to_string();+        let path = url.split_once('?').map(|(p, _)| p).unwrap_or(&url).to_string();+        let method = req.method().clone();+        let state_ref = Arc::clone(&state);++        let result: (u16, Value, bool) = match (method.clone(), path.as_str()) {+            // Static+            (Method::Get, "/") | (Method::Get, "/index.html") => {+                let _ = req.respond(+                    Response::from_string(UI_HTML)+                        .with_status_code(200)+                        .with_header(Header::from_bytes("Content-Type", "text/html; charset=utf-8").unwrap())+                        .with_header(Header::from_bytes("Cache-Control", "no-cache").unwrap()),+                );+                continue;+            }+            (Method::Get, "/favicon.svg") => {+                let _ = req.respond(+                    Response::from_string(ICON_SVG)+                        .with_status_code(200)+                        .with_header(Header::from_bytes("Content-Type", "image/svg+xml").unwrap())+                        .with_header(Header::from_bytes("Cache-Control", "max-age=86400").unwrap()),+                );+                continue;+            }++            // State + control+            (Method::Get, "/state") => {+                let q = state_ref.last_query.lock().unwrap().clone();+                let r = state_ref.last_results.lock().unwrap().clone();+                (200, json!({+                    "backend": state_ref.api,+                    "dev": state_ref.dev,+                    "last_query": q,+                    "last_results": r,+                }), false)+            }++            (Method::Post, "/search") => match read_body(&mut req) {+                Ok(body) => {+                    let keyword = body.get("keyword").and_then(|v| v.as_str()).unwrap_or("").to_string();+                    let limit = body.get("limit").and_then(|v| v.as_u64()).unwrap_or(20);+                    let in_stock = body.get("inStockOnly").and_then(|v| v.as_bool()).unwrap_or(false);+                    if keyword.trim().is_empty() {+                        (400, json!({"error": "keyword is required"}), false)+                    } else {+                        match backend_post(+                            &state_ref.api,+                            json!({"action":"search","keyword":keyword,"limit":limit,"inStockOnly":in_stock}),+                        ) {+                            Ok(v) => {+                                if let Some(e) = v.get("error").and_then(|v| v.as_str()) {+                                    (502, json!({"error": e}), false)+                                } else {+                                    *state_ref.last_query.lock().unwrap() = Some(keyword);+                                    *state_ref.last_results.lock().unwrap() = Some(v.clone());+                                    (200, v, false)+                                }+                            }+                            Err(e) => (502, json!({"error": e}), false),+                        }+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },++            // /categories — proxy to backend action=categories (no body)+            (Method::Get, "/categories") => {+                match backend_post(&state_ref.api, json!({"action":"categories"})) {+                    Ok(v) => (200, v, false),+                    Err(e) => (502, json!({"error": e}), false),+                }+            }++            // /list — proxy to backend action=list (category, subcategory?, limit, inStockOnly)+            (Method::Post, "/list") => match read_body(&mut req) {+                Ok(body) => {+                    let mut proxied = json!({"action": "list"});+                    for k in ["category", "subcategory", "limit", "inStockOnly"] {+                        if let Some(v) = body.get(k) { proxied[k] = v.clone(); }+                    }+                    match backend_post(&state_ref.api, proxied) {+                        Ok(v) => (200, v, false),+                        Err(e) => (502, json!({"error": e}), false),+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },++            // /category-search — keyword scoped to category+            (Method::Post, "/category-search") => match read_body(&mut req) {+                Ok(body) => {+                    let mut proxied = json!({"action": "category_search"});+                    for k in ["keyword", "category", "limit", "inStockOnly"] {+                        if let Some(v) = body.get(k) { proxied[k] = v.clone(); }+                    }+                    match backend_post(&state_ref.api, proxied) {+                        Ok(v) => (200, v, false),+                        Err(e) => (502, json!({"error": e}), false),+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },++            (Method::Get, "/backend-health") => {+                match backend_get(&format!("{}/health", state_ref.api)) {+                    Ok(v) => (200, v, false),+                    Err(e) => (502, json!({"error": e, "backend": state_ref.api}), false),+                }+            }++            // Console forwarding (UI → server)+            (Method::Post, "/console") => match read_body(&mut req) {+                Ok(v) => {+                    let mut cons = state_ref.console.lock().unwrap();+                    if cons.len() >= CONSOLE_MAX {+                        cons.pop_front();+                    }+                    cons.push_back(v);+                    (200, json!({"ok": true}), false)+                }+                Err(e) => (400, json!({"error": e}), false),+            },+            (Method::Get, "/console") => {+                let cons: Vec<Value> = state_ref.console.lock().unwrap().iter().cloned().collect();+                (200, json!({"messages": cons}), false)+            }+            (Method::Delete, "/console") => {+                state_ref.console.lock().unwrap().clear();+                (200, json!({"ok": true}), false)+            }++            // Eval channel (dev only — gated on --dev flag)+            (Method::Post, "/eval") if state_ref.dev => match read_body(&mut req) {+                Ok(v) => {+                    let code = v.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();+                    if code.is_empty() {+                        (400, json!({"error": "code is required"}), false)+                    } else {+                        let id = format!("ev{}", rand_suffix());+                        *state_ref.pending_eval.lock().unwrap() = Some((id.clone(), code));+                        (200, json!({"id": id}), false)+                    }+                }+                Err(e) => (400, json!({"error": e}), false),+            },+            (Method::Get, "/eval/pending") if state_ref.dev => {+                let pending = state_ref.pending_eval.lock().unwrap().take();+                match pending {+                    Some((id, code)) => (200, json!({"id": id, "code": code}), false),+                    None => (200, json!({}), false),+                }+            }+            (Method::Post, path) if state_ref.dev && path.starts_with("/eval/") && path.ends_with("/result") => {+                let id = path.trim_start_matches("/eval/").trim_end_matches("/result").to_string();+                match read_body(&mut req) {+                    Ok(v) => {+                        let result = v.get("result").cloned().unwrap_or(Value::Null);+                        state_ref.eval_results.lock().unwrap().insert(id.clone(), result);+                        (200, json!({"ok": true}), false)+                    }+                    Err(e) => (400, json!({"error": e}), false),+                }+            }+            (Method::Get, path) if state_ref.dev && path.starts_with("/eval/") => {+                let id = path.trim_start_matches("/eval/").to_string();+                let r = state_ref.eval_results.lock().unwrap().get(&id).cloned();+                match r {+                    Some(v) => (200, json!({"result": v, "done": true}), false),+                    None => (200, json!({"done": false}), false),+                }+            }++            // Shutdown+            (Method::Post, "/shutdown") => {+                if !args.no_tab {+                    let _ = tab::remove_webview_tab(&args.tab_name);+                }+                let _ = req.respond(json_resp(200, json!({"ok": true, "shutdown": true})));+                ok("shutdown requested, exiting");+                std::process::exit(0);+            }++            _ => (404, json!({"error": format!("unknown endpoint: {} {}", method, path)}), false),+        };++        let _ = req.respond(json_resp(result.0, result.1));+    }+    Ok(())+}++fn rand_suffix() -> String {+    // Tiny rand using system time — good enough for per-request ids.+    let now = std::time::SystemTime::now()+        .duration_since(std::time::UNIX_EPOCH)+        .unwrap_or_default()+        .as_nanos();+    format!("{:x}", now)+}+
src/app/tab.rsadded+134
@@ -0,0 +1,134 @@+//! Hydrogen tab lifecycle via `adom-cli hydrogen workspace`.++use base64::Engine;+use std::process::Command;++const WEBVIEW_PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++/// Substitute the current port into VSCODE_PROXY_URI so the webview+/// loads via the Coder proxy (same-origin with hydrogen.adom.inc).+/// Falls back to 127.0.0.1 for local testing when the env var is not+/// set — `adom-jlcpcb app --no-tab` + `$ open http://127.0.0.1:8892`.+pub fn proxy_url(port: u16) -> String {+    match std::env::var("VSCODE_PROXY_URI") {+        Ok(tmpl) => tmpl.replace("{{port}}", &port.to_string()),+        Err(_) => format!("http://127.0.0.1:{}/", port),+    }+}++fn icon_data_url(icon_svg: &str) -> String {+    let b64 = base64::engine::general_purpose::STANDARD.encode(icon_svg.as_bytes());+    format!("data:image/svg+xml;base64,{}", b64)+}++pub fn add_webview_tab(name: &str, url: &str, icon_svg: &str) -> Result<(), String> {+    // Find the focused leaf we should attach to.+    let leaf = focused_leaf_for_tab()?;+    let initial_state = serde_json::json!({ "url": url }).to_string();+    let icon = icon_data_url(icon_svg);+    let out = Command::new("adom-cli")+        .args([+            "hydrogen",+            "workspace",+            "add-tab",+            "--panel-id",+            &leaf,+            "--panel-type",+            WEBVIEW_PANEL_TYPE,+            "--display-name",+            name,+            "--display-icon",+            &icon,+            "--initial-state",+            &initial_state,+        ])+        .output()+        .map_err(|e| format!("adom-cli add-tab: {}", e))?;+    if !out.status.success() {+        return Err(format!(+            "adom-cli add-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++pub fn remove_webview_tab(name: &str) -> Result<(), String> {+    let out = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "remove-tab", "--name", name])+        .output()+        .map_err(|e| format!("adom-cli remove-tab: {}", e))?;+    if !out.status.success() {+        return Err(format!(+            "adom-cli remove-tab failed: {}",+            String::from_utf8_lossy(&out.stderr)+        ));+    }+    Ok(())+}++/// Pick a leaf panel that is NOT the VS Code editor, so we don't cover+/// the user's code view. Walk the workspace tree, find nodes with+/// `type: "leaf"`, check whether any tab in the leaf is a VS Code+/// editor (`...eeee-4000-a000-...`). Return the first non-editor+/// leaf's id. Falls back to the focused panel id if nothing else fits.+fn focused_leaf_for_tab() -> Result<String, String> {+    let out = Command::new("adom-cli")+        .args(["hydrogen", "workspace", "get"])+        .output()+        .map_err(|e| format!("adom-cli workspace get: {}", e))?;+    if !out.status.success() {+        return Err(format!("adom-cli workspace get failed: {}", String::from_utf8_lossy(&out.stderr)));+    }+    let v: serde_json::Value = serde_json::from_slice(&out.stdout)+        .map_err(|e| format!("parse workspace JSON: {}", e))?;++    let mut leaves: Vec<LeafInfo> = Vec::new();+    walk_leaves(&v, &mut leaves);++    // Prefer leaves that contain no VS Code editor tabs.+    let non_editor: Vec<&LeafInfo> = leaves.iter().filter(|l| !l.has_editor).collect();+    if let Some(l) = non_editor.first() {+        return Ok(l.id.clone());+    }+    if let Some(id) = v.get("focusedPanelId").and_then(|v| v.as_str()) {+        return Ok(id.to_string());+    }+    leaves+        .first()+        .map(|l| l.id.clone())+        .ok_or_else(|| "no panels found in workspace".into())+}++struct LeafInfo {+    id: String,+    has_editor: bool,+}++fn walk_leaves(v: &serde_json::Value, out: &mut Vec<LeafInfo>) {+    if let Some(obj) = v.as_object() {+        if obj.get("type").and_then(|v| v.as_str()) == Some("leaf") {+            if let Some(id) = obj.get("id").and_then(|v| v.as_str()) {+                let tabs = obj.get("tabs").and_then(|v| v.as_array());+                let has_editor = tabs+                    .map(|arr| {+                        arr.iter().any(|t| {+                            let pt = t.get("panelType").and_then(|v| v.as_str()).unwrap_or("");+                            // VS Code editor panel UUID has "-eeee-4000-a000-"+                            // or "-0030-4000-a000-". Web View is "-0031-".+                            pt.contains("eeee-4000") || pt.contains("0030-4000")+                        })+                    })+                    .unwrap_or(false);+                out.push(LeafInfo { id: id.to_string(), has_editor });+            }+        }+        for (_, child) in obj {+            walk_leaves(child, out);+        }+    } else if let Some(arr) = v.as_array() {+        for c in arr {+            walk_leaves(c, out);+        }+    }+}
src/app/ui.htmladded+638
@@ -0,0 +1,638 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1">+<title>JLCPCB Search</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2') format('woff2'),+       url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 600; font-display: swap;+}+@font-face {+  font-family: 'Familjen Grotesk';+  src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+  font-weight: 400; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+  font-weight: 500; font-display: swap;+}+@font-face {+  font-family: 'Satoshi';+  src: url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');+  font-weight: 600 900; font-display: swap;+}++:root {+  --bg: #0d1117;+  --surface: #161b22;+  --surface2: #1c2129;+  --border: #30363d;+  --text: #e6edf3;+  --text-dim: #8b949e;+  --accent: #00b8b0;+  --green: #3fb950;+  --red: #f85149;+  --yellow: #d29922;+  --purple: #8C6BF7;+  --blue: #58a6ff;+}++* { box-sizing: border-box; margin: 0; padding: 0; }+html, body {+  background: var(--bg);+  color: var(--text);+  font-family: 'Satoshi', sans-serif;+  font-size: 14px;+  line-height: 1.55;+  min-height: 100vh;+  /* Tooltips default-anchor `left: 0` and grow rightward; a tooltip on a+     right-edge trigger extends up to 340px past the viewport even at+     opacity:0, which creates a horizontal scrollbar. overflow-x: hidden+     clips that silently. Specific right-edge triggers also carry+     data-tooltip-align="right" for correct semantics. */+  overflow-x: hidden;+}+body { padding: 24px 28px 80px; }+h1, h2, h3 { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; }+code, pre, .mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }++/* --- Header --- */+.hdr { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }+.hdr .logo {+  /* In-app icons are monochrome white (var(--text)) per brand/SKILL.md §"Icons+     inside the UI must be monochrome white (#e6edf3) or inherit currentColor+     from a white-text context". The favicon at docs/icon.svg can be teal —+     this rule only governs icons rendered INSIDE the running app. */+  width: 32px; height: 32px; color: var(--text);+  display: inline-flex; align-items: center; justify-content: center;+}+.hdr h1 { font-size: 22px; letter-spacing: -0.01em; }+.hdr .subtitle { color: var(--text-dim); font-size: 13px; margin-top: 2px; }+.hdr .backend-status {+  margin-left: auto; display: flex; align-items: center; gap: 6px;+  font-size: 12px; color: var(--text-dim);+  padding: 4px 10px; border-radius: 8px;+  background: var(--surface); border: 1px solid var(--border);+}+.hdr .backend-status .dot {+  width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);+  transition: background 0.2s;+}+.hdr .backend-status.ok .dot { background: var(--green); box-shadow: 0 0 0 2px rgba(63,185,80,0.2); }+.hdr .backend-status.err .dot { background: var(--red); box-shadow: 0 0 0 2px rgba(248,81,73,0.2); }++/* --- Search bar --- */+.searchbar {+  display: flex; gap: 10px; align-items: stretch; margin-bottom: 16px;+  background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 6px;+  transition: border-color 0.15s, box-shadow 0.15s;+}+.searchbar:focus-within {+  border-color: var(--accent);+  box-shadow: 0 0 0 3px rgba(0,184,176,0.15);+}+.searchbar input {+  flex: 1; background: transparent; border: none; outline: none; color: var(--text);+  font-family: 'Satoshi', sans-serif; font-size: 15px; padding: 10px 14px;+}+.searchbar input::placeholder { color: var(--text-dim); }+.searchbar .limit-select {+  background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;+  color: var(--text); padding: 0 10px; font-size: 13px; cursor: pointer;+  font-family: 'JetBrains Mono', monospace;+}+.btn {+  padding: 10px 18px; border: none; border-radius: 8px; cursor: pointer;+  font-family: 'Satoshi', sans-serif; font-weight: 600; font-size: 14px;+  transition: background 0.15s, transform 0.05s;+}+.btn:active { transform: translateY(1px); }+.btn.primary { background: var(--accent); color: #0d1117; }+.btn.primary:hover { background: #00d4cb; }+.btn.primary:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }+.btn.ghost {+  background: transparent; color: var(--text-dim); border: 1px solid var(--border);+}+.btn.ghost:hover { color: var(--text); border-color: var(--text-dim); }++.filters {+  display: flex; gap: 16px; align-items: center; margin-bottom: 20px;+  color: var(--text-dim); font-size: 13px;+}+.filters label { display: flex; gap: 6px; align-items: center; cursor: pointer; }+.filters input[type="checkbox"] { accent-color: var(--accent); width: 14px; height: 14px; }+.filters .hint { font-size: 12px; }++/* --- Status + feedback --- */+.statusline {+  color: var(--text-dim); font-size: 13px; margin: 8px 0 16px; min-height: 20px;+}+.statusline.error { color: var(--red); }+.statusline.ok { color: var(--green); }++/* --- Results --- */+.results { display: flex; flex-direction: column; gap: 12px; }+.card {+  background: var(--surface); border: 1px solid var(--border); border-radius: 12px;+  padding: 18px 20px; transition: border-color 0.15s;+}+.card:hover { border-color: var(--text-dim); }++.card-top { display: flex; align-items: flex-start; gap: 16px; }+.card-img {+  flex: 0 0 64px; width: 64px; height: 64px; border-radius: 8px;+  background: var(--surface2); display: flex; align-items: center; justify-content: center;+  overflow: hidden; position: relative; cursor: zoom-in;+  transition: transform 0.12s ease;+}+.card-img:hover { transform: scale(1.04); }+.card-img img { width: 100%; height: 100%; object-fit: contain; display: block; }++/* Lightbox for clicked product photo */+.lightbox {+  position: fixed; inset: 0; z-index: 100000;+  background: rgba(13,17,23,0.92); backdrop-filter: blur(10px);+  display: flex; align-items: center; justify-content: center;+  opacity: 0; pointer-events: none;+  transition: opacity 0.18s ease;+  cursor: zoom-out;+}+.lightbox.open { opacity: 1; pointer-events: auto; }+.lightbox img {+  max-width: min(90vw, 1200px); max-height: 86vh;+  border-radius: 12px;+  box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(0,184,176,0.25);+  background: #fff;+}+.lightbox .lb-caption {+  position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);+  color: var(--text); background: rgba(22,27,34,0.88);+  padding: 10px 18px; border-radius: 10px; font-size: 13px;+  border: 1px solid var(--border); max-width: min(90vw, 900px);+  font-family: 'JetBrains Mono', monospace;+}+.lightbox .lb-close {+  position: absolute; top: 20px; right: 24px; color: var(--text);+  background: rgba(22,27,34,0.88); border: 1px solid var(--border);+  width: 40px; height: 40px; border-radius: 50%;+  display: flex; align-items: center; justify-content: center;+  cursor: pointer; font-family: 'Satoshi', sans-serif; font-size: 18px;+}+.card-img .placeholder { color: var(--text-dim); font-size: 10px; font-family: 'JetBrains Mono', monospace; }++.card-body { flex: 1; min-width: 0; }+.card-row1 {+  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 4px;+}+.jlcpcb-pn { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-weight: 600; font-size: 14px; }+.mpn { font-family: 'JetBrains Mono', monospace; color: var(--text); font-size: 13px; }+.mfg { color: var(--text-dim); font-size: 13px; }+.card-desc { color: var(--text); font-size: 13px; margin-bottom: 8px; }+.card-category { color: var(--text-dim); font-size: 12px; margin-bottom: 10px; }++.card-row2 {+  display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; font-size: 13px;+}+.card-field { display: flex; flex-direction: column; gap: 2px; }+.card-field .label { color: var(--text-dim); font-size: 11px; text-transform: lowercase; }+.card-field .value { color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 13px; }+.card-field .value.low { color: var(--red); }+.card-field .value.medium { color: var(--yellow); }+.card-field .value.high { color: var(--green); }++.card-flags {+  display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; font-size: 11px;+}+.flag {+  padding: 2px 8px; border-radius: 10px;+  background: var(--surface2); border: 1px solid var(--border); color: var(--text-dim);+  font-family: 'JetBrains Mono', monospace;+}+.flag.rohs { color: var(--green); border-color: rgba(63,185,80,0.3); }+.flag.lifecycle-active { color: var(--green); border-color: rgba(63,185,80,0.3); }+.flag.lifecycle-nrnd { color: var(--yellow); border-color: rgba(210,153,34,0.3); }+.flag.lifecycle-obsolete { color: var(--red); border-color: rgba(248,81,73,0.3); }+.flag.lifecycle-new { color: var(--blue); border-color: rgba(88,166,255,0.3); }++.card-actions { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }+.card-actions a {+  color: var(--accent); text-decoration: none; font-size: 12px;+  padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border);+  background: var(--surface2);+  display: inline-flex; align-items: center; gap: 6px;+}+.card-actions a:hover { border-color: var(--accent); }+.card-actions a svg { width: 14px; height: 14px; color: var(--text); fill: currentColor; }++.price-tiers {+  font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-dim);+  margin-top: 6px;+}+.price-tiers b { color: var(--text); font-weight: 600; }++.empty {+  padding: 60px 20px; text-align: center; color: var(--text-dim);+  border: 1px dashed var(--border); border-radius: 12px;+  background: var(--surface);+}+.empty h3 { color: var(--text); margin-bottom: 8px; font-size: 16px; }+.empty p { font-size: 13px; line-height: 1.6; }++.loading {+  display: inline-block; width: 14px; height: 14px;+  border: 2px solid var(--border); border-top-color: var(--accent);+  border-radius: 50%; animation: spin 0.8s linear infinite;+  vertical-align: middle; margin-right: 6px;+}+@keyframes spin { to { transform: rotate(360deg); } }++/* --- Tooltips (per app-creator skill: 500ms hover, anchored to trigger edges) --- */+[data-tooltip] { position: relative; }+[data-tooltip]::after {+  content: attr(data-tooltip);+  position: absolute;+  bottom: calc(100% + 8px);+  left: 0; right: auto;+  width: max-content;+  max-width: min(340px, calc(100vw - 48px));+  background: rgba(22,27,34,0.96);+  backdrop-filter: blur(16px);+  border: 1px solid rgba(0,184,176,0.25);+  border-radius: 10px;+  padding: 10px 14px;+  font-family: 'Satoshi', sans-serif;+  font-size: 12px;+  line-height: 1.55;+  color: var(--text);+  text-align: left;+  white-space: pre-wrap;+  pointer-events: none;+  opacity: 0;+  transform: translateY(4px);+  box-shadow: 0 12px 40px rgba(0,0,0,0.55);+  transition: opacity 0.2s ease 0s, transform 0.2s ease 0s;+  z-index: 99999;+}+[data-tooltip][data-tooltip-align="right"]::after { left: auto; right: 0; }+[data-tooltip]:hover::after {+  opacity: 1;+  transform: translateY(0);+  transition: opacity 0.2s ease 0.5s, transform 0.2s ease 0.5s;+}+</style>+</head>+<body>++<div class="hdr">+  <span class="logo" aria-hidden="true">+    <svg width="28" height="28" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">+      <g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">+        <rect x="10" y="14" width="28" height="28" rx="3"/>+        <path d="M6 20 L10 20 M6 28 L10 28 M6 36 L10 36 M38 20 L42 20 M38 28 L42 28 M38 36 L42 36 M16 10 L16 14 M24 10 L24 14 M32 10 L32 14 M16 42 L16 46 M24 42 L24 46 M32 42 L32 46"/>+      </g>+      <circle cx="42" cy="40" r="10" fill="#0d1117" stroke="currentColor" stroke-width="4"/>+      <line x1="50" y1="48" x2="58" y2="56" stroke="currentColor" stroke-width="5" stroke-linecap="round"/>+      <!-- no hard-coded teal fill here — brand rule: in-app icons monochrome white -->+    </svg>+  </span>+  <div>+    <h1>JLCPCB Search</h1>+    <div class="subtitle">Live component search, pricing, and stock — via the shared backend service.</div>+  </div>+  <div id="backend-status" class="backend-status" data-tooltip-align="right" data-tooltip="Health of the adom-jlcpcb backend HTTP proxy the UI talks to. Green means the backend is reachable and its the JLCPCB Node backend is working. Click to re-check.">+    <span class="dot"></span>+    <span class="txt">checking…</span>+  </div>+</div>++<form id="search-form" class="searchbar" autocomplete="off">+  <input id="q" name="keyword" type="text" placeholder="Search: STM32F103RBT6, ESP32, LDO 3.3V 500mA SOT-23 …" autofocus>+  <select id="limit" class="limit-select" data-tooltip="How many results to request from the JLCPCB API. 10 is a good default for browsing; set higher when you need to compare many alternatives.">+    <option value="5">5</option>+    <option value="10" selected>10</option>+    <option value="20">20</option>+    <option value="50">50</option>+  </select>+  <button type="submit" class="btn primary" id="go" data-tooltip-align="right" data-tooltip="Run the search against JLCPCB. Results will replace whatever is shown below. Keyboard: Enter from the search box does the same thing.">Search</button>+</form>++<div class="filters">+  <label data-tooltip="When enabled, only parts that currently have stock at JLCPCB are returned. Useful when you need something fast; otherwise leave off so you can see pricing for factory-lead-time parts too.">+    <input type="checkbox" id="instock"> In stock only+  </label>+  <span class="hint">Tip: paste a full MPN (e.g. <code>STM32F103RBT6</code>) for exact matches, or a parametric query (e.g. <code>LDO 3.3V 500mA SOT-23</code>) for discovery.</span>+</div>++<div id="status" class="statusline" aria-live="polite"></div>++<div id="results" class="results"></div>++<!-- Lightbox for product-photo zoom -->+<div id="lightbox" class="lightbox" role="dialog" aria-hidden="true">+  <div class="lb-close" id="lb-close" data-tooltip="Close (or press Escape)">✕</div>+  <img id="lb-img" alt="">+  <div class="lb-caption" id="lb-caption"></div>+</div>++<script>+// --- Console forwarding so the AI can read UI-side logs via GET /console+(function() {+  const orig = { log: console.log, warn: console.warn, error: console.error };+  function forward(level, args) {+    orig[level].apply(console, args);+    try {+      fetch('console', {+        method: 'POST',+        headers: {'Content-Type':'application/json'},+        body: JSON.stringify({+          level,+          ts: Date.now(),+          message: args.map(a => {+            try { return typeof a === 'string' ? a : JSON.stringify(a); }+            catch { return String(a); }+          }).join(' ')+        })+      }).catch(() => {});+    } catch {}+  }+  console.log = function(...a) { forward('log', a); };+  console.warn = function(...a) { forward('warn', a); };+  console.error = function(...a) { forward('error', a); };+  window.addEventListener('error', e => forward('error', [e.message, (e.filename||'') + ':' + (e.lineno||'')]));+  window.addEventListener('unhandledrejection', e => forward('error', ['unhandledrejection', String(e.reason)]));+})();++// --- Eval channel (dev only). The server's /state tells us whether+// --dev is on; if not, don't poll (silences a 404/sec spam in prod).+(async function() {+  let devEnabled = false;+  try {+    const r = await fetch('state');+    const s = await r.json();+    devEnabled = !!s.dev;+  } catch {}+  if (!devEnabled) return;+  async function pollOnce() {+    try {+      const r = await fetch('eval/pending');+      if (!r.ok) return;+      const pending = await r.json();+      if (!pending || !pending.code) return;+      let result;+      try {+        const fn = new (async function(){}).constructor(pending.code);+        result = await fn();+      } catch (e) {+        result = { error: String(e && e.message || e), stack: String(e && e.stack || '') };+      }+      console.log('eval-result', pending.id, result);+      await fetch('eval/' + pending.id + '/result', {+        method: 'POST',+        headers: {'Content-Type':'application/json'},+        body: JSON.stringify({ result: result === undefined ? null : result })+      });+    } catch {}+  }+  setInterval(pollOnce, 500);+})();++// --- Backend health poll+async function updateBackendStatus() {+  const el = document.getElementById('backend-status');+  const txt = el.querySelector('.txt');+  try {+    const r = await fetch('backend-health');+    const v = await r.json();+    if (r.ok && v.ok) {+      el.classList.remove('err');+      el.classList.add('ok');+      txt.textContent = 'backend ok';+      if (v.api_reachable === false) {+        el.classList.remove('ok');+        el.classList.add('err');+        txt.textContent = 'API unreachable';+      }+    } else {+      el.classList.remove('ok');+      el.classList.add('err');+      txt.textContent = v.error ? 'no API key' : 'backend down';+    }+  } catch {+    el.classList.remove('ok');+    el.classList.add('err');+    txt.textContent = 'backend down';+  }+}+document.getElementById('backend-status').addEventListener('click', updateBackendStatus);+updateBackendStatus();+setInterval(updateBackendStatus, 30000);++// --- Format helpers+function fmtStock(stock, text) {+  if (text && text !== 'Unknown') return text;+  if (stock >= 1_000_000) return (stock/1_000_000).toFixed(1) + 'M';+  if (stock >= 1000) return (stock/1000).toFixed(1) + 'k';+  return String(stock);+}+function lifecycleClass(lc) {+  const l = (lc||'').toLowerCase();+  if (l.includes('obsolete') || l.includes('eol')) return 'lifecycle-obsolete';+  if (l.includes('nrnd') || l.includes('not recommended')) return 'lifecycle-nrnd';+  if (l.includes('new')) return 'lifecycle-new';+  if (l.includes('active')) return 'lifecycle-active';+  return '';+}+function escape(s) {+  return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));+}++function renderCard(c) {+  const priceTiers = (c.price_tiers||[]).slice(0, 6).map(t => `<b>${t.qty_from || t.qty}+</b>: $${(t.price_usd || t.price)}`).join('  &nbsp;·&nbsp;  ');+  const attrs = (Array.isArray(c.attributes) ? c.attributes : []).slice(0, 6).map(a =>+    `<span class="flag" data-tooltip="${escape(a.name)}: ${escape(a.value)}">${escape(a.name)}: ${escape(a.value)}</span>`+  ).join('');+  const lc = escape(c.lifecycle || '');+  const rohs = escape(c.rohs || '');+  const stockTip =+    `${c.stock.toLocaleString()} units reported in stock at JLCPCB. ` ++    `Stock counts refresh every 10 minutes on the backend cache.`;+  const priceTip =+    `Unit price at quantity 1. The "Breaks" line below shows quantity pricing tiers — ordering more gets you a lower per-unit price.`;+  const leadTip =+    `How long JLCPCB says it will take to get more of this part from the manufacturer if stock runs out.`;+  return `+  <div class="card">+    <div class="card-top">+      <div class="card-img">+        ${c.image_url ? `<img src="${escape(c.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'">` : ''}+        <div class="placeholder" ${c.image_url ? 'style="display:none"' : ''}>no image</div>+      </div>+      <div class="card-body">+        <div class="card-row1">+          <span class="jlcpcb-pn">${escape(c.mouser_pn)}</span>+          <span class="mpn">${escape(c.mpn)}</span>+          <span class="mfg">${escape(c.manufacturer)}</span>+        </div>+        ${c.description ? `<div class="card-desc">${escape(c.description)}</div>` : ''}+        ${c.category ? `<div class="card-category">${escape(c.category)}</div>` : ''}+        <div class="card-row2">+          <div class="card-field" data-tooltip="${stockTip}">+            <span class="label">stock</span>+            <span class="value ${c.popularity||''}">${fmtStock(c.stock, c.stock_text)}</span>+          </div>+          <div class="card-field" data-tooltip="${priceTip}">+            <span class="label">unit price</span>+            <span class="value">${escape(c.unit_price || '—')}</span>+          </div>+          <div class="card-field" data-tooltip="${leadTip}">+            <span class="label">lead time</span>+            <span class="value">${escape(c.lead_time || '—')}</span>+          </div>+        </div>+        ${priceTiers ? `<div class="price-tiers">Breaks: ${priceTiers}</div>` : ''}+        <div class="card-flags">+          ${rohs ? `<span class="flag rohs" data-tooltip="RoHS = Restriction of Hazardous Substances. 'Compliant' means the part doesn't contain banned amounts of lead, cadmium, mercury, etc.">${rohs}</span>` : ''}+          ${lc ? `<span class="flag ${lifecycleClass(lc)}" data-tooltip="Lifecycle status from the manufacturer. Active = currently produced. NRND = 'not recommended for new design'. Obsolete = don't design in. New = recently released.">${lc}</span>` : ''}+          ${`<span class="flag" data-tooltip="Smallest order quantity and the step size. Min 1 / Mult 1 means you can order any number; Mult 100 means you must order in multiples of 100 (typical for reel packaging).">MinQty ${c.min_order||1} · Mult ${c.mult||1}</span>`}+          ${attrs}+        </div>+        <div class="card-actions">+          ${c.product_url ? `<a href="${escape(c.product_url)}" target="_blank" rel="noopener" data-tooltip="Open the JLCPCB product page in your default browser. Use this to view the full spec sheet, order, or check real-time stock at all JLCPCB warehouses.">Product page ↗</a>` : ''}+          ${c.datasheet_url ? `<a href="${escape(c.datasheet_url)}" target="_blank" rel="noopener" data-tooltip="Open the manufacturer's PDF datasheet.">Datasheet ↗</a>` : ''}+          <a href="#" data-copy="${escape(c.mouser_pn)}" data-tooltip="Copy the JLCPCB part number to your clipboard. Use this for BOM ordering.">Copy JLCPCB PN</a>+          <a href="#" data-copy="${escape(c.mpn)}" data-tooltip="Copy the manufacturer part number (MPN) to your clipboard. Use this in schematic symbols and footprint names.">Copy MPN</a>+        </div>+      </div>+    </div>+  </div>`;+}++function setStatus(msg, level='') {+  const el = document.getElementById('status');+  el.textContent = msg;+  el.className = 'statusline' + (level ? ' '+level : '');+}++async function doSearch(keyword) {+  if (!keyword || !keyword.trim()) return;+  const limit = parseInt(document.getElementById('limit').value, 10);+  const inStock = document.getElementById('instock').checked;+  const go = document.getElementById('go');+  go.disabled = true; go.textContent = 'Searching…';+  setStatus('<span class="loading"></span> searching JLCPCB for "' + keyword + '"…');+  document.getElementById('status').innerHTML = '<span class="loading"></span> searching JLCPCB for "' + escape(keyword) + '"…';+  try {+    const r = await fetch('search', {+      method: 'POST',+      headers: {'Content-Type':'application/json'},+      body: JSON.stringify({ keyword, limit, inStockOnly: inStock })+    });+    const v = await r.json();+    if (!r.ok) {+      setStatus('error: ' + (v.error || r.statusText), 'error');+      document.getElementById('results').innerHTML = '';+      return;+    }+    const components = v.components || [];+    if (components.length === 0) {+      document.getElementById('results').innerHTML = '<div class="empty"><h3>No parts found</h3><p>Try a different keyword, a full MPN, or turn off the in-stock-only filter.</p></div>';+      setStatus('0 results', 'ok');+      return;+    }+    document.getElementById('results').innerHTML = components.map(renderCard).join('');+    setStatus(`found ${v.count || components.length} results; showing ${components.length}`, 'ok');+  } catch (e) {+    console.error('search error', e);+    setStatus('error: ' + (e && e.message || e), 'error');+  } finally {+    go.disabled = false; go.textContent = 'Search';+  }+}++document.getElementById('search-form').addEventListener('submit', e => {+  e.preventDefault();+  doSearch(document.getElementById('q').value);+});++// Copy-to-clipboard for JLCPCB PN / MPN buttons+document.addEventListener('click', async e => {+  const a = e.target.closest('a[data-copy]');+  if (!a) return;+  e.preventDefault();+  const val = a.getAttribute('data-copy');+  try {+    await navigator.clipboard.writeText(val);+    const orig = a.textContent;+    a.textContent = '✓ Copied';+    setTimeout(() => { a.textContent = orig; }, 1200);+  } catch (err) {+    console.warn('clipboard write failed', err);+  }+});++// Lightbox: click a card thumbnail to see the full product photo+(function() {+  const lb = document.getElementById('lightbox');+  const lbImg = document.getElementById('lb-img');+  const lbCap = document.getElementById('lb-caption');+  const lbClose = document.getElementById('lb-close');+  function open(src, caption) {+    lbImg.src = src;+    lbCap.textContent = caption || '';+    lb.classList.add('open');+    lb.setAttribute('aria-hidden', 'false');+  }+  function close() {+    lb.classList.remove('open');+    lb.setAttribute('aria-hidden', 'true');+    // Release the img src after the fade so we don't hold the cached resource.+    setTimeout(() => { if (!lb.classList.contains('open')) lbImg.src = ''; }, 200);+  }+  // Click on any .card-img opens the lightbox with that card's MPN as caption.+  document.addEventListener('click', e => {+    const box = e.target.closest('.card-img');+    if (box) {+      const img = box.querySelector('img');+      if (!img || !img.src || img.style.display === 'none') return;+      const card = box.closest('.card');+      const mpn = card?.querySelector('.mpn')?.textContent || '';+      const mfg = card?.querySelector('.mfg')?.textContent || '';+      open(img.src, [mpn, mfg].filter(Boolean).join(' — '));+      return;+    }+    // Clicking the backdrop or close button closes+    if (e.target === lb || e.target === lbClose) close();+  });+  document.addEventListener('keydown', e => {+    if (e.key === 'Escape' && lb.classList.contains('open')) close();+  });+})();++// Restore last query if there is one (so a tab reopen shows prior results)+(async function() {+  try {+    const r = await fetch('state');+    const v = await r.json();+    if (v.last_query) {+      document.getElementById('q').value = v.last_query;+    }+    if (v.last_results && Array.isArray(v.last_results.components) && v.last_results.components.length) {+      document.getElementById('results').innerHTML = v.last_results.components.map(renderCard).join('');+      setStatus(`cached: ${v.last_results.count || v.last_results.components.length} results for "${escape(v.last_query || '')}"`, 'ok');+    }+  } catch {}+})();+</script>+</body>+</html>
src/cli.rsadded+105
@@ -0,0 +1,105 @@+//! CLI verbs: search, category-search, list, categories, health.+//! All dispatch to the Node backend via `POST /` with `{action, ...params}`.++use crate::jlcpcb;+use crate::{ok, warn, CategoriesArgs, CategorySearchArgs, HealthArgs, ListArgs, SearchArgs};+use serde_json::{json, Value};++fn emit_json(v: &Value) {+    // Put OK: on stderr so stdout stays pure JSON for `| jq`.+    let count = v.get("components").and_then(|c| c.as_array()).map(|a| a.len()).unwrap_or(0);+    let total = v.get("count").or_else(|| v.get("total")).and_then(|n| n.as_u64());+    let query = v.get("query").or_else(|| v.get("category")).and_then(|q| q.as_str()).unwrap_or("");+    let summary = if let Some(t) = total {+        format!("{} components (showing {}, total {})", count, count, t)+    } else {+        format!("{} components", count)+    };+    if crate::stderr_is_tty() {+        eprintln!("{}{}OK:{} {} ({})", crate::GREEN, crate::BOLD, crate::RESET, summary, query);+    } else {+        eprintln!("OK: {} ({})", summary, query);+    }+    println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());+}++fn resolve_or_fail(explicit: Option<String>) -> Result<String, String> {+    match jlcpcb::resolve_backend(explicit) {+        Some(api) => Ok(api),+        None => {+            warn("No backend at http://127.0.0.1:8774 and no --api / JLCPCB_API set.");+            Err(+                "JLCPCB backend unreachable. Point --api / JLCPCB_API at the service URL \+                 (e.g. https://jlcpcb-<slug>.adom.cloud), or start a local backend with \+                 `adom-jlcpcb install` + the service/backend/ Node server."+                    .into(),+            )+        }+    }+}++pub fn run_search(args: SearchArgs) -> Result<(), String> {+    let api = resolve_or_fail(args.api)?;+    let v = jlcpcb::post_action(+        &api,+        json!({+            "action": "search",+            "keyword": args.keyword,+            "limit": args.limit,+            "inStockOnly": args.in_stock_only,+        }),+    )?;+    if let Some(e) = v.get("error").and_then(|v| v.as_str()) { return Err(e.into()); }+    emit_json(&v);+    Ok(())+}++pub fn run_category_search(args: CategorySearchArgs) -> Result<(), String> {+    let api = resolve_or_fail(args.api)?;+    let v = jlcpcb::post_action(+        &api,+        json!({+            "action": "category_search",+            "keyword": args.keyword,+            "category": args.category,+            "limit": args.limit,+            "inStockOnly": args.in_stock_only,+        }),+    )?;+    if let Some(e) = v.get("error").and_then(|v| v.as_str()) { return Err(e.into()); }+    emit_json(&v);+    Ok(())+}++pub fn run_list(args: ListArgs) -> Result<(), String> {+    let api = resolve_or_fail(args.api)?;+    let mut body = json!({+        "action": "list",+        "category": args.category,+        "limit": args.limit,+        "inStockOnly": args.in_stock_only,+    });+    if let Some(sc) = args.subcategory {+        body["subcategory"] = Value::String(sc);+    }+    let v = jlcpcb::post_action(&api, body)?;+    if let Some(e) = v.get("error").and_then(|v| v.as_str()) { return Err(e.into()); }+    emit_json(&v);+    Ok(())+}++pub fn run_categories(args: CategoriesArgs) -> Result<(), String> {+    let api = resolve_or_fail(args.api)?;+    let v = jlcpcb::post_action(&api, json!({"action": "categories"}))?;+    if let Some(e) = v.get("error").and_then(|v| v.as_str()) { return Err(e.into()); }+    println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+    Ok(())+}++pub fn run_health(args: HealthArgs) -> Result<(), String> {+    let api = resolve_or_fail(args.api)?;+    let v = jlcpcb::get_health(&api)?;+    ok(format!("backend {} reachable", api));+    println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());+    Ok(())+}
src/install.rsadded+61
@@ -0,0 +1,61 @@+//! `adom-jlcpcb install` — deploy SKILL.md + BUILD-SKILL.md + bash completions.++use crate::{ok, warn};+use clap::CommandFactory;+use clap_complete::Shell;+use std::fs;+use std::io::Write;+use std::path::PathBuf;++const SKILL_MD: &str = include_str!("../SKILL.md");+const BUILD_SKILL_MD: &str = include_str!("../BUILD-SKILL.md");++pub fn install() -> Result<(), String> {+    let home = std::env::var("HOME").map_err(|_| "HOME env var not set".to_string())?;+    let skills_root = PathBuf::from(&home).join(".claude").join("skills");++    // 1a. User-facing skill+    let skill_dir = skills_root.join("adom-jlcpcb");+    fs::create_dir_all(&skill_dir).map_err(|e| format!("create {}: {}", skill_dir.display(), e))?;+    let skill_path = skill_dir.join("SKILL.md");+    fs::write(&skill_path, SKILL_MD).map_err(|e| format!("write {}: {}", skill_path.display(), e))?;+    ok(format!("installed skill → {}", skill_path.display()));++    // 1b. Build skill (per-app release workflow)+    let build_skill_dir = skills_root.join("adom-jlcpcb-build");+    fs::create_dir_all(&build_skill_dir)+        .map_err(|e| format!("create {}: {}", build_skill_dir.display(), e))?;+    let build_skill_path = build_skill_dir.join("SKILL.md");+    fs::write(&build_skill_path, BUILD_SKILL_MD)+        .map_err(|e| format!("write {}: {}", build_skill_path.display(), e))?;+    ok(format!("installed build skill → {}", build_skill_path.display()));++    // 2. Bash completions+    let comp_dir = PathBuf::from(&home)+        .join(".local")+        .join("share")+        .join("bash-completion")+        .join("completions");+    fs::create_dir_all(&comp_dir).map_err(|e| format!("create {}: {}", comp_dir.display(), e))?;+    let comp_path = comp_dir.join("adom-jlcpcb");+    let mut cmd = crate::Cli::command();+    let mut buf: Vec<u8> = Vec::new();+    clap_complete::generate(Shell::Bash, &mut cmd, "adom-jlcpcb", &mut buf);+    fs::write(&comp_path, &buf).map_err(|e| format!("write {}: {}", comp_path.display(), e))?;+    ok(format!("installed completions → {}", comp_path.display()));++    // 3. Append eval to .bashrc once+    let bashrc = PathBuf::from(&home).join(".bashrc");+    let current = fs::read_to_string(&bashrc).unwrap_or_default();+    if !current.contains("# adom-jlcpcb completions") {+        match fs::OpenOptions::new().append(true).open(&bashrc) {+            Ok(mut f) => {+                let _ = f.write_all(b"\n# adom-jlcpcb completions\neval \"$(adom-jlcpcb completions bash 2>/dev/null)\"\n");+                ok("appended completion source line to ~/.bashrc (open a new shell for it to take effect)");+            }+            Err(e) => warn(format!(".bashrc: {}", e)),+        }+    }++    Ok(())+}
src/jlcpcb.rsadded+47
@@ -0,0 +1,47 @@+//! Thin HTTP client for the JLCPCB Node backend.+//!+//! Unlike mouser/digikey (which proxy external APIs from Rust), the JLCPCB+//! service is a Node.js HTTP server backed by a local 1.3GB SQLite + FTS5+//! database (from CDFER/jlcpcb-parts-database). This module doesn't+//! reimplement that — it just POSTs JSON actions to the backend and+//! returns the response as-is.++use serde_json::Value;+use std::time::Duration;++pub const DEFAULT_LOCAL: &str = "http://127.0.0.1:8774";+pub const FETCH_TIMEOUT_SECS: u64 = 30;++pub fn resolve_backend(explicit: Option<String>) -> Option<String> {+    if let Some(api) = explicit.filter(|s| !s.is_empty()) {+        return Some(api);+    }+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(2)).build();+    match agent.get(&format!("{}/health", DEFAULT_LOCAL)).call() {+        Ok(_) => Some(DEFAULT_LOCAL.into()),+        Err(ureq::Error::Status(_, _)) => Some(DEFAULT_LOCAL.into()),+        Err(_) => None,+    }+}++pub fn post_action(api: &str, body: Value) -> Result<Value, String> {+    let url = if api.ends_with('/') { api.to_string() } else { format!("{}/", api) };+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();+    let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {+        Ok(r) => r,+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("JLCPCB backend POST failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("JLCPCB backend JSON parse failed: {}", e))+}++pub fn get_health(api: &str) -> Result<Value, String> {+    let url = if api.ends_with('/') { format!("{}health", api) } else { format!("{}/health", api) };+    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(10)).build();+    let res = match agent.get(&url).call() {+        Ok(r) => r,+        Err(ureq::Error::Status(_code, r)) => r,+        Err(e) => return Err(format!("JLCPCB backend GET /health failed: {}", e)),+    };+    res.into_json().map_err(|e| format!("JLCPCB backend /health JSON parse failed: {}", e))+}
src/main.rsadded+169
@@ -0,0 +1,169 @@+//! adom-jlcpcb: JLCPCB component search.+//!+//! Thin Rust CLI over the existing Node.js JLCPCB backend. Five verbs+//! matching the legacy MCP tools:+//!   search — keyword/part-number search (FTS5)+//!   category-search — keyword search scoped to a category+//!   list — list components in a category, sorted by stock+//!   categories — list all categories + subcategories+//!   health — backend reachability + DB stats++mod app;+mod cli;+mod install;+mod jlcpcb;++use clap::{CommandFactory, Parser, Subcommand};+use std::io::IsTerminal;+use std::process::ExitCode;+use std::sync::OnceLock;++pub const GREEN: &str = "\x1b[32m";+pub const RED: &str = "\x1b[31m";+pub const YELLOW: &str = "\x1b[33m";+pub const CYAN: &str = "\x1b[36m";+pub const BOLD: &str = "\x1b[1m";+pub const DIM: &str = "\x1b[2m";+pub const RESET: &str = "\x1b[0m";++static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();+static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();+pub fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }+pub fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }++pub fn strip_ansi(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars().peekable();+    while let Some(c) = chars.next() {+        if c == '\x1b' && chars.peek() == Some(&'[') {+            chars.next();+            for c2 in chars.by_ref() { if c2.is_ascii_alphabetic() { break; } }+            continue;+        }+        out.push(c);+    }+    out+}++pub fn ok(msg: impl std::fmt::Display) {+    if stdout_is_tty() { println!("{GREEN}{BOLD}OK:{RESET} {msg}"); }+    else { println!("OK: {}", strip_ansi(&msg.to_string())); }+}+pub fn err(msg: impl std::fmt::Display) {+    if stderr_is_tty() { eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}"); }+    else { eprintln!("ERROR: {}", strip_ansi(&msg.to_string())); }+}+pub fn hint(msg: impl std::fmt::Display) {+    if stderr_is_tty() { eprintln!("{DIM}Hint:{RESET} {msg}"); }+    else { eprintln!("Hint: {}", strip_ansi(&msg.to_string())); }+}+pub fn warn(msg: impl std::fmt::Display) {+    if stderr_is_tty() { eprintln!("{YELLOW}{BOLD}WARN:{RESET} {msg}"); }+    else { eprintln!("WARN: {}", strip_ansi(&msg.to_string())); }+}++#[macro_export]+macro_rules! note {+    ($($arg:tt)*) => {{+        let __s = format!($($arg)*);+        if $crate::stdout_is_tty() { println!("{}", __s); }+        else { println!("{}", $crate::strip_ansi(&__s)); }+    }};+}++#[derive(Parser)]+#[command(name = "adom-jlcpcb", version, about = "JLCPCB component search — CLI + Hydrogen webview app")]+pub struct Cli {+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Keyword / part-number search via FTS5. Backend is Node + SQLite.+    Search(SearchArgs),++    /// Keyword search scoped to a specific category.+    #[command(name = "category-search")]+    CategorySearch(CategorySearchArgs),++    /// List all components in a category, sorted by stock.+    List(ListArgs),++    /// List all categories and subcategories.+    Categories(CategoriesArgs),++    /// Backend reachability + DB stats.+    Health(HealthArgs),++    /// Open the JLCPCB search webview as a Hydrogen tab.+    App(AppArgs),++    /// Install SKILL.md + BUILD-SKILL.md + bash completions.+    Install,++    /// Print shell completion script.+    Completions { shell: clap_complete::Shell },+}++#[derive(clap::Args)]+pub struct SearchArgs {+    pub keyword: String,+    #[arg(long, default_value_t = 10)] pub limit: u32,+    #[arg(long)] pub in_stock_only: bool,+    #[arg(long, env = "JLCPCB_API")] pub api: Option<String>,+}+#[derive(clap::Args)]+pub struct CategorySearchArgs {+    pub keyword: String,+    #[arg(long)] pub category: String,+    #[arg(long, default_value_t = 10)] pub limit: u32,+    #[arg(long)] pub in_stock_only: bool,+    #[arg(long, env = "JLCPCB_API")] pub api: Option<String>,+}+#[derive(clap::Args)]+pub struct ListArgs {+    #[arg(long)] pub category: String,+    #[arg(long)] pub subcategory: Option<String>,+    #[arg(long, default_value_t = 10)] pub limit: u32,+    #[arg(long)] pub in_stock_only: bool,+    #[arg(long, env = "JLCPCB_API")] pub api: Option<String>,+}+#[derive(clap::Args)]+pub struct CategoriesArgs {+    #[arg(long, env = "JLCPCB_API")] pub api: Option<String>,+}+#[derive(clap::Args)]+pub struct HealthArgs {+    #[arg(long, env = "JLCPCB_API")] pub api: Option<String>,+}+#[derive(clap::Args)]+pub struct AppArgs {+    #[arg(long, default_value_t = 8892)] pub port: u16,+    #[arg(long, env = "JLCPCB_API")] pub api: Option<String>,+    #[arg(long, default_value = "JLCPCB Search")] pub tab_name: String,+    #[arg(long)] pub no_tab: bool,+    #[arg(long, env = "ADOM_JLCPCB_DEV")] pub dev: bool,+}++fn main() -> ExitCode {+    let args = Cli::parse();+    let result = match args.cmd {+        Cmd::Search(a) => cli::run_search(a),+        Cmd::CategorySearch(a) => cli::run_category_search(a),+        Cmd::List(a) => cli::run_list(a),+        Cmd::Categories(a) => cli::run_categories(a),+        Cmd::Health(a) => cli::run_health(a),+        Cmd::App(a) => app::run(a),+        Cmd::Install => install::install(),+        Cmd::Completions { shell } => {+            let mut cmd = Cli::command();+            clap_complete::generate(shell, &mut cmd, "adom-jlcpcb", &mut std::io::stdout());+            Ok(())+        }+    };+    match result {+        Ok(()) => ExitCode::SUCCESS,+        Err(e) => { err(&e); ExitCode::FAILURE }+    }+}
tests/cron-smoke.shadded+63
@@ -0,0 +1,63 @@+#!/usr/bin/env bash+# adom-jlcpcb cron smoke runner.+#+# Purpose: every ~1 h, run the full smoke suite against the live backend+# (the one this container IS serving), and alert via Kel on state+# transitions — not on every run.+#+# Alerting policy (mirrors adom-mouser's quota alert):+#   - PASS → PASS : silent+#   - PASS → FAIL : ALERT ("jlcpcb backend smoke test failing")+#   - FAIL → FAIL : silent (already alerted; don't spam)+#   - FAIL → PASS : ALERT ("jlcpcb backend recovered")+#+# State persisted to /tmp/adom-jlcpcb-smoke-state. Resets on reboot+# (which is fine — first run after reboot assumes PASS-state and only+# alerts on transition).+#+# Install (on service-jlcpcb):+#   (crontab -l 2>/dev/null; echo '15 * * * * /home/adom/adom-jlcpcb/tests/cron-smoke.sh >> /tmp/adom-jlcpcb-smoke.log 2>&1') | crontab -++set -u+set -a; [ -f /etc/environment ] && . /etc/environment; set +a++REPO_DIR="${ADOM_JLCPCB_REPO:-$HOME/adom-jlcpcb}"+STATE_FILE="${ADOM_JLCPCB_SMOKE_STATE:-/tmp/adom-jlcpcb-smoke-state}"+BACKEND="${JLCPCB_API:-http://127.0.0.1:${JLCPCB_PORT:-8774}}"+WEBHOOK="${KEL_ALERT_WEBHOOK:-}"+HOSTNAME_SHORT="$(hostname -s 2>/dev/null || echo jlcpcb-host)"++prev_status="pass"+[ -f "$STATE_FILE" ] && prev_status="$(cat "$STATE_FILE" 2>/dev/null || echo pass)"++log_out="$(bash "$REPO_DIR/tests/smoke.sh" "$BACKEND" 2>&1)"+status=$?+printf '[%s] status=%s\n%s\n' "$(date -Iseconds)" "$status" "$log_out"++new_status="pass"+[ "$status" -ne 0 ] && new_status="fail"++post_kel() {+  local text="$1"+  [ -z "$WEBHOOK" ] && return 0+  local body+  body="$(jq -n --arg t "$text" '{text:$t}')"+  curl -s --max-time 3 -X POST -H 'content-type: application/json' \+    -d "$body" "$WEBHOOK" >/dev/null || true+}++if [ "$prev_status" = "pass" ] && [ "$new_status" = "fail" ]; then+  # Extract summary + first 3 failing cases for the alert body.+  summary="$(printf '%s' "$log_out" | tail -25 | grep -E '^(FAIL|OK|FAIL.*passed)' | head -4)"+  post_kel "⚠️ *adom-jlcpcb backend smoke FAILING on $HOSTNAME_SHORT.*+Backend: $BACKEND+$(printf '%s' "$summary")+Check /tmp/adom-jlcpcb-smoke.log for the full run. This alert fires once per incident; no spam."+elif [ "$prev_status" = "fail" ] && [ "$new_status" = "pass" ]; then+  post_kel "✅ *adom-jlcpcb backend smoke RECOVERED on $HOSTNAME_SHORT.*+Backend: $BACKEND+All cases passing again."+fi++echo "$new_status" > "$STATE_FILE"+exit "$status"
tests/smoke.shadded+115
@@ -0,0 +1,115 @@+#!/usr/bin/env bash+# adom-jlcpcb smoke test — real-backend search assertions.+#+# Runs a fixed set of real-world keyword queries against the Node + SQLite+# backend and asserts each returns at least one component whose `mfr` field+# contains the expected substring. Catches regressions like the 2026-04 bug+# where handleSearch destructured `query` but every caller sent `keyword`,+# silently routing all searches to "top stocked parts" (capacitors).+#+# Usage:+#   tests/smoke.sh                                                    # hits deployed backend+#   tests/smoke.sh http://127.0.0.1:8774                              # local backend+#   JLCPCB_API=http://127.0.0.1:8774 tests/smoke.sh                   # same via env+#+# Exit code: 0 iff all cases pass. Non-zero + red FAIL line otherwise.++set -u++BACKEND="${1:-${JLCPCB_API:-https://jlcpcb-wela51osctvp.adom.cloud}}"+BACKEND="${BACKEND%/}"++# ANSI only on a real terminal (adom-cli-design rule: no color escapes in+# captured stdout).+if [ -t 1 ]; then+  C_OK=$'\033[32m'; C_FAIL=$'\033[31m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'+else+  C_OK=''; C_FAIL=''; C_DIM=''; C_BOLD=''; C_RESET=''+fi++pass=0; fail=0; failures=()++run_case() {+  local label="$1" keyword="$2" expect_substr="$3" expect_min="${4:-1}"+  local body resp count match+  body=$(jq -n --arg k "$keyword" '{action:"search", keyword:$k, limit:5}')+  resp=$(curl -sS --max-time 10 -X POST "$BACKEND/search" \+    -H 'content-type: application/json' -d "$body" 2>/dev/null)+  if [ -z "$resp" ]; then+    fail=$((fail+1)); failures+=("$label — backend unreachable or empty response")+    printf '%sFAIL%s  %-36s  %s\n' "$C_FAIL" "$C_RESET" "$label" "(no response)"+    return+  fi+  count=$(printf '%s' "$resp" | jq '.count // 0' 2>/dev/null)+  if [ -z "$expect_substr" ]; then+    # Just assert >= expect_min results, no substring check.+    if [ "$count" -ge "$expect_min" ]; then+      pass=$((pass+1))+      printf '%sPASS%s  %-36s  count=%s\n' "$C_OK" "$C_RESET" "$label" "$count"+    else+      fail=$((fail+1)); failures+=("$label — count=$count expected >=$expect_min")+      printf '%sFAIL%s  %-36s  count=%s (expected >=%s)\n' "$C_FAIL" "$C_RESET" "$label" "$count" "$expect_min"+    fi+    return+  fi+  match=$(printf '%s' "$resp" | jq --arg s "$expect_substr" \+    '[.components[]? | (.mfr // "") + " " + (.description // "") | ascii_downcase | contains($s | ascii_downcase)] | any' 2>/dev/null)+  if [ "$match" = "true" ] && [ "$count" -ge "$expect_min" ]; then+    pass=$((pass+1))+    printf '%sPASS%s  %-36s  count=%s  mfr contains %s\n' "$C_OK" "$C_RESET" "$label" "$count" "$expect_substr"+  else+    fail=$((fail+1)); failures+=("$label — count=$count, mfr-contains-'$expect_substr'=$match")+    printf '%sFAIL%s  %-36s  count=%s  mfr contains %s? %s\n' \+      "$C_FAIL" "$C_RESET" "$label" "$count" "$expect_substr" "${match:-null}"+  fi+}++run_negative() {+  local label="$1" keyword="$2"+  local body resp count+  body=$(jq -n --arg k "$keyword" '{action:"search", keyword:$k, limit:5}')+  resp=$(curl -sS --max-time 10 -X POST "$BACKEND/search" \+    -H 'content-type: application/json' -d "$body" 2>/dev/null)+  count=$(printf '%s' "$resp" | jq '.count // 0' 2>/dev/null)+  if [ "$count" = "0" ]; then+    pass=$((pass+1))+    printf '%sPASS%s  %-36s  count=0 (expected)\n' "$C_OK" "$C_RESET" "$label"+  else+    fail=$((fail+1)); failures+=("$label — nonsense keyword returned $count results (should be 0)")+    printf '%sFAIL%s  %-36s  count=%s (expected 0)\n' "$C_FAIL" "$C_RESET" "$label" "$count"+  fi+}++echo "${C_BOLD}adom-jlcpcb smoke test${C_RESET}  ${C_DIM}backend: $BACKEND${C_RESET}"+echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"++# Canonical MPNs that should always match.+run_case "esp32 (generic)"         "ESP32"              "esp32"       1+run_case "esp32-s3 (variant)"      "ESP32-S3"           "esp32-s3"    1+run_case "stm32f103 (arm mcu)"     "STM32F103"          "stm32f103"   1+run_case "lm358 (op-amp)"          "LM358"              "lm358"       1+run_case "ne555 (timer)"           "NE555"              "ne555"       1+run_case "tl072 (audio op-amp)"    "TL072"              "tl072"       1+run_case "atmega328 (arduino mcu)" "ATmega328"          "atmega328"   1+run_case "ams1117 (ldo)"           "AMS1117"            "ams1117"     1+run_case "ch340 (usb-uart)"        "CH340"              "ch340"       1+run_case "w25q (spi flash)"        "W25Q"               "w25q"        1++# LCSC-number lookup (leading 'C' + digits).+run_case "C1525 (lcsc direct)"     "C1525"              ""            1++# Broad keyword — at least one cap match.+run_case "100nF 0603 (keyword)"    "100nF 0603"         ""            1++# Nonsense query should return 0, not default-popular-parts.+run_negative "bogus keyword"       "XYZZZZ999NOTAPART"++echo "${C_DIM}────────────────────────────────────────────────────────${C_RESET}"+if [ "$fail" -eq 0 ]; then+  echo "${C_OK}${C_BOLD}OK${C_RESET}  ${pass} passed, 0 failed"+  exit 0+else+  echo "${C_FAIL}${C_BOLD}FAIL${C_RESET}  ${pass} passed, ${fail} failed"+  for f in "${failures[@]}"; do echo "      - $f"; done+  exit 1+fi
wiki/body.mdadded+59
@@ -0,0 +1,59 @@+# adom-jlcpcb++Search JLCPCB Electronics for components, pricing, stock, and datasheets. One Rust binary with three faces:++- **CLI verbs** — `adom-jlcpcb search|part|health` return normalized JSON on stdout, ready for `| jq`.+- **Hydrogen webview app** — `adom-jlcpcb app` opens a first-class "JLCPCB Search" tab with per-card images, price breaks, RoHS / lifecycle / packaging flags, copy-to-clipboard for MPN and JLCPCB PN, and a backend-health indicator.+- **Backend HTTP service** — `adom-jlcpcb serve` runs an HTTP proxy (port 8774) with a 10-minute LRU cache. Keeps the JLCPCB API key server-side; consumer containers point `JLCPCB_API` at it.++## Install++```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/adom-jlcpcb \+  -o /tmp/adom-jlcpcb \+  && chmod +x /tmp/adom-jlcpcb \+  && sudo install -m 0755 /tmp/adom-jlcpcb /usr/local/bin/adom-jlcpcb \+  && adom-jlcpcb install+```++Installs the binary to `/usr/local/bin/adom-jlcpcb`, deploys `~/.claude/skills/adom-jlcpcb/SKILL.md` and `~/.claude/skills/adom-jlcpcb-build/SKILL.md`, and registers bash completions.++For gallia containers, this is done automatically by `gallia/install.mjs` — adom-jlcpcb is a **Tier A app** (every Adom user needs component search day-one).++## Use++```bash+# CLI+adom-jlcpcb search "STM32F103RBT6" --limit 5+adom-jlcpcb part C8734 | jq '.components[0] | {mouser_pn, unit_price, price_tiers, stock}'+adom-jlcpcb health++# UI+adom-jlcpcb app        # opens a Hydrogen webview tab++# Backend+JLCPCB_API_KEY_UNUSED_placeholder=<key> adom-jlcpcb serve    # runs on service-jlcpcb container+```++## Architecture++```+  any user container             service-jlcpcb (default-light)+  ──────────────────             ──────────────────────────────+  adom-jlcpcb search       ─┐+  adom-jlcpcb app          ─┼──▶ adom-jlcpcb serve (port 8774)+  adom-jlcpcb health       ─┘    + JLCPCB_API_KEY_UNUSED_placeholder+                                 + 10-min LRU cache+                                 + self-heal cron watchdog+                                          │+                                          ▼+                                api.jlcpcb.com / v2+```++The service container clones only `adom-inc/adom-jlcpcb`; its `service/watchdog.sh` runs every 2 min to (1) keep the service healthy and (2) `git fetch origin main` + rebuild + swap binary when a new release lands. No redeploy step after publishing — publish.sh pushes to main, the service container picks it up within ~5 minutes.++## Source++Source at [adom-inc/adom-jlcpcb](https://github.com/adom-inc/adom-jlcpcb) (private). The public artifact is this wiki page's `docker-binary` asset — source access is not required to install or use the app.++Built following the canonical [adom-app-model](https://wiki-ufypy5dpx93o.adom.cloud/wiki/skills/adom-app-model): single private repo for both client + service code, binary published to the wiki, service on its own default-light container, Tier A distribution via `gallia/install.mjs`.
wiki/discovery.jsonadded+16
@@ -0,0 +1,16 @@+{+  "slug": "adom-jlcpcb",+  "pitch": "Search JLCPCB Electronics for components, pricing, stock, and datasheets — CLI + webview app + shared backend service.",+  "triggers": [+    "jlcpcb",+    "jlcpcb parts",+    "jlcpcb pricing",+    "jlcpcb search",+    "jlcpcb part number",+    "component search",+    "part lookup",+    "price breaks",+    "datasheet jlcpcb",+    "in stock jlcpcb"+  ]+}
wiki/page.jsonadded+34
@@ -0,0 +1,34 @@+{+  "type": "app",+  "slug": "adom-jlcpcb",+  "title": "JLCPCB Parts Library Search",+  "brief": "Search JLCPCB's 437K-component parts library (basic/preferred/extended). Rust CLI + Hydrogen webview app + Node.js + SQLite FTS5 backend \u2014 no API credentials needed (all local).",+  "metadata": {+    "repo": "adom-inc/adom-jlcpcb",+    "version": "0.1.3",+    "releases": {+      "adom_docker": {+        "asset_name": "adom-jlcpcb",+        "binary_url": "https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/adom-jlcpcb",+        "install_hint": "curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-jlcpcb/adom-jlcpcb -o /tmp/adom-jlcpcb && chmod +x /tmp/adom-jlcpcb && sudo install -m 0755 /tmp/adom-jlcpcb /usr/local/bin/adom-jlcpcb && adom-jlcpcb install"+      }+    },+    "discovery_triggers": [+      "jlcpcb",+      "jlcpcb parts",+      "jlcpcb search",+      "lcsc",+      "lcsc part number",+      "basic parts",+      "preferred parts",+      "extended parts",+      "jlcpcb assembly",+      "pcba parts",+      "parts library",+      "jlcpcb price breaks",+      "list jlcpcb components",+      "category search jlcpcb"+    ],+    "discovery_pitch": "Search JLCPCB's parts library (437K components, SQLite + FTS5) \u2014 basic/preferred/extended classification, price breaks, stock. No API credentials needed."+  }+}