- name
- test-lib-with-wallet
- description
- Prove a ts-libs library is compatible with the ledger-live wallet monorepo BEFORE publishing, by packing local tarballs and forcing them into ledger-live via pnpm.overrides. Use after /import-lib-from-live, before releasing, or to validate an open PR against its biggest consumer.
# test-lib-with-wallet
A published npm version is immutable, so ledger-live's CI is normally the first thing to
find a broken release. This skill closes that loop early: **pack the candidate libs, force
them into a real ledger-live checkout, run the wallet's own builds.** Nothing is published;
everything is reverted.
Mirror of `/import-lib-from-live` — that one moves code out, this one proves the move
survives the consumer. Works the same for a package *replacement*.
## Usage
```
/test-lib-with-wallet # everything on the release branch
/test-lib-with-wallet <lib> <lib> # only these
/test-lib-with-wallet --pr <n> # include an open PR branch
```
## Steps
### 0. Set up
`$LIVE` = the ledger-live checkout (ask if unknown), on a clean `develop`.
`PACKS=$TMPDIR/tslibs-packs`.
Test **`origin/changeset-release/develop`**, not `develop` — it carries the versions
changesets will actually publish. Cherry-pick any open PR on top.
### 1. Build and pack
```bash
mise exec -- pnpm install && mise exec -- pnpm build && mise exec -- pnpm verify-pack
mkdir -p $PACKS
for l in <libs>; do (cd libs/$l && mise exec -- pnpm pack --pack-destination $PACKS); done
```
`pnpm pack`, never `npm pack` — only pnpm reproduces what the release publishes.
`verify-pack` proves the tarball ships what the manifest declares; it skips `*` patterns
and knows nothing about consumers, which is step 3's job.
### 2. Parity diff
**Matching versions prove nothing** — a release cut from a release branch can post-date a
ledger-live commit and still predate its content, so a "newer" package can carry older code.
```bash
mkdir -p $PACKS/x && tar xzf $PACKS/ledgerhq-<name>-<v>.tgz -C $PACKS/x --strip-components=1 package/src
diff -r -x '*.test.ts' $PACKS/x/src $LIVE/<path-to-lib>/src
```
Name every remaining diff. Diff the manifests too: `dependencies`, `peerDependencies`,
`main`/`module`/`types`.
For a **replacement** package, also diff the two packages' sources and check every name the
wallet imports is exported. Watch shared deps pinned differently in the two catalogs: the
skew adds another copy of that dep — harmless for stateless calls, not for values compared
or composed across instances.
### 3. Subpath audit
`/import-lib-from-live` narrows `exports`, which silently removes subpaths consumers
import. Audit both populations — **they need different fixes**:
```bash
# a) wallet sources → FIXABLE: rewrite the imports
grep -rhoE "@ledgerhq/<name>(/[a-zA-Z0-9_./-]+)?" --include="*.ts" --include="*.tsx" \
$LIVE/apps $LIVE/libs $LIVE/shared $LIVE/domain $LIVE/features | sort -u
# b) already-published @ledgerhq tarballs → NOT fixable: the lib keeps a back-compat alias
grep -rhoE "@ledgerhq/<name>/[a-zA-Z0-9_./-]+" \
$LIVE/node_modules/.pnpm/*/node_modules/@ledgerhq/*/lib*/ | sort -u
```
Enumerate everything the old wildcards matched, including the non-obvious: `./index`, and
the `./lib/*` / `./lib-es/*` paths that published `.d.ts` files reference. `tsc` and unit
tests resolve through `@ledgerhq/source` to `src/`, so a missing subpath surfaces only when
a bundler resolves the published package.
### 4. Peer audit
`auto-install-peers=false`, and pnpm satisfies a peer from **the importer's** deps — so the
workspace manifests are not the whole picture, some importers are immutable tarballs.
```bash
grep -rln '"@ledgerhq/<name>"' $LIVE/*/*/package.json $LIVE/*/package.json
for d in node_modules/.pnpm/@ledgerhq+<name>@*/; do
printf "%s " "$(basename $d)"
ls $d/node_modules/ | grep -q "^<peer>$" && echo HAS || echo MISSING
done
```
Symptoms are indirect: a type widening to `any`, or `Cannot find module` from a bundler
**or from jest**.
**Nuke `node_modules` first (step 5) — a stale instance raises the same
`Cannot find module` as a real gap.** If the gap survives a clean tree, one
`packageExtensions` entry **per published consumer** is the only recourse, and it is
mandatory, not cosmetic — confirm by A/B on a clean tree. Two traps:
- On **the lib itself it does nothing, silently**: pnpm keeps the existing
`peerDependencies` entry and discards the `dependencies` you added. Only the consumer
side works.
- The consumer side rejects `catalog:` (`ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`) — an
external package cannot read the workspace catalog. So the version is hand-pinned: the
one place in the PR diff that cannot use `catalog:` and will silently drift from it.
The cost is linear in the number of published modules consuming the lib — which is what
justifies **escalating instead of patching**: is the peer still worth it, or should those
modules be republished declaring it?
### 5. Wire in and install
Tarballs go in the root `package.json`'s **existing** `pnpm.overrides` block; the future
published versions go in the catalog (that is the diff the real PR carries).
```jsonc
"pnpm": { "overrides": { "@ledgerhq/<name>": "file:/abs/path/ledgerhq-<name>-<v>.tgz" } }
```
`overrides` intercepts every instance, including transitive ones from published
`@ledgerhq/*`. The path cannot go in the catalog — `file:` there fails with
`ERR_PNPM_CATALOG_ENTRY_INVALID_SPEC`. A `catalog:` ref *inside* `overrides` is fine.
> **Check pnpm's major version.** pnpm 10 reads `pnpm.overrides` from `package.json`;
> pnpm 11 ignores that whole field with only a warning, so the install silently resolves
> the *published* versions and every check below passes for the wrong reason. On pnpm 11
> the overrides belong in `pnpm-workspace.yaml`.
**Delete `node_modules` between repack cycles.** Neither `pnpm install` nor `--force`
purges stale `.pnpm/` instances or the dead peer symlinks inside them, and every `pnpm pack`
mints a new `file:` hash, hence a new instance. Real packages keep linking the stale one, so
a bundler walks a path the lockfile says does not exist and fails on a dependency that is
genuinely present — a phantom failure.
```bash
cd $LIVE && find . -type d -name node_modules -prune -exec rm -r {} +
CI=true mise exec -- pnpm install --no-frozen-lockfile
grep -c "ledgerhq-<name>-<v>.tgz" pnpm-lock.yaml # must be > 0
grep -c "@ledgerhq/<name>@<old-version>" pnpm-lock.yaml # must be 0
```
Match the version **without** a closing quote: pnpm appends peer suffixes to keys
(`'@ledgerhq/<name>@<v>(<peer>@<v>)'`) and the same string appears inside other packages'
keys, so an exact `'…@<v>'` reports 0 for a graph that still resolves the old one.
**The lockfile diagnoses, a clean tree convicts.** If no snapshot depends on the offending
instance, you are looking at residue. Never report an incompatibility from a dirty tree.
### 6. Run the wallet's checks
```bash
CI=true mise exec -- pnpm build:lld:deps --parallel=100% # desktop
CI=true mise exec -- pnpm build:llm:deps --parallel=100% # mobile
CI=true mise exec -- pnpm typecheck
```
Run the **builds**, not just `typecheck` — rspack/metro are what catch a broken `exports`
map, an unresolvable peer, or an ESM/CJS mismatch.
**Two ways this step reports a pass without having tested anything:**
- **`… | tail` returns the pipe's exit code, not pnpm's.** Redirect to a log and read `$?`, or the
first failing build looks like a success.
- **nx replays the run from its local cache.** A second invocation prints
`Successfully ran target build …` next to `read the output from the cache for N out of N tasks`
— nothing executed. Prove real execution by counting cache entries written during the run: nx
only writes an entry for a task that ran *and* passed, so N fresh entries == N tasks proved.
```bash
START=$(date '+%Y-%m-%d %H:%M:%S')
CI=true mise exec -- pnpm build:lld:deps --parallel=100% > /tmp/lld.log 2>&1
STATUS=$? # capture before anything else runs
FRESH=$(find .nx/cache -mindepth 1 -maxdepth 1 -type d -newermt "$START" | wc -l)
echo "exit=$STATUS fresh=$FRESH"
[ "$STATUS" -eq 0 ] && [ "$FRESH" -gt 0 ] || echo "NOT PROVEN"
```
`STATUS` must be captured on the line after the build — `echo "exit=$?"` works, but any command
between the build and the read (even the `find`) overwrites `$?`. `-mindepth 1` matters too:
without it `find` counts `.nx/cache` itself, so a run that executed **nothing** still reports 1.
Nx output is ANSI-coloured and can carry invalid UTF-8 — grep it with `LC_ALL=C grep -a`.
Reading ledger-live's `pnpm typecheck`: `scripts/typecheck.js` keeps only diagnostics under the
app's own `src`/`tests`/`tools` and prints `(Filtered N errors)` for everything else. A large N is
its standing baseline, not damage from your change.
Compute the test targets instead of guessing: every manifest that depends on a packed lib
*and* has a `test` script, then `nx run-many -t test -p <list> --parallel=6`.
```bash
cd $LIVE && mise exec -- node -e '
const { execSync } = require("child_process"), fs = require("fs");
const libs = process.argv.slice(1), out = new Set();
for (const f of execSync(`git ls-files "*package.json"`, { encoding: "utf8" }).split("\n").filter(Boolean)) {
let m; try { m = JSON.parse(fs.readFileSync(f, "utf8")); } catch { continue; }
if (!m.name || !m.scripts?.test) continue;
const deps = { ...m.dependencies, ...m.devDependencies, ...m.peerDependencies };
if (libs.some(l => deps[`@ledgerhq/${l}`])) out.add(m.name);
}
console.log([...out].join(","));
' <libs>
```
**A failing task is not evidence.** App integration suites time out
(`Exceeded timeout of 5000 ms`) under heavy `--parallel`, and the monorepo carries failures
unrelated to your change. Before calling anything a regression: check the suite even
references a packed lib (`grep -c "@ledgerhq/<name>" <suite>` → `0` means suspect), then
re-run it alone at low parallelism.
A suite that *does* reference a packed lib still is not proof — **classify it against the clean
baseline**, which the revert in step 7 gives you for free: `git checkout -- .`, reinstall, re-run
that one target. Same failure ⇒ pre-existing. This matters most for undeclared ("phantom")
dependencies: a package that a manifest never declared but resolved anyway through workspace
hoisting fails the moment the lib arrives as a tarball — and fails identically on develop once
`node_modules` has been nuked. The nuke, not your change, is what exposed it.
### 7. Report and revert
Report per lib: parity diff, subpath gaps, peer gaps, each check's result. Fixes land **in
ts-libs, before publishing** — that is the point. Amend the changeset if the public API moved.
Keep the local diff separable: all scaffolding (`file:` paths, temporary
`packageExtensions`) stays inside the root `pnpm.overrides` / `pnpm.packageExtensions`, so
`git checkout -- package.json pnpm-lock.yaml` recovers the real PR diff — catalog entries,
consumer manifests, import rewrites.
## What each step catches
| Step | Catches | Missed by |
|---|---|---|
| 1 `verify-pack` | a declared path the tarball omits | `pnpm build` |
| 2 parity diff | stale build, diverged source | version comparison |
| 3 subpath audit | narrowed `exports` breaking published consumers | `tsc`, tests, `verify-pack` |
| 4 peer audit | peer unsatisfied in a published importer | `pnpm install` |
| 5 lockfile assertion | overrides silently ignored | install exit code |
| 5 `node_modules` nuke | phantom failures from stale `.pnpm/` instances | `pnpm install --force` |
| 6 builds | ESM/CJS mismatch, runtime resolution | `typecheck` |
| 6 computed targets | an untested direct consumer | guessing |
Ver no GitHub