From 5003dc7f33495e384712e8a1787305e5b4148399 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 13 Jul 2026 17:35:17 +0200 Subject: [PATCH] feat(sdk): add update verification --- plugin-sdk/README.md | 29 + plugin-sdk/package-lock.json | 771 +++++++++++++++++++++- plugin-sdk/package.json | 4 +- plugin-sdk/src/cli/create.ts | 2 + plugin-sdk/src/cli/trek-plugin.ts | 4 + plugin-sdk/src/cli/update-check-worker.ts | 52 ++ plugin-sdk/src/cli/update-check.ts | 112 ++++ plugin-sdk/src/cli/update-notice.ts | 26 + plugin-sdk/test/update-check.test.ts | 209 ++++++ 9 files changed, 1205 insertions(+), 4 deletions(-) create mode 100644 plugin-sdk/src/cli/update-check-worker.ts create mode 100644 plugin-sdk/src/cli/update-check.ts create mode 100644 plugin-sdk/src/cli/update-notice.ts create mode 100644 plugin-sdk/test/update-check.test.ts diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index 0116fb9ff..b5435eff7 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -152,4 +152,33 @@ bin if you install the package): - `release [dir] --repo o/n --tag vX [--sign [key]] [--merge f]` — pack → GitHub release → entry, in one go. - `publish [dir] --repo o/n --tag vX [--sign [key]] [--no-preflight]` — **the lot**: pack → tag + release → preflight → open the PR. +## Update notice + +Both CLIs (`trek-plugin` and `create-trek-plugin`) tell you when a newer SDK has been +published. This matters more than the usual "you're on an old version" nag: the +registry entry format, the manifest rules and the permission catalog all move with the +TREK host, so a stale SDK can `pack` and `submit` an entry that today's registry CI +rejects. + +It is powered by [`update-notifier`](https://github.com/sindresorhus/update-notifier), +the standard for npm CLIs: + +- At most **once every 24 hours**, a detached background process asks + `registry.npmjs.org` for this package's `latest` version and caches the answer under + `$XDG_CONFIG_HOME/configstore/` (or `~/.config/…`). +- Your command **never waits for it** — the notice is printed from that cache, so a + fresh install learns about an update on a later run. +- The notice goes to **stderr**, so `pack --json` and `entry` keep piping clean JSON. + +The request is an unauthenticated GET for a public package — the same one `npm install` +makes (npm's servers see your IP, as for any download). TREK has no telemetry, and this +isn't any. To turn it off: + +```bash +export NO_UPDATE_NOTIFIER=1 +``` + +It is already silent in CI (any `CI` env var), under `NODE_ENV=test`, and whenever +stdout isn't a terminal (i.e. when piped or redirected). + The SDK tooling in this repo is MIT. Your plugin is your own code under your own license. diff --git a/plugin-sdk/package-lock.json b/plugin-sdk/package-lock.json index d494677f5..c416183aa 100644 --- a/plugin-sdk/package-lock.json +++ b/plugin-sdk/package-lock.json @@ -1,16 +1,17 @@ { "name": "trek-plugin-sdk", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-plugin-sdk", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@clack/prompts": "^1.7.0", - "semver": "^7.7.4" + "semver": "^7.7.4", + "update-notifier": "^7.3.1" }, "bin": { "create-trek-plugin": "dist/cli/create.js", @@ -20,6 +21,7 @@ "devDependencies": { "@types/node": "^22.20.0", "@types/semver": "^7.7.1", + "@types/update-notifier": "^6.0.8", "typescript": "^5.9.3", "vitest": "^4.1.9" }, @@ -125,6 +127,47 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -418,6 +461,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/configstore": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/configstore/-/configstore-6.0.2.tgz", + "integrity": "sha512-OS//b51j9uyR3zvwD04Kfs5kHpve2qalQ18JhY/ho3voGYUTPLEG90/ocfKPI48hyHH8T04f7KEEbK6Ue60oZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -449,6 +499,125 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/update-notifier": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@types/update-notifier/-/update-notifier-6.0.8.tgz", + "integrity": "sha512-IlDFnfSVfYQD+cKIg63DEXn3RFmd7W1iYtKQsJodcHK9R1yr8aKbKaPKfBxzPpcHCq2DU8zUq4PIPmy19Thjfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/configstore": "*", + "boxen": "^7.1.1" + } + }, + "node_modules/@types/update-notifier/node_modules/boxen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/update-notifier/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/update-notifier/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -562,6 +731,80 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -572,6 +815,50 @@ "node": ">=12" } }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -582,6 +869,64 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/configstore": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.1.0.tgz", + "integrity": "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg==", + "license": "BSD-2-Clause", + "dependencies": { + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -589,6 +934,15 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "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", @@ -599,6 +953,34 @@ "node": ">=8" } }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", @@ -606,6 +988,18 @@ "dev": true, "license": "MIT" }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -683,6 +1077,139 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.1.0.tgz", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/latest-version": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", + "license": "MIT", + "dependencies": { + "package-json": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -954,6 +1481,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -987,6 +1523,24 @@ "node": ">=12.20.0" } }, + "node_modules/package-json": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", + "license": "MIT", + "dependencies": { + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1043,6 +1597,75 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/pupa": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "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/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -1126,6 +1749,62 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1178,6 +1857,18 @@ "license": "0BSD", "optional": true }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1199,6 +1890,30 @@ "dev": true, "license": "MIT" }, + "node_modules/update-notifier": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.1.tgz", + "integrity": "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA==", + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^8.0.1", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -1367,6 +2082,12 @@ } } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1383,6 +2104,50 @@ "engines": { "node": ">=8" } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index 703f8b82c..273ce4e12 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -64,7 +64,8 @@ ], "dependencies": { "@clack/prompts": "^1.7.0", - "semver": "^7.7.4" + "semver": "^7.7.4", + "update-notifier": "^7.3.1" }, "scripts": { "build": "tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/finish-cjs.mjs", @@ -75,6 +76,7 @@ "devDependencies": { "@types/node": "^22.20.0", "@types/semver": "^7.7.1", + "@types/update-notifier": "^6.0.8", "typescript": "^5.9.3", "vitest": "^4.1.9" }, diff --git a/plugin-sdk/src/cli/create.ts b/plugin-sdk/src/cli/create.ts index 9c53ebdac..6ae05a3ac 100644 --- a/plugin-sdk/src/cli/create.ts +++ b/plugin-sdk/src/cli/create.ts @@ -15,6 +15,7 @@ import { PERMISSION_CATALOG, } from './ui.js'; import { KNOWN_ADDONS } from '../manifest.js'; +import { notifySdkUpdate } from './update-notice.js'; /** This package's own version, for the scaffold's devDependency range. */ function sdkVersionRange(): string { @@ -491,6 +492,7 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin // CLI entry if (process.argv[1] && process.argv[1].endsWith('create.js')) { + notifySdkUpdate(); const args = process.argv.slice(2); const name = args.find((a: string) => !a.startsWith('-')); const typeIdx = args.indexOf('--type'); diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index acb8952d3..ea4c1ae56 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -41,6 +41,7 @@ import { promptText, promptConfirm, clackLogSink, missingArgs, } from './ui.js'; import { runMenu } from './menu.js'; +import { notifySdkUpdate } from './update-notice.js'; const [cmd, ...args] = process.argv.slice(2); @@ -159,6 +160,9 @@ The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha or hand-write the registry JSON.`; async function main(): Promise { + // Advisory only (update-notifier): prints from cache on stderr, refreshes in the + // background, never blocks. `dev` never returns, so this runs up front. + notifySdkUpdate(); // Before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown. // Help is not an error: it goes to stdout and exits 0. if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flags.help) { diff --git a/plugin-sdk/src/cli/update-check-worker.ts b/plugin-sdk/src/cli/update-check-worker.ts new file mode 100644 index 000000000..9f94523a4 --- /dev/null +++ b/plugin-sdk/src/cli/update-check-worker.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * The background half of the update notice (#plugins) — see update-check.ts. + * + * Spawned detached and unref'd, so nothing is waiting on it: it asks npm for the + * `latest` dist-tag, writes the cache the NEXT command will read, and exits. It + * prints nothing, ever, and every failure (offline, DNS, 404, timeout, read-only + * cache dir) is a silent no-op — the author is not debugging our update check. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { PACKAGE_NAME, cachePath, type UpdateCache } from './update-check.js'; + +const REGISTRY = 'https://registry.npmjs.org'; +/** Generous: nobody is waiting on this process. */ +const TIMEOUT_MS = 3000; + +/** Ask the registry for the `latest` dist-tag. `null` on any failure. */ +export async function fetchLatest(registry: string = REGISTRY): Promise { + try { + const res = await fetch(`${registry}/${PACKAGE_NAME}/latest`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + if (!res.ok) return null; + const body = (await res.json()) as { version?: unknown }; + return typeof body?.version === 'string' ? body.version : null; + } catch { + return null; + } +} + +/** Write the cache the next command reads. Silently gives up if it can't. */ +export function writeCache(file: string, cache: UpdateCache): void { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(cache)); + } catch { + // A cache we can't write just means we check again next time. + } +} + +/** Fetch → cache. Exported whole so the tests can drive it with a stubbed `fetch`. */ +export async function refresh(file: string = cachePath(), now: number = Date.now()): Promise { + const latest = await fetchLatest(); + if (latest) writeCache(file, { lastCheck: now, latest }); +} + +// Bin entry. Never throws, never prints, always exits 0. +if (process.argv[1] && process.argv[1].endsWith('update-check-worker.js')) { + refresh().catch(() => {}).finally(() => process.exit(0)); +} diff --git a/plugin-sdk/src/cli/update-check.ts b/plugin-sdk/src/cli/update-check.ts new file mode 100644 index 000000000..b1a7b45e8 --- /dev/null +++ b/plugin-sdk/src/cli/update-check.ts @@ -0,0 +1,112 @@ +/** + * "Your SDK is out of date" — the advisory update notice (#plugins). + * + * A stale SDK is not a cosmetic problem: the registry-entry format, the manifest + * validator and the permission catalog all move with the host, so an author on an + * old SDK can happily `pack` and `submit` an entry that today's registry CI rejects. + * + * Three rules keep this safe to call from the top of every command: + * 1. ZERO added latency. The command only ever prints from a cache file; the + * refresh runs in a DETACHED child (see update-check-worker.ts). An un-awaited + * in-process fetch() would NOT do — Node keeps the socket alive and won't exit + * until it resolves, so every command would pay ~300ms. + * 2. STDERR only. stdout is a pure data channel (`entry`, `pack --json`, PR URLs) — + * see the contract in ui.ts. + * 3. It CANNOT fail a command. Every path is wrapped; a broken cache, a missing + * worker or an offline machine are all silently indistinguishable from "no news". + */ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import semver from 'semver'; + +export const PACKAGE_NAME = 'trek-plugin-sdk'; +export const CACHE_FILE = 'update-check.json'; +export const TTL_MS = 24 * 60 * 60 * 1000; + +export interface UpdateCache { + /** Epoch ms of the last successful registry read. */ + lastCheck: number; + /** The `latest` dist-tag as of `lastCheck`. */ + latest: string; +} + +/** This package's own version — the thing we compare the registry against. */ +export function sdkVersion(): string { + try { + const pkg = createRequire(import.meta.url)('../../package.json') as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +/** + * Where the cache lives. Takes its inputs as arguments (rather than reading the + * environment itself) so tests can point it at a temp dir without touching $HOME. + */ +export function cachePath(env: NodeJS.ProcessEnv = process.env, homedir = os.homedir(), tmpdir = os.tmpdir()): string { + const base = env.XDG_CACHE_HOME || (homedir ? path.join(homedir, '.cache') : tmpdir); + return path.join(base, PACKAGE_NAME, CACHE_FILE); +} + +/** Missing, unreadable, corrupt, or wrong-shaped — all are simply "no news". Never throws. */ +export function readCache(file: string): UpdateCache | null { + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial; + if (typeof raw?.lastCheck !== 'number' || typeof raw?.latest !== 'string') return null; + return { lastCheck: raw.lastCheck, latest: raw.latest }; + } catch { + return null; + } +} + +/** An absent cache is stale — that is what triggers the very first refresh. */ +export function isStale(cache: UpdateCache | null, now: number, ttlMs: number = TTL_MS): boolean { + return !cache || now - cache.lastCheck >= ttlMs; +} + +/** + * The notice is for humans at a terminal, and for nobody else: not CI logs, not + * pipes, and not authors who have opted out. + */ +export function isSilenced(env: NodeJS.ProcessEnv = process.env, isTTY: boolean = Boolean(process.stderr.isTTY)): boolean { + if (env.TREK_SDK_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI) return true; + return !isTTY; +} + +/** The notice itself — `null` when there is nothing to say (same version, or ahead of it). */ +export function banner(current: string, latest: string): string | null { + if (!semver.valid(current) || !semver.valid(latest) || !semver.gt(latest, current)) return null; + const dim = (s: string): string => `\x1b[2m${s}\x1b[0m`; + return dim(`\n Update available ${current} → ${latest} · npm i -g ${PACKAGE_NAME}@latest\n`); +} + +/** Detached + unref'd, so the parent process exits the instant its own work is done. */ +function spawnRefresh(): void { + const worker = fileURLToPath(new URL('./update-check-worker.js', import.meta.url)); + spawn(process.execPath, [worker], { detached: true, stdio: 'ignore' }).unref(); +} + +/** + * The one entry point. Call it at the top of a command; it returns immediately. + * Prints from cache, and refreshes that cache in the background for the NEXT run — + * which is why a brand-new install learns about an update one run late. That is the + * price of never making an author wait on the network. + */ +export function maybeNotify(now: number = Date.now()): void { + try { + if (isSilenced()) return; + const cache = readCache(cachePath()); + if (cache) { + const msg = banner(sdkVersion(), cache.latest); + if (msg) process.stderr.write(msg); + } + if (isStale(cache, now)) spawnRefresh(); + } catch { + // An update notice is never worth failing a command over. + } +} diff --git a/plugin-sdk/src/cli/update-notice.ts b/plugin-sdk/src/cli/update-notice.ts new file mode 100644 index 000000000..e8e969671 --- /dev/null +++ b/plugin-sdk/src/cli/update-notice.ts @@ -0,0 +1,26 @@ +/** + * "Your SDK is out of date" — the advisory update notice (#plugins). + * + * A stale SDK is not cosmetic: the registry-entry format, the manifest validator and + * the permission catalog all move with the host, so an author on an old SDK can `pack` + * and `submit` an entry that today's registry CI rejects. + * + * This is a thin wrapper over `update-notifier` (the de-facto standard for npm CLIs): + * a detached background process checks the registry at most once a day, the notice is + * printed from cache on stderr, and CI / non-TTY / `NO_UPDATE_NOTIFIER` all silence it. + * We hand it off rather than hand-roll the same detached-child + cache dance ourselves. + */ +import updateNotifier from 'update-notifier'; +import { createRequire } from 'node:module'; + +export function notifySdkUpdate(): void { + try { + // `../../package.json` resolves to the package root from dist/cli/ (prod) and + // src/cli/ (tests) alike. createRequire keeps it working on every Node >=18, + // unlike `import ... with { type: 'json' }`, which Node 18 does not accept. + const pkg = createRequire(import.meta.url)('../../package.json') as { name: string; version: string }; + updateNotifier({ pkg }).notify(); + } catch { + // An update notice is never worth disturbing a command over. + } +} diff --git a/plugin-sdk/test/update-check.test.ts b/plugin-sdk/test/update-check.test.ts new file mode 100644 index 000000000..345a95ac9 --- /dev/null +++ b/plugin-sdk/test/update-check.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const spawn = vi.hoisted(() => vi.fn(() => ({ unref: vi.fn() }))); +vi.mock('node:child_process', async (orig) => ({ + ...(await orig()), + spawn, +})); + +const { + banner, cachePath, readCache, isStale, isSilenced, maybeNotify, sdkVersion, PACKAGE_NAME, TTL_MS, +} = await import('../src/cli/update-check.js'); +const { fetchLatest, writeCache, refresh } = await import('../src/cli/update-check-worker.js'); + +let tmp: string; +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'update-check-')); + spawn.mockClear(); +}); +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('banner', () => { + it('announces only a strictly newer version', () => { + expect(banner('1.2.0', '1.5.0')).toContain('1.2.0 → 1.5.0'); + expect(banner('1.5.0', '1.5.0')).toBeNull(); + expect(banner('1.6.0', '1.5.0')).toBeNull(); + }); + + it('says nothing when either version is unparseable', () => { + expect(banner('not-a-version', '1.5.0')).toBeNull(); + expect(banner('1.2.0', 'garbage')).toBeNull(); + }); +}); + +describe('sdkVersion', () => { + it("reads this package's real version", () => { + const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + expect(sdkVersion()).toBe(pkg.version); + }); +}); + +describe('cachePath', () => { + it('prefers XDG_CACHE_HOME, else ~/.cache', () => { + expect(cachePath({ XDG_CACHE_HOME: '/xdg' }, '/home/a', '/tmp')) + .toBe(path.join('/xdg', PACKAGE_NAME, 'update-check.json')); + expect(cachePath({}, '/home/a', '/tmp')) + .toBe(path.join('/home/a', '.cache', PACKAGE_NAME, 'update-check.json')); + }); +}); + +describe('readCache', () => { + it('reads a well-formed cache', () => { + const f = path.join(tmp, 'c.json'); + fs.writeFileSync(f, JSON.stringify({ lastCheck: 42, latest: '9.9.9' })); + expect(readCache(f)).toEqual({ lastCheck: 42, latest: '9.9.9' }); + }); + + it('returns null — never throws — for missing, corrupt or wrong-shaped files', () => { + expect(readCache(path.join(tmp, 'nope.json'))).toBeNull(); + + const corrupt = path.join(tmp, 'corrupt.json'); + fs.writeFileSync(corrupt, '{not json'); + expect(readCache(corrupt)).toBeNull(); + + const wrong = path.join(tmp, 'wrong.json'); + fs.writeFileSync(wrong, JSON.stringify({ latest: 5 })); + expect(readCache(wrong)).toBeNull(); + }); +}); + +describe('isStale', () => { + it('treats an absent cache as stale, and expires at the TTL boundary', () => { + expect(isStale(null, 1_000_000)).toBe(true); + expect(isStale({ lastCheck: 1000, latest: '1.0.0' }, 1000 + TTL_MS - 1)).toBe(false); + expect(isStale({ lastCheck: 1000, latest: '1.0.0' }, 1000 + TTL_MS)).toBe(true); + }); +}); + +describe('isSilenced', () => { + it('is silent in CI, when opted out, and when stderr is not a TTY', () => { + expect(isSilenced({ CI: '1' }, true)).toBe(true); + expect(isSilenced({ NO_UPDATE_NOTIFIER: '1' }, true)).toBe(true); + expect(isSilenced({ TREK_SDK_NO_UPDATE_CHECK: '1' }, true)).toBe(true); + expect(isSilenced({}, false)).toBe(true); + expect(isSilenced({}, true)).toBe(false); + }); +}); + +describe('maybeNotify', () => { + /** A human at a terminal, with $XDG_CACHE_HOME pointed at the temp dir. */ + function asHuman(): ReturnType { + vi.stubEnv('CI', ''); + vi.stubEnv('NO_UPDATE_NOTIFIER', ''); + vi.stubEnv('TREK_SDK_NO_UPDATE_CHECK', ''); + vi.stubEnv('XDG_CACHE_HOME', tmp); + vi.spyOn(process.stderr, 'isTTY', 'get').mockReturnValue(true); + return vi.spyOn(process.stderr, 'write').mockReturnValue(true); + } + + function seedCache(cache: { lastCheck: number; latest: string }): void { + const f = path.join(tmp, PACKAGE_NAME, 'update-check.json'); + fs.mkdirSync(path.dirname(f), { recursive: true }); + fs.writeFileSync(f, JSON.stringify(cache)); + } + + it('prints the notice when a fresh cache holds a newer version', () => { + const write = asHuman(); + seedCache({ lastCheck: Date.now(), latest: '999.0.0' }); + + maybeNotify(); + + expect(write).toHaveBeenCalledOnce(); + expect(String(write.mock.calls[0][0])).toContain(`999.0.0 · npm i -g ${PACKAGE_NAME}@latest`); + expect(spawn).not.toHaveBeenCalled(); // fresh cache — no refresh + }); + + it('prints nothing when the cached version is the one we are running', () => { + const write = asHuman(); + seedCache({ lastCheck: Date.now(), latest: sdkVersion() }); + + maybeNotify(); + + expect(write).not.toHaveBeenCalled(); + }); + + it('is silent on a cold cache, but spawns the background refresh for next time', () => { + const write = asHuman(); + + maybeNotify(); + + expect(write).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledOnce(); + const [, args, opts] = spawn.mock.calls[0] as unknown as [string, string[], Record]; + expect(args[0]).toMatch(/update-check-worker\.js$/); + expect(opts).toMatchObject({ detached: true, stdio: 'ignore' }); + }); + + it('refreshes a stale cache while still printing what it knows', () => { + const write = asHuman(); + seedCache({ lastCheck: Date.now() - TTL_MS - 1, latest: '999.0.0' }); + + maybeNotify(); + + expect(write).toHaveBeenCalledOnce(); + expect(spawn).toHaveBeenCalledOnce(); + }); + + it('does nothing at all — no print, no spawn — when silenced', () => { + const write = asHuman(); + vi.stubEnv('CI', '1'); + seedCache({ lastCheck: Date.now(), latest: '999.0.0' }); + + maybeNotify(); + + expect(write).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('survives a corrupt cache without throwing', () => { + const write = asHuman(); + const f = path.join(tmp, PACKAGE_NAME, 'update-check.json'); + fs.mkdirSync(path.dirname(f), { recursive: true }); + fs.writeFileSync(f, 'not json at all'); + + expect(() => maybeNotify()).not.toThrow(); + expect(write).not.toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledOnce(); // unreadable == no news == refresh + }); +}); + +describe('worker', () => { + it('returns the latest dist-tag version', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '2.0.0' })))); + await expect(fetchLatest()).resolves.toBe('2.0.0'); + }); + + it('returns null on a non-200, a malformed body, or a network failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 404 }))); + await expect(fetchLatest()).resolves.toBeNull(); + + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: 42 })))); + await expect(fetchLatest()).resolves.toBeNull(); + + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ENOTFOUND'); })); + await expect(fetchLatest()).resolves.toBeNull(); + }); + + it('writes the cache only when the fetch succeeded', async () => { + const f = path.join(tmp, 'sub', 'update-check.json'); + + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + await refresh(f, 123); + expect(fs.existsSync(f)).toBe(false); + + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ version: '3.1.0' })))); + await refresh(f, 123); + expect(readCache(f)).toEqual({ lastCheck: 123, latest: '3.1.0' }); + }); + + it('gives up silently when the cache is unwritable', () => { + expect(() => writeCache('/proc/definitely/not/writable.json', { lastCheck: 1, latest: '1.0.0' })).not.toThrow(); + }); +});