Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2853546
Take the last Electron and better-sqlite3 of their lines
micahflee Sep 21, 2026
7b1a470
Take the Electron tooling minors
micahflee Sep 21, 2026
068a1d7
Take the ecosystem cluster minors
micahflee Sep 21, 2026
11f0101
Let npm audit fix update the transitive pins
micahflee Sep 21, 2026
2cd2595
Override the dependencies upstream will not patch
micahflee Sep 21, 2026
2a99bcf
Move better-sqlite3 onto the N-API build
micahflee Sep 21, 2026
72fe288
Move to Electron 44
micahflee Sep 21, 2026
6fbecbf
Move x-archive from Vue CLI to Vite
micahflee Sep 21, 2026
d3ad01a
Write down how to upgrade this repo's dependencies
micahflee Sep 21, 2026
a241112
Reconcile allowScripts with what the tree installs
micahflee Sep 21, 2026
a56bc58
Drop the last git dependency from the tree
micahflee Sep 21, 2026
333ff5f
Build releases on Node 24 LTS and the npm developers run
micahflee Sep 21, 2026
ff331ea
Drop a Vite option that does nothing
micahflee Sep 21, 2026
58d374e
Load the Vite configs as ESM
micahflee Sep 21, 2026
66754e9
Inject the archive.js tag instead of shipping it in index.html
micahflee Sep 21, 2026
609b78a
Read X cookies from the session, not from request headers
micahflee Sep 21, 2026
52575b0
Load the vitest config as ESM too
micahflee Sep 21, 2026
4db7a65
Await the sleeps that were dropped on the floor
micahflee Sep 21, 2026
deaa5b8
Drop the ignore entries the .mts renames left behind
micahflee Sep 21, 2026
034d6a0
Take the browser's redirect URL in finish-oauth
micahflee Sep 21, 2026
477b195
Keep the authorization code out of the invalid URL dialog
micahflee Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions .agents/skills/upgrading-dependencies/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
name: upgrading-dependencies
description: Upgrade Cyd's npm dependencies without breaking the Electron build. Use when updating dependencies, reducing the npm audit count, or taking an Electron, better-sqlite3 or Forge major.
---

# Upgrading dependencies

Three things in this repo punish a careless upgrade. npm refuses install scripts it has not been told to trust, by exact version. The test suite runs *inside* the Electron binary one of those scripts fetches, so a blocked script fails every test at once rather than in one spot. And most of the advisory count sits in transitive packages that no direct bump reaches.

Work in phases, cheapest and most load-bearing first. Every phase ends on **gates green**.

## The metric

`npm audit --json`, field `.metadata.vulnerabilities`. Record it before touching anything, and after every phase.

The count `npm install` prints at the end is a *different number*: it counts only what is installed on this machine, so the macOS-only `appdmg` chain is invisible on Linux. Comparing an install summary against an audit invents regressions that never happened. Diff audit against audit, and report which advisories resolved rather than the total alone.

`npm outdated` is a lead, not a source of truth. It reported a stale `Latest` for `typescript-eslint`, and better-sqlite3 publishes GitHub tags that never reach npm. Confirm every target with `npm view <pkg> version` before you install it.

## The gates

Run all of these after every phase. The suite takes seconds, so per-phase gating costs nothing and is what makes a failure name its own culprit.

```bash
npm test # runs inside the Electron binary
npm run lint # prettier + eslint + vue-tsc
npm run build --workspace=x-archive # the static site shipped inside the app
```

Plus the native smoke test, which is the fastest ABI check there is:

```bash
ELECTRON_RUN_AS_NODE=1 ./node_modules/.bin/electron -e \
"const D=require('better-sqlite3');new D(':memory:').exec('create table t(a)');console.log('NATIVE OK')"
```

Construct a `Database`. A bare `require('better-sqlite3')` returns cleanly with no compiled binary anywhere on disk, because the addon loads lazily — it is a false pass.

`npm run make-local` is the real packaging gate but needs an interactive `sudo` to chown `chrome-sandbox`. Ask the user to run it. `npx electron-forge package` covers most of the same ground without sudo.

