wyvrnpm 2.13.12 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/bin/wyvrnpm.js +4 -0
- package/claude/skills/wyvrnpm.skill +0 -0
- package/cmake/functions.cmake +11 -5
- package/package.json +1 -1
- package/src/build/index.js +51 -4
- package/src/commands/install.js +90 -15
- package/src/commands/show.js +26 -0
- package/src/config.js +6 -1
- package/src/manifest.js +142 -6
- package/src/update-check/fetcher.js +66 -0
- package/src/update-check/index.js +145 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A simple, private C++ package manager that works with any static file hosting pr
|
|
|
4
4
|
|
|
5
5
|
There is no central registry. You control where packages are hosted.
|
|
6
6
|
|
|
7
|
-
> **README version: 2.
|
|
7
|
+
> **README version: 2.15.0** — **New:** `buildDependencies` block + `whenOption` gating — closes EVALUATION.md **F3** (`tool_requires` + `test_requires`). Recipes can now declare author-private deps (the asio / websocketpp / boost-internals case — header-only libs that compile into a `StaticLib` and aren't exposed by its public API; the protoc / flex / bison case — executables the build invokes at configure time) in a sibling block to `dependencies`. Buildable at the author's root and at source-build of a transitive, but **never walked transitively when a downstream consumer pulls the prebuilt binary** — they just get the lean runtime closure. `whenOption: "websocket"` (or `"api=legacy"`) gates a dep on a recipe-declared option value, evaluated at the seam where the package's effective options are known (root install + source-build). The combination also subsumes Conan's `test_requires`: declare `options.tests` and list gtest in `buildDependencies` with `whenOption: "tests"` — default `tests=false` skips it, `-o MyPkg:tests=true` opts in for that build, consumers of the prebuilt binary never see it either way. Mirrors Conan 2.0's `requires` / `tool_requires` split. Backward compat is automatic — old wyvrnpm clients ignore the unknown top-level field. `wyvrnpm show` surfaces both blocks per-profile so a publisher can verify what shipped. (Cross-compilation case — tool requires resolved under a *build* profile distinct from the *host* profile — stays open as F4, deferred pending a concrete cross-compilation user.) **New:** npm self-update notifier — when a newer `wyvrnpm` is on the npm registry, the next interactive run prints a one-line `[wyvrn] warn wyvrnpm <old> → <new> available` banner on stderr. The fetch happens in a detached background process so the user's command never blocks on the network; the banner appears on the *following* invocation after the cache (`%LOCALAPPDATA%\wyvrnpm\update-check.json`) is populated. Auto-suppressed in CI (`$CI`), non-TTY runs, and `--format json` mode; opt out via `WYVRNPM_NO_UPDATE_CHECK=1` or `--no-update-check`. Hand-rolled (~120 lines, zero new deps) instead of pulling in `update-notifier` and its 10+ transitives. Closes the gap where users on stale CLIs silently missed bug fixes (the `2.8.4` CloudFront cache-split fix and the `2.12.2` install-failure exit code, in particular). **Resolver caches `@latest` lookups** within a single `wyvrnpm install` — when a package is referenced as `@latest` from multiple parents in the dep graph (or already resolved earlier via a range), the registry round-trip is reused instead of re-probing `latest.json` per encounter. Eliminates ~12 redundant HTTPS round-trips on a typical 16-dep graph; visible win on resolution wall-time when the registry sits behind CloudFront. Closes the parallel gap to `manifestCache` / `publishedCache` — all three S3-backed helpers in `resolve.js` now memoise per-name within one install. **Per-config artefact slicing.** Recipes opt in via `build.publishPerConfig: true`; `wyvrnpm publish` then emits one `wyvrn-<Config>.zip` per declared CMake config under one `profileHash` URL instead of a single fat zip. Consumers fetch only the configs they need: `wyvrnpm install --request-configs Release` on a CI runner pulls 1.93 GB of gRPC instead of 14.96 GB across all four configs (~87% saving). Driven by CMake's own `*Targets-<config>.cmake` exports (`IMPORTED_LOCATION_<CONFIG>`) so both the MSVC convention (`lib/<Config>/foo.lib`) and the postfix convention (`lib/foo.lib` Release vs `lib/food.lib` Debug, via `CMAKE_DEBUG_POSTFIX`) classify correctly. `wyvrn.local.json` gains `requestConfigs` (global) + `depRequestConfigs` (per-dep override — "all four configs of fmt, but MinSizeRel of grpc"). Attestation v2 binds an `artefactSha256: { config → sha }` map; one signature covers every slice. `wyvrnpm publish --dry-run` materialises the would-be-uploaded artefacts to `<build>/dry-run-publish/` for inspection (override with `--dry-run-out`), and tolerates expired AWS credentials (the existence check degrades to `wouldOverwrite: null` instead of crashing). Highlights since 2.8.3: S1 artefact signing — Ed25519 detached signatures over a canonical attestation, verified against a per-registry `.trust/keys.json`; `wyvrnpm key gen / show-pub`, `wyvrnpm trust list / refresh`, `wyvrnpm configure signing set-default / show / unset`; `--require-signatures` on install, `--no-sign` / `--signing-key-env` / `--signing-key-file` on publish; pure-Node `crypto` (no external tooling, identical on Windows / Linux / macOS). Closes S1 + S3. Profile's `compiler.cppstd` is now authoritative for the toolchain — `CMAKE_CXX_STANDARD` is set unconditionally before `project()` and `cpp.cmake` no longer touches it post-`project()`, so a `-DCMAKE_CXX_STANDARD=23` on the cmake CLI cannot defeat a `cppstd=20` profile; `wyvrnpm install` exits non-zero on partial failure (sha256-mismatch, extract-failed, no-exact-match, source-build-failed, not-found) and prints a per-dep failure summary instead of misreporting "Done — N installed"; streaming publish via `archiver` (handles >2 GiB artefacts that `adm-zip` choked on — e.g. gRPC's `RelWithDebInfo` static libs); `--build-dir <path>` + `wyvrn.local.json:binaryDirRoot` to sidestep `build/` case-insensitive-FS collisions on repos with a Bazel/Buck `BUILD` file at the root (gRPC, …); `wyvrnpm version` to read / set / partially update the top-level `version` field from CI pipelines (`wyvrnpm version --build "$BUILD_ID"`); `wyvrnpm bootstrap <git-url>` to scaffold a first-draft `wyvrn.json` for OSS C++ libraries (fmt, spdlog, zlib, …) with cookbook-driven defaults; `wyvrnpm publish --dry-run` (npm-style preview of the upload plan, plus `wouldOverwrite` flag in `--format json`); `wyvrnpm build --config Debug,Release` / `--all-configs` to build multiple configurations from a single configure; resolver hardened against the CloudFront brotli-vs-identity cache split (resolved a class of bugs where `show` listed a freshly-published version that `install` claimed didn't exist). Full list in `claude/EVALUATION.md`.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -22,6 +22,16 @@ For S3 publishing, also install the AWS SDK:
|
|
|
22
22
|
npm install -g @aws-sdk/client-s3 @aws-sdk/credential-providers
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
### Update notifier
|
|
26
|
+
|
|
27
|
+
When a newer `wyvrnpm` is published to npm, the next interactive run prints a single-line banner on stderr:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
[wyvrn] warn wyvrnpm 2.13.12 → 2.14.0 available. Run `npm i -g wyvrnpm` to update.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The check is non-blocking — the registry round-trip happens in a detached background process, so your command never waits on the network. The banner appears on the *following* invocation. Auto-suppressed in CI (`$CI` set), non-TTY runs (redirected stdout, pipelines), and `--format json` mode. Opt out manually via `WYVRNPM_NO_UPDATE_CHECK=1` or `--no-update-check`.
|
|
34
|
+
|
|
25
35
|
---
|
|
26
36
|
|
|
27
37
|
## Quick Start
|
package/bin/wyvrnpm.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
// npm self-update notifier — synchronous, non-blocking, suppressed in CI /
|
|
6
|
+
// non-TTY / JSON mode. Runs before yargs so it gets first crack at argv.
|
|
7
|
+
require('../src/update-check').maybeNotify();
|
|
8
|
+
|
|
5
9
|
const yargs = require('yargs');
|
|
6
10
|
const init = require('../src/commands/init');
|
|
7
11
|
const add = require('../src/commands/add');
|
|
Binary file
|
package/cmake/functions.cmake
CHANGED
|
@@ -192,28 +192,34 @@ function(SETUP_LIBRARY target type)
|
|
|
192
192
|
|
|
193
193
|
endfunction()
|
|
194
194
|
|
|
195
|
-
# ── SETUP_EXE(target [EXCLUDE pat...] [extra_sources...])
|
|
195
|
+
# ── SETUP_EXE(target [CONSOLE] [EXCLUDE pat...] [extra_sources...]) ───────────
|
|
196
196
|
# Creates an executable target from all sources in CMAKE_CURRENT_SOURCE_DIR,
|
|
197
197
|
# with platform-specific HAL sources included automatically.
|
|
198
|
-
# On Windows and Xbox the WIN32 subsystem flag is set automatically
|
|
198
|
+
# On Windows and Xbox the WIN32 subsystem flag is set automatically; pass
|
|
199
|
+
# CONSOLE to opt out and produce a console-subsystem binary instead.
|
|
199
200
|
#
|
|
200
201
|
# Arguments:
|
|
201
202
|
# target CMake target name
|
|
203
|
+
# CONSOLE (optional flag) Skip the WIN32 subsystem flag on Windows/Xbox
|
|
204
|
+
# so the binary is built as a console application. Equivalent
|
|
205
|
+
# to passing /SUBSYSTEM:CONSOLE under MSVC by hand. No-op
|
|
206
|
+
# outside Windows/Xbox.
|
|
202
207
|
# EXCLUDE (optional) One or more regex patterns; matched files are
|
|
203
208
|
# excluded from the auto-collected source list
|
|
204
209
|
# extra_sources (optional) Additional source files to append (positional,
|
|
205
210
|
# or via the SOURCES keyword)
|
|
206
211
|
#
|
|
207
212
|
# Example:
|
|
208
|
-
# SETUP_EXE(ChromaApp)
|
|
213
|
+
# SETUP_EXE(ChromaApp) # GUI subsystem on Windows
|
|
214
|
+
# SETUP_EXE(ChromaTool CONSOLE) # console subsystem on Windows
|
|
209
215
|
# SETUP_EXE(ChromaTool ${TOOL_GENERATED_SOURCES})
|
|
210
216
|
# SETUP_EXE(ChromaTool EXCLUDE ".*test_stub.*" SOURCES ${EXTRA})
|
|
211
217
|
function(SETUP_EXE target)
|
|
212
|
-
cmake_parse_arguments(_SETUP "" "" "EXCLUDE;SOURCES" ${ARGN})
|
|
218
|
+
cmake_parse_arguments(_SETUP "CONSOLE" "" "EXCLUDE;SOURCES" ${ARGN})
|
|
213
219
|
_wyvrn_collect_sources(_sources ${_SETUP_EXCLUDE})
|
|
214
220
|
list(APPEND _sources ${_SETUP_SOURCES} ${_SETUP_UNPARSED_ARGUMENTS})
|
|
215
221
|
|
|
216
|
-
if(WYVRN_PLATFORM_WINDOWS OR WYVRN_PLATFORM_XBOX)
|
|
222
|
+
if((WYVRN_PLATFORM_WINDOWS OR WYVRN_PLATFORM_XBOX) AND NOT _SETUP_CONSOLE)
|
|
217
223
|
add_executable(${target} WIN32 ${_sources})
|
|
218
224
|
else()
|
|
219
225
|
add_executable(${target} ${_sources})
|
package/package.json
CHANGED
package/src/build/index.js
CHANGED
|
@@ -9,7 +9,12 @@ const { normalizeRecipe, hasBuildRecipe } = require('./recipe');
|
|
|
9
9
|
const { configureBuildInstall } = require('./cmake');
|
|
10
10
|
const { getBuildPaths, hasValidArtefact } = require('./cache');
|
|
11
11
|
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
normalizeDependencies,
|
|
14
|
+
normalizeManifestDeps,
|
|
15
|
+
validateWhenOption,
|
|
16
|
+
evaluateWhenOption,
|
|
17
|
+
} = require('../manifest');
|
|
13
18
|
const { resolveDependencies } = require('../resolve');
|
|
14
19
|
const { hashProfile, mergeProfile, sha256Of } = require('../profile');
|
|
15
20
|
const { generateToolchain } = require('../toolchain');
|
|
@@ -188,9 +193,51 @@ async function buildFromSource(args) {
|
|
|
188
193
|
// eslint-disable-next-line global-require
|
|
189
194
|
const { downloadDependencies } = require('../download');
|
|
190
195
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
);
|
|
196
|
+
// Read both runtime and author-private buildDependencies from the cloned
|
|
197
|
+
// source. The source-build sandbox needs both to compile (e.g. asio +
|
|
198
|
+
// websocketpp headers when building WyvrnIPC); the fetched buildDeps
|
|
199
|
+
// stay scoped to this sandbox's `wyvrn_internal/` and never propagate
|
|
200
|
+
// up to the outer install's lockfile or wyvrn_deps.cmake.
|
|
201
|
+
let depsByKind;
|
|
202
|
+
try {
|
|
203
|
+
depsByKind = normalizeManifestDeps(clonedManifest);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`${name}@${version}: ${err.message} (in cloned wyvrn.json at ${gitSha.slice(0, 12)})`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const { runtime: runtimeDeps, build: buildDeps } = depsByKind;
|
|
210
|
+
|
|
211
|
+
// Validate + filter whenOption against the source-build's effective
|
|
212
|
+
// options (passed in from the consumer's resolution as `args.options`).
|
|
213
|
+
const declarationForFilter = clonedManifest.options
|
|
214
|
+
? normalizeOptionsDeclaration(clonedManifest.options, `${name}@${version}`)
|
|
215
|
+
: null;
|
|
216
|
+
for (const [depName, d] of Object.entries(runtimeDeps)) {
|
|
217
|
+
if (d.whenOption) {
|
|
218
|
+
validateWhenOption(d.whenOption, declarationForFilter, `${name}.dependencies.${depName}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const [depName, d] of Object.entries(buildDeps)) {
|
|
222
|
+
if (d.whenOption) {
|
|
223
|
+
validateWhenOption(d.whenOption, declarationForFilter, `${name}.buildDependencies.${depName}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const filterByWhenOption = (deps) => {
|
|
227
|
+
const out = {};
|
|
228
|
+
for (const [depName, d] of Object.entries(deps)) {
|
|
229
|
+
if (!d.whenOption || evaluateWhenOption(d.whenOption, options)) {
|
|
230
|
+
out[depName] = d;
|
|
231
|
+
} else {
|
|
232
|
+
log.info(` ${depName}: skipped (whenOption "${d.whenOption}" not satisfied)`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
};
|
|
237
|
+
const rawPackageDeps = {
|
|
238
|
+
...filterByWhenOption(runtimeDeps),
|
|
239
|
+
...filterByWhenOption(buildDeps),
|
|
240
|
+
};
|
|
194
241
|
const depsForResolution = Object.fromEntries(
|
|
195
242
|
Object.entries(rawPackageDeps).map(([n, d]) => [n, d.version]),
|
|
196
243
|
);
|
package/src/commands/install.js
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
normalizeDependencies,
|
|
8
|
+
normalizeManifestDeps,
|
|
9
|
+
validateWhenOption,
|
|
10
|
+
evaluateWhenOption,
|
|
11
|
+
} = require('../manifest');
|
|
7
12
|
const { resolveDependencies } = require('../resolve');
|
|
8
13
|
const { downloadDependencies } = require('../download');
|
|
9
14
|
const { resolveSigningContext } = require('../signing/resolve-context');
|
|
@@ -134,8 +139,76 @@ async function install(argv) {
|
|
|
134
139
|
// and caches the result for the rest of this invocation.
|
|
135
140
|
const manifest = ctx.manifest;
|
|
136
141
|
|
|
137
|
-
//
|
|
138
|
-
|
|
142
|
+
// Pull both runtime `dependencies` and author-private `buildDependencies`.
|
|
143
|
+
// The latter feeds the root install (the author needs them to compile)
|
|
144
|
+
// and source-builds of this package as a transitive, but is never walked
|
|
145
|
+
// when a downstream consumer pulls the prebuilt binary — see resolve.js.
|
|
146
|
+
let depsByKind;
|
|
147
|
+
try {
|
|
148
|
+
depsByKind = normalizeManifestDeps(manifest);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log.error(err.message);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const { runtime: runtimeDeps, build: buildDeps } = depsByKind;
|
|
154
|
+
|
|
155
|
+
// Resolve the project's own effective options early. Needed for
|
|
156
|
+
// `whenOption` filtering of root deps, then reused below for the
|
|
157
|
+
// CMake preset's `${options.*}` substitution.
|
|
158
|
+
let projectDeclaration = null;
|
|
159
|
+
let projectEffectiveOptions = null;
|
|
160
|
+
try {
|
|
161
|
+
projectDeclaration = manifest.options
|
|
162
|
+
? normalizeOptionsDeclaration(manifest.options, manifest.name)
|
|
163
|
+
: null;
|
|
164
|
+
projectEffectiveOptions = resolveEffectiveOptions(
|
|
165
|
+
projectDeclaration, {}, cliOptionsByPkg[manifest.name] ?? {}, manifest.name,
|
|
166
|
+
);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
log.error(err.message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Validate every `whenOption` against the project's declared options
|
|
173
|
+
// before resolving anything — surface authoring errors fast.
|
|
174
|
+
try {
|
|
175
|
+
for (const [name, d] of Object.entries(runtimeDeps)) {
|
|
176
|
+
if (d.whenOption) {
|
|
177
|
+
validateWhenOption(d.whenOption, projectDeclaration, `${manifest.name}.dependencies.${name}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const [name, d] of Object.entries(buildDeps)) {
|
|
181
|
+
if (d.whenOption) {
|
|
182
|
+
validateWhenOption(d.whenOption, projectDeclaration, `${manifest.name}.buildDependencies.${name}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
log.error(err.message);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Filter both blocks by whenOption. A dep whose condition is false is
|
|
191
|
+
// omitted from the resolver's queue — the consumer never pays the
|
|
192
|
+
// download / find_package cost, and the entry stays out of wyvrn.lock.
|
|
193
|
+
const filterByWhenOption = (deps, label) => {
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const [name, d] of Object.entries(deps)) {
|
|
196
|
+
if (!d.whenOption || evaluateWhenOption(d.whenOption, projectEffectiveOptions)) {
|
|
197
|
+
out[name] = d;
|
|
198
|
+
} else {
|
|
199
|
+
log.info(`${name}: ${label} skipped (whenOption "${d.whenOption}" not satisfied)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
};
|
|
204
|
+
const activeRuntimeDeps = filterByWhenOption(runtimeDeps, 'runtime');
|
|
205
|
+
const activeBuildDeps = filterByWhenOption(buildDeps, 'buildDep');
|
|
206
|
+
|
|
207
|
+
// Combined view for resolver + downstream lookups. `buildDepNames`
|
|
208
|
+
// is the only side-channel — used to annotate lockfile entries with
|
|
209
|
+
// `kind: "build"` and to label log lines.
|
|
210
|
+
const normalizedDeps = { ...activeRuntimeDeps, ...activeBuildDeps };
|
|
211
|
+
const buildDepNames = new Set(Object.keys(activeBuildDeps));
|
|
139
212
|
|
|
140
213
|
// For resolution, only pass name → version (settings don't affect resolution)
|
|
141
214
|
const depsForResolution = Object.fromEntries(
|
|
@@ -484,7 +557,15 @@ async function install(argv) {
|
|
|
484
557
|
// ── Write v2 lock file ────────────────────────────────────────────────────
|
|
485
558
|
const sortedPackages = {};
|
|
486
559
|
for (const key of [...resolvedDeps.keys()].sort()) {
|
|
487
|
-
|
|
560
|
+
const entry = lockEntries?.get(key) ?? { version: resolvedDeps.get(key) };
|
|
561
|
+
// Annotate root-level buildDependencies so `wyvrnpm show` / audits
|
|
562
|
+
// can distinguish runtime deps from author-private build deps.
|
|
563
|
+
// Runtime deps stay un-annotated so pre-feature lockfiles round-trip
|
|
564
|
+
// byte-identically.
|
|
565
|
+
if (buildDepNames.has(key) && entry && typeof entry === 'object' && !entry.kind) {
|
|
566
|
+
entry.kind = 'build';
|
|
567
|
+
}
|
|
568
|
+
sortedPackages[key] = entry;
|
|
488
569
|
}
|
|
489
570
|
|
|
490
571
|
const lockData = {
|
|
@@ -533,17 +614,11 @@ async function install(argv) {
|
|
|
533
614
|
let presetConfigs = null;
|
|
534
615
|
if (hasBuildRecipe(manifest)) {
|
|
535
616
|
try {
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
? normalizeOptionsDeclaration(manifest.options, manifest.name)
|
|
542
|
-
: null;
|
|
543
|
-
const projectCliOverrides = cliOptionsByPkg[manifest.name] ?? {};
|
|
544
|
-
const projectEffectiveOptions = resolveEffectiveOptions(
|
|
545
|
-
projectDeclaration, {}, projectCliOverrides, manifest.name,
|
|
546
|
-
);
|
|
617
|
+
// `projectEffectiveOptions` was already resolved at the top of the
|
|
618
|
+
// function (needed for whenOption filtering). Reuse it here for
|
|
619
|
+
// `${options.*}` substitution in `build.configure` / `build.buildArgs`
|
|
620
|
+
// — normalizeRecipe enforces that template references must match a
|
|
621
|
+
// declared option.
|
|
547
622
|
const recipe = normalizeRecipe(manifest.build, projectEffectiveOptions, manifest);
|
|
548
623
|
|
|
549
624
|
if (recipe.generators.length > 0) {
|
package/src/commands/show.js
CHANGED
|
@@ -170,6 +170,17 @@ async function show(argv) {
|
|
|
170
170
|
if (meta?.options && Object.keys(meta.options).length > 0) {
|
|
171
171
|
console.log(` declared: ${formatDeclaredOptions(meta.options)}`);
|
|
172
172
|
}
|
|
173
|
+
// Author-private buildDependencies (F-buildDeps). Surface both
|
|
174
|
+
// blocks so a publisher can confirm what shipped — runtime
|
|
175
|
+
// deps propagate to consumers, buildDeps don't. `whenOption`
|
|
176
|
+
// entries are annotated inline so it's obvious which deps are
|
|
177
|
+
// gated on an option value.
|
|
178
|
+
if (meta?.dependencies && Object.keys(meta.dependencies).length > 0) {
|
|
179
|
+
console.log(` deps : ${formatDeps(meta.dependencies)}`);
|
|
180
|
+
}
|
|
181
|
+
if (meta?.buildDependencies && Object.keys(meta.buildDependencies).length > 0) {
|
|
182
|
+
console.log(` buildDeps: ${formatDeps(meta.buildDependencies)}`);
|
|
183
|
+
}
|
|
173
184
|
// PLAN-CONF.md §4.6 — surface publisher's publishedConf.
|
|
174
185
|
// Informational only; absent for pre-2.6.0 author publishes.
|
|
175
186
|
if (meta?.publishedConf && Object.keys(meta.publishedConf).length > 0) {
|
|
@@ -193,6 +204,8 @@ async function show(argv) {
|
|
|
193
204
|
uploadedBy: meta?.uploadedBy ?? null,
|
|
194
205
|
compatibility: meta?.compatibility ?? null,
|
|
195
206
|
declaredOptions: meta?.options ?? null,
|
|
207
|
+
dependencies: meta?.dependencies ?? null,
|
|
208
|
+
buildDependencies: meta?.buildDependencies ?? null,
|
|
196
209
|
publishedConf: meta?.publishedConf ?? null,
|
|
197
210
|
signing,
|
|
198
211
|
});
|
|
@@ -271,5 +284,18 @@ function formatDeclaredOptions(declared) {
|
|
|
271
284
|
.join('; ');
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
function formatDeps(deps) {
|
|
288
|
+
return Object.entries(deps)
|
|
289
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
290
|
+
.map(([name, value]) => {
|
|
291
|
+
const ver = typeof value === 'string' ? value : (value?.version ?? '?');
|
|
292
|
+
const when = (typeof value === 'object' && value?.whenOption)
|
|
293
|
+
? ` [when ${value.whenOption}]`
|
|
294
|
+
: '';
|
|
295
|
+
return `${name}@${ver}${when}`;
|
|
296
|
+
})
|
|
297
|
+
.join(', ');
|
|
298
|
+
}
|
|
299
|
+
|
|
274
300
|
module.exports = show;
|
|
275
301
|
module.exports.parseSpec = parseSpec;
|
package/src/config.js
CHANGED
|
@@ -23,6 +23,11 @@ function getConfigPath() {
|
|
|
23
23
|
return path.join(getConfigDir(), 'config.json');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Full path to update-check.json (npm self-update notifier cache). */
|
|
27
|
+
function getUpdateCheckPath() {
|
|
28
|
+
return path.join(getConfigDir(), 'update-check.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* Read and parse config.json.
|
|
28
33
|
* Returns a default empty config if the file doesn't exist yet.
|
|
@@ -76,7 +81,7 @@ function writeConfig(config) {
|
|
|
76
81
|
fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
77
82
|
}
|
|
78
83
|
|
|
79
|
-
module.exports = { getConfigDir, getConfigPath, readConfig, writeConfig };
|
|
84
|
+
module.exports = { getConfigDir, getConfigPath, getUpdateCheckPath, readConfig, writeConfig };
|
|
80
85
|
|
|
81
86
|
/**
|
|
82
87
|
* @typedef {{ name: string, url: string, profile?: string, token?: string, signing?: SigningConfig, trustRootPub?: string }} SourceEntry
|
package/src/manifest.js
CHANGED
|
@@ -54,21 +54,24 @@ function defaultManifest(name) {
|
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
56
|
* Normalise the raw `dependencies` field from a manifest into a consistent
|
|
57
|
-
* `{ name → { version, settings, options, source? } }` map,
|
|
58
|
-
* input format:
|
|
57
|
+
* `{ name → { version, settings, options, source?, whenOption? } }` map,
|
|
58
|
+
* regardless of input format:
|
|
59
59
|
*
|
|
60
|
-
* • String value : "1.0.0"
|
|
61
|
-
* • Object value : { version, settings?, options?, source? } → kept (missing keys default to {})
|
|
62
|
-
* • Array format : [{ Name, Version }]
|
|
60
|
+
* • String value : "1.0.0" → { version: "1.0.0", settings: {}, options: {} }
|
|
61
|
+
* • Object value : { version, settings?, options?, source?, whenOption? } → kept (missing keys default to {})
|
|
62
|
+
* • Array format : [{ Name, Version }] → converted (no settings/options)
|
|
63
63
|
*
|
|
64
64
|
* `settings` merges on top of the active profile for this dep only.
|
|
65
65
|
* `options` maps to the recipe's declared options; validated against
|
|
66
66
|
* the recipe's `allowed` list at resolve time (see src/options.js).
|
|
67
67
|
* `source` (EVALUATION.md S4) pins the dep to a named install source
|
|
68
68
|
* (e.g. `"primary-s3"`); absent = fan out across all configured sources.
|
|
69
|
+
* `whenOption` (`"name"` or `"name=value"`) gates the dep on a recipe-
|
|
70
|
+
* declared option — see `evaluateWhenOption`. Validated against the
|
|
71
|
+
* package's `options` block by `validateWhenOption` at install time.
|
|
69
72
|
*
|
|
70
73
|
* @param {object|Array|undefined} rawDeps
|
|
71
|
-
* @returns {Record<string, { version: string, settings: object, options: object, source?: string }>}
|
|
74
|
+
* @returns {Record<string, { version: string, settings: object, options: object, source?: string, whenOption?: string }>}
|
|
72
75
|
*/
|
|
73
76
|
function normalizeDependencies(rawDeps) {
|
|
74
77
|
if (!rawDeps || typeof rawDeps !== 'object') return {};
|
|
@@ -98,12 +101,141 @@ function normalizeDependencies(rawDeps) {
|
|
|
98
101
|
if (typeof value.source === 'string' && value.source.length > 0) {
|
|
99
102
|
entry.source = value.source;
|
|
100
103
|
}
|
|
104
|
+
// Option-gated dep (F-buildDeps). Preserved verbatim here; structural
|
|
105
|
+
// validation lives in `validateWhenOption` because that's where we
|
|
106
|
+
// also know the package's declared options.
|
|
107
|
+
if (typeof value.whenOption === 'string' && value.whenOption.length > 0) {
|
|
108
|
+
entry.whenOption = value.whenOption;
|
|
109
|
+
}
|
|
101
110
|
result[n] = entry;
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
return result;
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Pull both runtime `dependencies` and author-private `buildDependencies`
|
|
118
|
+
* out of a manifest, normalised through `normalizeDependencies`. A package
|
|
119
|
+
* name MUST NOT appear in both blocks — that's a hard authoring error
|
|
120
|
+
* (no sensible meaning for "both runtime and build-only").
|
|
121
|
+
*
|
|
122
|
+
* `buildDependencies` are deps the package needs to compile itself but
|
|
123
|
+
* that consumers of the prebuilt binary do not need transitively. The
|
|
124
|
+
* resolver walks them at the root (and at source-build of a transitive
|
|
125
|
+
* dep) but never when reading an upstream manifest's transitive graph
|
|
126
|
+
* — see [src/resolve.js](resolve.js). Backward compat with old wyvrnpm
|
|
127
|
+
* clients is automatic: they only read `dependencies`.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} manifest - parsed wyvrn.json
|
|
130
|
+
* @returns {{
|
|
131
|
+
* runtime: Record<string, object>,
|
|
132
|
+
* build: Record<string, object>,
|
|
133
|
+
* }}
|
|
134
|
+
*/
|
|
135
|
+
function normalizeManifestDeps(manifest) {
|
|
136
|
+
const runtime = normalizeDependencies(
|
|
137
|
+
manifest?.dependencies ?? manifest?.Dependencies,
|
|
138
|
+
);
|
|
139
|
+
const build = normalizeDependencies(manifest?.buildDependencies);
|
|
140
|
+
for (const name of Object.keys(build)) {
|
|
141
|
+
if (name in runtime) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`"${name}" appears in both \`dependencies\` and \`buildDependencies\` — ` +
|
|
144
|
+
`pick one. Runtime deps propagate to consumers; buildDependencies do not.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { runtime, build };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse a `whenOption` spec into `{ name, value }`. Bare `"foo"` is
|
|
153
|
+
* sugar for `"foo=true"`. Throws on malformed input. Pure structural
|
|
154
|
+
* parser — does not validate against a package's option declaration;
|
|
155
|
+
* use `validateWhenOption` for that.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} spec
|
|
158
|
+
* @returns {{ name: string, value: string|boolean }}
|
|
159
|
+
*/
|
|
160
|
+
function parseWhenOption(spec) {
|
|
161
|
+
if (typeof spec !== 'string' || spec.length === 0) {
|
|
162
|
+
throw new Error('whenOption must be a non-empty string');
|
|
163
|
+
}
|
|
164
|
+
const eqIdx = spec.indexOf('=');
|
|
165
|
+
if (eqIdx === -1) {
|
|
166
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(spec)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`whenOption "${spec}" is malformed — option name must match /^[a-z][a-z0-9_-]*$/`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return { name: spec, value: true };
|
|
172
|
+
}
|
|
173
|
+
const name = spec.slice(0, eqIdx);
|
|
174
|
+
const raw = spec.slice(eqIdx + 1);
|
|
175
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`whenOption "${spec}" is malformed — option name must match /^[a-z][a-z0-9_-]*$/`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (raw.length === 0) {
|
|
181
|
+
throw new Error(`whenOption "${spec}" has empty value after "="`);
|
|
182
|
+
}
|
|
183
|
+
const lc = raw.toLowerCase();
|
|
184
|
+
if (lc === 'true') return { name, value: true };
|
|
185
|
+
if (lc === 'false') return { name, value: false };
|
|
186
|
+
return { name, value: raw };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Validate a dep's `whenOption` against the owning package's normalised
|
|
191
|
+
* options declaration. Throws on:
|
|
192
|
+
* - structural error in the spec,
|
|
193
|
+
* - referenced option name not in the declaration,
|
|
194
|
+
* - compared value not in the option's `allowed` list.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} spec - the whenOption string
|
|
197
|
+
* @param {object|null} optionsDecl - normalised options (from src/options.js)
|
|
198
|
+
* @param {string} where - context for error messages ("WyvrnIPC.dependencies.websocketpp")
|
|
199
|
+
* @returns {{ name: string, value: any }}
|
|
200
|
+
*/
|
|
201
|
+
function validateWhenOption(spec, optionsDecl, where) {
|
|
202
|
+
const parsed = parseWhenOption(spec);
|
|
203
|
+
if (!optionsDecl || !(parsed.name in optionsDecl)) {
|
|
204
|
+
const declared = optionsDecl ? Object.keys(optionsDecl).join(', ') : '(none)';
|
|
205
|
+
throw new Error(
|
|
206
|
+
`whenOption on ${where} references option "${parsed.name}" which is not declared.\n` +
|
|
207
|
+
` declared options: ${declared}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const allowed = optionsDecl[parsed.name].allowed;
|
|
211
|
+
if (!allowed.includes(parsed.value)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`whenOption on ${where} compares ${parsed.name} = ${JSON.stringify(parsed.value)}, ` +
|
|
214
|
+
`which is not in allowed: ${JSON.stringify(allowed)}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return parsed;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Evaluate a `whenOption` spec against a resolved options map.
|
|
222
|
+
* Returns true (include the dep) when the option's resolved value
|
|
223
|
+
* matches the spec's compared value.
|
|
224
|
+
*
|
|
225
|
+
* Pre-validated: callers should run `validateWhenOption` once to
|
|
226
|
+
* surface authoring errors — `evaluateWhenOption` is the per-install
|
|
227
|
+
* runtime decision and assumes structurally valid input.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} spec
|
|
230
|
+
* @param {Record<string, any>|null} effectiveOptions
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
*/
|
|
233
|
+
function evaluateWhenOption(spec, effectiveOptions) {
|
|
234
|
+
const parsed = parseWhenOption(spec);
|
|
235
|
+
const actual = effectiveOptions?.[parsed.name];
|
|
236
|
+
return actual === parsed.value;
|
|
237
|
+
}
|
|
238
|
+
|
|
107
239
|
/**
|
|
108
240
|
* Reads and parses a wyvrn.json manifest from disk.
|
|
109
241
|
*
|
|
@@ -160,6 +292,10 @@ module.exports = {
|
|
|
160
292
|
VALID_KINDS,
|
|
161
293
|
defaultManifest,
|
|
162
294
|
normalizeDependencies,
|
|
295
|
+
normalizeManifestDeps,
|
|
296
|
+
parseWhenOption,
|
|
297
|
+
validateWhenOption,
|
|
298
|
+
evaluateWhenOption,
|
|
163
299
|
readManifest,
|
|
164
300
|
writeManifest,
|
|
165
301
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
// Detached background fetcher for the npm self-update notifier.
|
|
6
|
+
//
|
|
7
|
+
// Spawned by src/update-check/index.js when the update-check.json cache is
|
|
8
|
+
// stale or missing. Runs independently of the parent CLI process — the parent
|
|
9
|
+
// returns from `maybeNotify()` immediately; this script's exit timing is
|
|
10
|
+
// irrelevant to the user. Banner prints on the *next* invocation, after the
|
|
11
|
+
// cache file has been populated.
|
|
12
|
+
//
|
|
13
|
+
// Hard rules:
|
|
14
|
+
// - swallow every error; never exit non-zero (parent ignores us anyway, but
|
|
15
|
+
// a non-zero exit could surface in `ps` / job-control noise)
|
|
16
|
+
// - 10 s timeout — slow networks must not leave a zombie node process
|
|
17
|
+
// - atomic write (tmp + rename) so a half-written file never trips the
|
|
18
|
+
// parent's JSON.parse
|
|
19
|
+
//
|
|
20
|
+
// Test hooks (undocumented, internal-only):
|
|
21
|
+
// WYVRNPM_UPDATE_CHECK_URL — override the registry URL
|
|
22
|
+
// WYVRNPM_UPDATE_CHECK_PATH — override the cache file path
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const { getUpdateCheckPath } = require('../config');
|
|
28
|
+
const { wyvrnFetch } = require('../http-fetch');
|
|
29
|
+
|
|
30
|
+
const DEFAULT_URL = 'https://registry.npmjs.org/wyvrnpm/latest';
|
|
31
|
+
const TIMEOUT_MS = 10_000;
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
const url = process.env.WYVRNPM_UPDATE_CHECK_URL || DEFAULT_URL;
|
|
35
|
+
const cachePath = process.env.WYVRNPM_UPDATE_CHECK_PATH || getUpdateCheckPath();
|
|
36
|
+
|
|
37
|
+
const installed = require('../../package.json').version;
|
|
38
|
+
|
|
39
|
+
const ac = new AbortController();
|
|
40
|
+
const timeout = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
|
41
|
+
let body;
|
|
42
|
+
try {
|
|
43
|
+
const res = await wyvrnFetch(url, { signal: ac.signal });
|
|
44
|
+
if (!res.ok) return;
|
|
45
|
+
body = await res.json();
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timeout);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const latestVersion = body && typeof body.version === 'string' ? body.version : null;
|
|
51
|
+
if (!latestVersion) return;
|
|
52
|
+
|
|
53
|
+
const payload = {
|
|
54
|
+
lastCheckedAt: Date.now(),
|
|
55
|
+
latestVersion,
|
|
56
|
+
installedAtCheck: installed,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
60
|
+
const tmp = `${cachePath}.${process.pid}.tmp`;
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
62
|
+
fs.renameSync(tmp, cachePath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Swallow everything — a background fetch failure must never surface.
|
|
66
|
+
main().catch(() => {}).finally(() => process.exit(0));
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Lightweight npm self-update notifier for the wyvrnpm CLI.
|
|
4
|
+
//
|
|
5
|
+
// `maybeNotify()` is called once at the top of bin/wyvrnpm.js, before yargs
|
|
6
|
+
// parses argv. It runs synchronously and returns immediately — never blocks
|
|
7
|
+
// the user's command on a network call. Two paths:
|
|
8
|
+
//
|
|
9
|
+
// - cache fresh + newer version on npm → print one-line warning banner
|
|
10
|
+
// - cache stale or missing → spawn detached child to refresh,
|
|
11
|
+
// banner appears on the *next*
|
|
12
|
+
// invocation
|
|
13
|
+
//
|
|
14
|
+
// Suppression covers CI, non-TTY, --format=json, and explicit opt-out.
|
|
15
|
+
// Background failures (no network, npm rate-limit, corrupt cache) are
|
|
16
|
+
// silent — see fetcher.js. Hand-rolled to avoid the 10+ transitive deps
|
|
17
|
+
// the npm `update-notifier` package brings.
|
|
18
|
+
//
|
|
19
|
+
// Cache schema (under %LOCALAPPDATA%\wyvrnpm\update-check.json):
|
|
20
|
+
// { lastCheckedAt: <ms>, latestVersion: "<x.y.z>", installedAtCheck: "<x.y.z>" }
|
|
21
|
+
//
|
|
22
|
+
// `installedAtCheck` invalidates the cache when the user has manually
|
|
23
|
+
// upgraded since the last check — without it, a stale cache could keep
|
|
24
|
+
// nagging about a version the user already has.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const childProc = require('child_process');
|
|
29
|
+
|
|
30
|
+
const log = require('../logger');
|
|
31
|
+
const { getUpdateCheckPath } = require('../config');
|
|
32
|
+
const { compareVersions } = require('../version-range');
|
|
33
|
+
|
|
34
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
35
|
+
const FETCHER_SCRIPT = path.join(__dirname, 'fetcher.js');
|
|
36
|
+
const PKG_VERSION = require('../../package.json').version;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether to run the update check at all. Returns false in any
|
|
40
|
+
* non-interactive context — CI, redirected stdout, JSON output mode, or
|
|
41
|
+
* when the user has explicitly opted out.
|
|
42
|
+
*/
|
|
43
|
+
function shouldRunUpdateCheck(env = process.env, argv = process.argv, stdout = process.stdout) {
|
|
44
|
+
if (env.WYVRNPM_NO_UPDATE_CHECK) return false;
|
|
45
|
+
if (env.CI) return false;
|
|
46
|
+
if (!stdout || !stdout.isTTY) return false;
|
|
47
|
+
|
|
48
|
+
if (argv.includes('--no-update-check')) return false;
|
|
49
|
+
|
|
50
|
+
// `--format json` (space-separated) and `--format=json` (equals) — any
|
|
51
|
+
// subcommand running in JSON mode would only inject stderr noise into
|
|
52
|
+
// CI scripts that capture stdout.
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const a = argv[i];
|
|
55
|
+
if (a === '--format=json') return false;
|
|
56
|
+
if (a === '--format' && argv[i + 1] === 'json') return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Don't recurse — the detached fetcher must not re-trigger maybeNotify.
|
|
60
|
+
if (argv[1] && argv[1].endsWith(path.join('update-check', 'fetcher.js'))) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readCache(cachePath) {
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(cachePath, 'utf8');
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (typeof parsed.lastCheckedAt !== 'number') return null;
|
|
72
|
+
if (typeof parsed.latestVersion !== 'string') return null;
|
|
73
|
+
if (typeof parsed.installedAtCheck !== 'string') return null;
|
|
74
|
+
return parsed;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function spawnFetcher() {
|
|
81
|
+
try {
|
|
82
|
+
const child = childProc.spawn(
|
|
83
|
+
process.execPath,
|
|
84
|
+
[FETCHER_SCRIPT],
|
|
85
|
+
{ detached: true, stdio: 'ignore', windowsHide: true },
|
|
86
|
+
);
|
|
87
|
+
child.unref();
|
|
88
|
+
} catch {
|
|
89
|
+
// Spawn failures (sandbox, exec policy) — silent. Worst case the user
|
|
90
|
+
// never sees the banner; the CLI itself keeps working.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Print the banner via log.warn — single line on stderr, [wyvrn] prefix
|
|
96
|
+
* applied by the logger. Stays out of stdout so `VER=$(wyvrnpm version)`
|
|
97
|
+
* stays clean.
|
|
98
|
+
*/
|
|
99
|
+
function emitBanner(installed, latest) {
|
|
100
|
+
log.warn(`wyvrnpm ${installed} → ${latest} available. Run \`npm i -g wyvrnpm\` to update.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Main entry. Sync work (gate + cache read + banner) returns synchronously;
|
|
105
|
+
* background fetch runs in a detached child. Total foreground cost: one
|
|
106
|
+
* fs.readFileSync of a tiny JSON file in the happy path.
|
|
107
|
+
*/
|
|
108
|
+
function maybeNotify(opts = {}) {
|
|
109
|
+
const env = opts.env || process.env;
|
|
110
|
+
const argv = opts.argv || process.argv;
|
|
111
|
+
const stdout = opts.stdout || process.stdout;
|
|
112
|
+
const cachePath = opts.cachePath || getUpdateCheckPath();
|
|
113
|
+
const installed = opts.installedVersion || PKG_VERSION;
|
|
114
|
+
const now = opts.now || Date.now();
|
|
115
|
+
const spawn = opts.spawn || spawnFetcher;
|
|
116
|
+
const banner = opts.banner || emitBanner;
|
|
117
|
+
|
|
118
|
+
if (!shouldRunUpdateCheck(env, argv, stdout)) return;
|
|
119
|
+
|
|
120
|
+
const cache = readCache(cachePath);
|
|
121
|
+
|
|
122
|
+
// Cache invalid if installedAtCheck disagrees with the live version
|
|
123
|
+
// (user upgraded since the last check) — treat as stale.
|
|
124
|
+
if (!cache || cache.installedAtCheck !== installed) {
|
|
125
|
+
spawn();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (now - cache.lastCheckedAt >= TTL_MS) {
|
|
130
|
+
spawn();
|
|
131
|
+
// Fall through — we may still have a useful latestVersion from the
|
|
132
|
+
// last fetch even though it's "stale" by TTL. Print the banner if
|
|
133
|
+
// applicable; the background fetch will refresh for next time.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (compareVersions(cache.latestVersion, installed) > 0) {
|
|
137
|
+
banner(installed, cache.latestVersion);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
maybeNotify,
|
|
143
|
+
shouldRunUpdateCheck,
|
|
144
|
+
readCache,
|
|
145
|
+
};
|