When npm install runs in server/ and processes the file: dep at
vendor/keysat-licensing-client/, it fires the vendor's 'prepare'
script even with --ignore-scripts (npm bug for file/git deps).
The prepare script calls tsup which isn't installed in the build
container, so the build fails with 'sh: 1: tsup: not found'.
The vendored copy ships its prebuilt dist/ in the repo, so we don't
need tsup. Drop the scripts entirely.
The runtime crash on v0.2.3:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/server/util.js'
imported from /app/server/index.js
happened because the Dockerfile's stage-2 COPY only listed server/
index.js + server/license.js explicitly. When I started extracting
modules in v0.2.3 (util.js, gemini-helpers.js, audio.js, ytdlp.js,
cookies.js, config.js, license-middleware.js, history.js, library.js)
I forgot to update the COPY list, so those files were never copied
into the runner image. Local 'node' tests passed because the modules
exist on disk; the .s9pk container had only the two original files
and crashed on first import.
Fix:
COPY server/*.js ./server/
Glob picks up all top-level .js files automatically, including any
future extractions, while still skipping server/test/ and server/
node_modules/. This is the simplest forward-compatible form.
Bonus: refresh the vendored @keysat/licensing-client from 0.1.0 to
0.2.0. The new SDK adds:
• policySlug field on StartPurchaseOptions (so we can drive Core/
Pro tier selection programmatically from our backend)
• client.listPublicPolicies(productSlug) for fetching the tier
cards' data without auth
Both are prerequisites for the in-app buy flow planned in
~/.claude/plans/in-app-buy-flow.md. The vendor's own node_modules
(@noble/ed25519, @noble/hashes) is gitignored as before — Docker
builds re-install via `npm install --omit=dev --ignore-scripts` in
the vendor dir during stage 1.
Also includes the license-middleware update from earlier in the day:
a 30s license-file poll so a key set via the "Set Recap License"
StartOS action is picked up within seconds (instead of waiting for
the 6h scheduled validateOnline tick).
Two related changes that ship together because the second was uncovered
while testing the first.
1. Live config reload (the ostensible feature):
The "Set Gemini API Key" StartOS action writes to /data/config/
startos-config.json. The server used to read that file once at
startup (and via a separate Python read in docker_entrypoint.sh
before that), which meant a key change required a service restart
to take effect. Now the server polls the file every 3 s
(RECAP_CONFIG_POLL_MS, env-overridable) and updates serverApiKey
in place. fs.watch was tried first and dropped — it's flaky on
macOS (FSEvents single-file quirks) and behaves inconsistently with
atomic-rename writes the SDK file model uses. Polling is dead
simple and a stat call every 3 s is free.
Also dropped the Python config read from docker_entrypoint.sh; the
server now handles it natively. Entrypoint still loads /data/.env
for arbitrary env vars (RECAP_*, etc.).
2. Vendor module resolution (the silently-broken thing):
The earlier vendor change (move @keysat/licensing-client from a
git+https dep to a file: dep at vendor/) created a symlink in
server/node_modules. That symlink to the vendor dir was getting
resolved by Node, so the keysat client tried to import @noble/
ed25519 from /app/vendor/keysat-licensing-client/dist/, walked up
to /app/vendor/, then /app/, neither of which had node_modules.
Result: v0.2.0 and v0.2.1 would crash at startup with
ERR_MODULE_NOT_FOUND on @noble/ed25519. The Docker BUILD succeeded
because npm install with file: deps doesn't pull transitive deps
into the parent node_modules — but the runtime would have failed
the moment server/license.js ran.
Fix:
• Dockerfile builder now `npm install`s inside vendor/keysat-
licensing-client/ so @noble/* lands in its own node_modules,
where Node's resolver finds it.
• Dockerfile runner now COPYs vendor/ to the runner image
(previously not copied — the symlink in server/node_modules
would have pointed at nothing).
• vendor/keysat-licensing-client/package-lock.json is committed
so the in-Docker install is reproducible.
The keysat-client-ts repo is private. Previous builds were succeeding
purely because Docker layer caching reused a node_modules from when
the repo had been accessible — once anything invalidated the
server/package.json or server/package-lock.json hash (the rename did),
npm in a fresh container hit github with no credentials and 404'd.
Fix: copy the built dist/ from server/node_modules/@keysat/licensing-
client/ into vendor/keysat-licensing-client/, strip the prepare/build
scripts (we already have the compiled output), and switch the server
package.json dep to a file: path:
"@keysat/licensing-client": "file:../vendor/keysat-licensing-client"
Dockerfile now COPY's vendor/ before npm ci. No git, no SSH, no
credentials needed in the build container — and the npm step is
pure-local so it's deterministic.
Side cleanup: dropped the apt-install-git + url.insteadOf gymnastics
that existed solely to work around the now-removed git+https resolution.
The image is slightly smaller (no git in the builder stage). Switched
the npm flag to the modern --omit=dev (the legacy --production printed
a warning).
If keysat-client-ts updates, regenerate vendor/ by:
cp -r server/node_modules/@keysat/licensing-client/{dist,package.json,LICENSE,README.md} \
vendor/keysat-licensing-client/
# then strip prepare/build scripts and devDeps from the copied package.json
# (or just hand-edit if the upstream package.json hasn't changed)