## Phase 0 — baseline

Record the metric, and get all four gates green *before* changing anything. An upgrade onto a red baseline cannot be read.

Check that your npm matches the one in `Dockerfile`. A mismatch churns the lockfile on its own: npm 12 records a `libc` field on optional platform packages that npm 10 does not understand and silently strips, so the two versions fight over the same file across dev and the release build.

Note `workspaces` in `package.json`: `archive-static-sites/x-archive` shares the root lockfile, so its dependencies are your problem too. `scripts/x-capture` is not a workspace and is not part of the build.

## Phase 1 — the native floor

Electron and better-sqlite3, in that order, then reinstall.

Both run install scripts, and `package.json` holds an `allowScripts` map keyed by exact `name@version`. Bumping either silently invalidates its entry: Electron's binary never downloads and `dist/` disappears, better-sqlite3 never compiles. For each package whose version you moved:

```bash
npm install-scripts approve <pkg> # rewrites allowScripts, prunes the stale pin
npm rebuild <pkg> # plain `npm install` will NOT re-run a newly approved script
```

`npm install-scripts ls` shows what is currently blocked. Check it after every install in every phase — `esbuild` and `core-js` go stale on their own as transitives move.

Then run the native smoke test before anything else.

## Phase 2 — Electron tooling

`@electron/*` helpers: `osx-sign`, `rebuild`, `fuses`.

Check `@electron-forge/*` but expect nothing: it is usually already at its latest published version, and its advisories are not fixable by upgrading. They are Phase 5's problem.

## Phase 3 — ecosystem clusters

Move each cluster as a unit, gates after each, so a failure names its own culprit:

- vitest + `@vitest/coverage-v8`
- vite + `@vitejs/plugin-vue` + `@intlify/unplugin-vue-i18n`
- eslint + `@typescript-eslint/*` + `typescript-eslint` + `eslint-plugin-vue` + `@vue/eslint-config-typescript` + globals
- vue + vue-i18n + vue-tsc + `@vue/test-utils` + vue-chartjs
- `@atproto/*`
- independent leaves

When npm deadlocks with `ERESOLVE` on a cluster, it is resolving incrementally against versions the lockfile already pins. Editing `package.json` and reinstalling does not break the deadlock. `npm uninstall` the whole cluster and install it back at explicit versions in one command.

A Prettier bump reformats source files. Run `npm run format` and read the diff.

Treat `ERESOLVE overriding peer dependency` as a warning to re-check at the *end* of the cluster, not mid-way. One resolved itself once the rest of the cluster caught up.

## Phase 4 — npm audit fix

```bash
npm audit fix --allow-git=all
```

This is worth more than every direct upgrade combined — it moves transitive pins, which is where the advisories live. It touches only the lockfile; confirm `package.json` is unchanged afterward.

`--allow-git=all` is required because Forge pulls `@electron/node-gyp` from a git URL and npm now refuses git fetches by default. Check afterward that no *new* git dependency entered the lockfile.

Never `--force`. npm's idea of a fix is whatever version lacks the advisory, including backwards: it offers to take `@electron-forge/cli` from 7.11.2 down to 7.6.1. Prefer an override.

## Phase 5 — overrides

For what is left, read the leaf advisories — the entries whose `via` holds objects rather than strings. Those are real CVEs; everything else is propagation.

`"fixAvailable": false` means npm cannot get there by bumping a direct dependency. It does **not** mean no patched release exists. Compare the advisory's vulnerable `range` against `npm view <pkg> version`: if the registry is ahead of the range, an `overrides` entry reaches it where npm would not.

Two shapes:

```jsonc
"overrides": {
"tar": "^7.5.22", // force a version everywhere
"mhtml2html": { "jsdom": "^29.1.1" } // force it only inside one parent
}
```

An override only re-resolves a subtree npm has not already pinned. If the old version survives the install, `npm uninstall` the parent and install it back.

Verify each override by exercising the dependent, not by reading the audit. Check how the parent actually uses the package first: `http-mitm-proxy` imports only uuid's named `v4`, which survived the major, and `archive.ts` passes its own `parseDOM` into mhtml2html, which is why that bundled jsdom could be replaced wholesale.

Leave alone what has no fixed version at any release, and say so plainly in the report.

## Phase 6 — reconcile allowScripts

Approving as you go leaves the map full of versions that are no longer installed. Reconcile it once at the end, because a stale entry is invisible until the next person's install blocks.

List what the tree actually asks for, and match the map to it:

```bash
node -e "const l=require('./package-lock.json');for(const[k,v]of Object.entries(l.packages||{}))if(v.hasInstallScript)console.log(k.replace('node_modules/','')+'@'+v.version)"
npm install-scripts approve <pkg> # re-approving prunes that package's stale pin
```

That listing undercounts: npm also runs an implicit `node-gyp rebuild` for any package shipping a `binding.gyp`, even when it declares no install script and the lockfile sets no `hasInstallScript`. better-sqlite3 is one, which is why it still needs an entry despite shipping prebuilds — the entry only matters on a platform with no prebuild, where the source build is the fallback.

Delete by hand any entry whose package no longer runs a script at all. Electron from v42 is one: it downloads on first run instead.

Finish with a plain `npm install`, and confirm `npm install-scripts ls` reports nothing blocked.

## Majors

Minor-only is the default. When the minor pass is green, report the remaining advisories and **ask the user** whether to take majors — name the specific ones, what each is worth, and what it costs.

Read [`references/majors.md`](references/majors.md) before taking any.
57 changes: 57 additions & 0 deletions .agents/skills/upgrading-dependencies/references/majors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Taking a major

Reached from [`../SKILL.md`](../SKILL.md) once the minor pass is green and the user has chosen which majors to take.

## Order

Take **better-sqlite3 before Electron**, always.

From v13 it is built on N-API and ships portable prebuilds, so one binary spans ABIs. Taking it first means the Electron jump needs no rebuild at all — verified across ABI 145 to 149. Taking Electron first means fighting a rebuild you did not need to have.

The same logic generalises: move whatever is pinned to the runtime's ABI before moving the runtime.

## Read the release notes for the whole span

Not just the target. List the releases with `gh api "repos/<owner>/<repo>/releases?per_page=100"` and read every major in between, because breaking changes land in the ones you skip over.

Harvest two things: an explicit **end-of-support** warning, which turns a nice-to-have into a security deadline, and any note about **build hosts** rather than APIs. Those are the ones that do not show up in a test run.

## Scan before you install

Grep the source for every API the notes call out, and settle each hit before touching the manifest. Most will be misses, and knowing that up front is what makes the upgrade cheap.

The hits that matter are the ones where the *call site* decides. An option removed from a method costs nothing if this repo never passes it; a name that survives only in a type union costs nothing either. Read the call, not the symbol.

## Electron

- **node-abi lags.** `@electron/rebuild` pins a `node-abi` that does not know the newest Electron, and `electron-rebuild` then fails outright with `Could not detect abi for version`. An `overrides` entry on `node-abi` fixes it. `postinstall` runs `electron-rebuild` and exits non-zero on failure, so this breaks plain `npm install`.
- **From v42 the binary downloads on first run**, not in `postinstall`. An absent `node_modules/electron/dist` straight after an install is now normal; invoke the binary once to populate it.
- **Arch and OS removals** are packaging concerns, not code. Check `forge.config.ts` makers and the workflows in `.github/workflows/` for whether a dropped target is actually built.

## better-sqlite3

Check the glibc floor of the shipped prebuild against the build image in `Dockerfile`, not against the machine you are working on:

```bash
objdump -T node_modules/better-sqlite3/prebuilds/linux-x64.node | grep -oE "GLIBC_[0-9.]+" | sort -Vu | tail -1
```

Release notes quote a floor for the *old* per-ABI assets that does not apply to the N-API prebuilds. Measure the binary.

## x-archive

An archive is read by double-clicking `index.html`, so everything it ships is constrained by `file://`:

- Browsers refuse to fetch **ES modules** over `file://`. The entry has to stay a classic script, and `vite.config.ts` rewrites Vite's module tag to keep it that way.
- `crossorigin` on a script or stylesheet forces CORS mode, which fails the same way.
- A classic script in `<head>` runs **before** `#app` is parsed, so it needs `defer` or it mounts onto nothing.

None of this is visible to `npm test`, and the build succeeds either way. Verify by loading the built `dist/index.html` over `file://` in Electron with a seeded `window.archiveData`, and assert on rendered output: the document title, some body text, a computed style, a loaded font.

## Blocked majors

Some majors are held by a peer range rather than by their own difficulty. `@typescript-eslint` peer-requires `typescript >=4.8.4 <6.1.0`, so TypeScript 7 waits on typescript-eslint, not on this repo. Check peer ranges before promising a major is available.

## Dead build tooling is a migration

When a tool's last release is its final one, its advisories have no upgrade path and npm will only ever offer a downgrade. Price it as a migration to a maintained tool and put it to the user as such, rather than carrying the advisories forward each pass.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "24.x"
cache: "npm"

- name: Install build dependencies
Expand Down
12 changes: 10 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# For making linux releases

FROM node:22-bookworm
# Node 24 is the current LTS. Bookworm is deliberate, not incidental: the
# postinstall rebuilds better-sqlite3 from source against this image's glibc,
# so the base image sets the floor for every Linux user. Bookworm is 2.36;
# trixie would raise it to 2.41.
FROM node:24-bookworm

# Match the npm developers run. npm from 12 refuses install scripts it has not
# been told to trust, so building here exercises the allowScripts allowlist in
# package.json rather than leaving it to break on someone's laptop.
RUN npm install -g npm@12.0.2

RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \
build-essential \
Expand All @@ -11,4 +20,3 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \
sudo

WORKDIR /workspace

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Cyd Archive: X</title>
<script src="assets/archive.js"></script>
</head>

<body>
Expand All @@ -16,5 +15,6 @@
>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
21 changes: 11 additions & 10 deletions archive-static-sites/x-archive/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,23 @@
"name": "x-archive",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
"dev": "vite",
"serve": "vite",
"build": "vue-tsc --noEmit && vite build"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.2.0",
"@fortawesome/fontawesome-free": "^7.3.1",
"bootstrap": "^5.3.8",
"vue": "^3.5.35",
"vue-class-component": "8.0.0-rc.1",
"vue": "^3.5.43",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@types/node": "^25.2.3",
"@vue/cli-plugin-router": "~5.0.9",
"@vue/cli-plugin-typescript": "~5.0.9",
"@vue/cli-service": "~5.0.9",
"typescript": "^6.0.3"
"@vitejs/plugin-vue": "^6.0.9",
"@types/node": "^25.9.8",
"typescript": "^6.0.3",
"vite": "^8.3.0",
"vue-tsc": "^3.3.11"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, inject, Ref } from "vue";
import { inject, Ref } from "vue";
import { XArchive, Conversation } from "../types";
import UserComponent from "./UserComponent.vue";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Ref, defineProps, inject } from "vue";
import { Ref, inject } from "vue";
import { XArchive, Message } from "../types";
import { formattedDate, formattedDatetime } from "../helpers";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, computed, inject, Ref } from "vue";
import { computed, inject, Ref } from "vue";

import {
formattedDatetime,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, ref, computed } from "vue";
import { ref, computed } from "vue";

import { Tweet } from "../types";
import TweetComponent from "../components/TweetComponent.vue";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { defineProps } from "vue";
import { User } from "../types";

defineProps<{
Expand Down
23 changes: 8 additions & 15 deletions archive-static-sites/x-archive/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2022",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"moduleResolution": "bundler",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"useDefineForClassFields": true,
"sourceMap": true,
"baseUrl": ".",
"types": ["webpack-env"],
"noEmit": true,
"types": ["vite/client"],
"paths": {
"@/*": ["src/*"]
"@/*": ["./src/*"]
},
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
"lib": ["esnext", "dom", "dom.iterable"]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": ["node_modules"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
Loading
Loading