nebula-ai 0.0.16 → 0.0.17
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/bin/update-check-lib.js +223 -0
- package/package.json +1 -1
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Shared, pure helpers for the nebula-ai update-check flow.
|
|
2
|
+
//
|
|
3
|
+
// This module is imported by two very different consumers:
|
|
4
|
+
//
|
|
5
|
+
// 1. `scripts/publish/launcher.ts` — the Node launcher shipped as the
|
|
6
|
+
// npm wrapper. Uses these helpers to decide whether to fire the
|
|
7
|
+
// background check and to print the post-exit stderr notification.
|
|
8
|
+
//
|
|
9
|
+
// 2. `src/update-check/lib.ts` — a byte-for-byte mirror living inside
|
|
10
|
+
// the Bun SEA binary. Kept in sync via a parity test rather than a
|
|
11
|
+
// cross-tree import because the main tsconfig's `rootDir: src`
|
|
12
|
+
// forbids reaching outside `src/`. If you edit this file, edit the
|
|
13
|
+
// mirror as well (see the parity test in
|
|
14
|
+
// `src/update-check/lib.test.ts`).
|
|
15
|
+
//
|
|
16
|
+
// Everything exported here MUST be pure (no fs, no network, no process
|
|
17
|
+
// exits). Side-effecting code belongs in the callers.
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
// ─── update-check constants ────────────────────────────────────────────────
|
|
21
|
+
/** How long a cache entry counts as fresh (4 hours). */
|
|
22
|
+
export const UPDATE_CHECK_TTL_MS = 4 * 60 * 60 * 1000;
|
|
23
|
+
// ─── cache-path resolution ─────────────────────────────────────────────────
|
|
24
|
+
/**
|
|
25
|
+
* Return the path to the update-check cache file. Lives at the top of the
|
|
26
|
+
* cache directory (NOT under a version-specific subdirectory) so it persists
|
|
27
|
+
* across version upgrades.
|
|
28
|
+
*/
|
|
29
|
+
export function getUpdateCheckCachePath() {
|
|
30
|
+
const base = process.env.NEBULA_AI_CACHE_DIR ||
|
|
31
|
+
(process.platform === "win32"
|
|
32
|
+
? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
|
|
33
|
+
: process.env.XDG_CACHE_HOME || join(homedir(), ".cache"));
|
|
34
|
+
return join(base, "nebula-ai", "update-check.json");
|
|
35
|
+
}
|
|
36
|
+
// ─── version helpers ───────────────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Determine the release channel from a version string.
|
|
39
|
+
* Dev versions look like `x.y.z-dev.YYYYMMDDHHMMSS.sha`.
|
|
40
|
+
*/
|
|
41
|
+
export function getChannel(version) {
|
|
42
|
+
return version.includes("-dev.") ? "dev" : "prod";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Return true if `latest` is a newer semver than `current` (prod), or a
|
|
46
|
+
* strictly newer build timestamp (dev).
|
|
47
|
+
*
|
|
48
|
+
* Dev versions look like `x.y.z-dev.YYYYMMDDHHMMSS.sha`, produced by CI on
|
|
49
|
+
* every merge. Comparing with `!==` would falsely flag any mismatch as an
|
|
50
|
+
* "update available" — including the common case where a contributor runs a
|
|
51
|
+
* locally-built dev binary that's ahead of the registry's `dev` dist-tag
|
|
52
|
+
* (e.g. a PR branch that's ahead of main). Parse the 14-digit stamp out of
|
|
53
|
+
* both sides and compare those instead; fall back to `!==` only if either
|
|
54
|
+
* string doesn't match the expected shape.
|
|
55
|
+
*/
|
|
56
|
+
export function isNewerVersion(latest, current) {
|
|
57
|
+
if (current.includes("-dev.")) {
|
|
58
|
+
const devStamp = /-dev\.(\d{14})\./;
|
|
59
|
+
const latestStamp = latest.match(devStamp)?.[1];
|
|
60
|
+
const currentStamp = current.match(devStamp)?.[1];
|
|
61
|
+
if (latestStamp && currentStamp)
|
|
62
|
+
return latestStamp > currentStamp;
|
|
63
|
+
return latest !== current;
|
|
64
|
+
}
|
|
65
|
+
// Strip any prerelease suffix (e.g., "-beta.1") before parsing.
|
|
66
|
+
const clean = (v) => v.replace(/-.*$/, "");
|
|
67
|
+
const [a1, a2, a3] = clean(latest).split(".").map(Number);
|
|
68
|
+
const [b1, b2, b3] = clean(current).split(".").map(Number);
|
|
69
|
+
if (a1 !== b1)
|
|
70
|
+
return a1 > b1;
|
|
71
|
+
if (a2 !== b2)
|
|
72
|
+
return a2 > b2;
|
|
73
|
+
if (a3 !== b3)
|
|
74
|
+
return a3 > b3;
|
|
75
|
+
// Same numeric version: a stable release is newer than a prerelease
|
|
76
|
+
// (e.g. 1.0.0 is newer than 1.0.0-beta.1).
|
|
77
|
+
if (current.includes("-") && !latest.includes("-"))
|
|
78
|
+
return true;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
// ─── environment gates ─────────────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Return true when the update check should be suppressed entirely.
|
|
84
|
+
*
|
|
85
|
+
* The TTY check targets the stream the caller is about to write to — callers
|
|
86
|
+
* pass the stream they'd render on:
|
|
87
|
+
*
|
|
88
|
+
* - Launcher context → `process.stderr` (the stderr one-liner).
|
|
89
|
+
* - Binary / TUI context → `process.stdout` (the in-TUI banner).
|
|
90
|
+
*
|
|
91
|
+
* Without this distinction, a user running `nebula-ai 2>/dev/null` would
|
|
92
|
+
* silence the in-TUI banner even though the TUI itself is still rendering
|
|
93
|
+
* on stdout. Defaults to `process.stderr` to preserve the launcher's
|
|
94
|
+
* historical behaviour for any caller that doesn't pass one.
|
|
95
|
+
*/
|
|
96
|
+
export function isUpdateCheckDisabled(stream = process.stderr) {
|
|
97
|
+
// Canonical `CI=true` is what GitHub Actions / CircleCI / GitLab CI set,
|
|
98
|
+
// but the long tail of CI runners uses `CI=1`, `CI=yes`, etc. Treat any
|
|
99
|
+
// non-empty value as "in CI" unless it's explicitly opted out
|
|
100
|
+
// (`CI=false` / `CI=0`), matching npm's is-ci heuristic.
|
|
101
|
+
const ci = process.env.CI?.toLowerCase();
|
|
102
|
+
const inCI = !!ci && ci !== "false" && ci !== "0";
|
|
103
|
+
return (process.env.NEBULA_AI_NO_UPDATE_CHECK === "1" ||
|
|
104
|
+
process.env.NEBULA_AI_NO_UPDATE_CHECK === "true" ||
|
|
105
|
+
inCI ||
|
|
106
|
+
!stream.isTTY);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Return true when running under a transient package runner (npx, pnpm dlx,
|
|
110
|
+
* yarn dlx, bunx). Those flows fetch a fresh copy of the wrapper every
|
|
111
|
+
* invocation, so an "update available" notice would be misleading — there's
|
|
112
|
+
* nothing persistent to upgrade.
|
|
113
|
+
*
|
|
114
|
+
* Detection is a two-step heuristic because no single signal catches every
|
|
115
|
+
* runner:
|
|
116
|
+
*
|
|
117
|
+
* - `npm_command` is set to `"exec"` by npm and `"dlx"` by pnpm/yarn.
|
|
118
|
+
* - bunx doesn't set `npm_command` at all, but bun-originated invocations
|
|
119
|
+
* set `npm_config_user_agent` to `bun/...`. If we see that UA *and* the
|
|
120
|
+
* binary isn't living under a persistent bun globals path (that check
|
|
121
|
+
* belongs in `detectInstallMethod`), we treat it as transient.
|
|
122
|
+
*
|
|
123
|
+
* `isNpx()` deliberately stays conservative — callers that also have a
|
|
124
|
+
* path-based signal (like `detectInstallMethod`) layer that check on top.
|
|
125
|
+
*/
|
|
126
|
+
export function isNpx() {
|
|
127
|
+
const cmd = process.env.npm_command;
|
|
128
|
+
return cmd === "exec" || cmd === "dlx";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Pure detection: given a resolved launch path + a few env vars, return
|
|
132
|
+
* the best-guess install method. No fs / no process inspection — all
|
|
133
|
+
* inputs are passed in by the caller so this function stays trivial to
|
|
134
|
+
* test.
|
|
135
|
+
*/
|
|
136
|
+
export function detectInstallMethod(input) {
|
|
137
|
+
// 1. Transient-runner env vars win unconditionally. `exec` is set by npx;
|
|
138
|
+
// `dlx` by pnpm/yarn's dlx commands. Each invocation re-fetches the
|
|
139
|
+
// wrapper, so there's nothing persistent to upgrade and the banner
|
|
140
|
+
// should be suppressed.
|
|
141
|
+
if (input.npmCommand === "exec" || input.npmCommand === "dlx")
|
|
142
|
+
return "npx";
|
|
143
|
+
// 2. Path fingerprinting precedes user-agent matching because the path
|
|
144
|
+
// tells us WHERE the binary lives (persistent vs. transient cache),
|
|
145
|
+
// whereas the UA only tells us WHO invoked it. A `bunx nebula-ai`
|
|
146
|
+
// invocation sets the UA to `bun/...` but the binary lives in bun's
|
|
147
|
+
// run-cache, not its globals dir — trusting UA there would flash an
|
|
148
|
+
// `bun add -g` banner even though bunx already fetches fresh. Check
|
|
149
|
+
// both execPath and argv0 because on some shims they diverge.
|
|
150
|
+
const paths = [input.execPath, input.argv0 ?? ""].map((p) =>
|
|
151
|
+
// Normalize Windows separators so our substring checks work uniformly.
|
|
152
|
+
p.replace(/\\/g, "/"));
|
|
153
|
+
const anyMatch = (...needles) => paths.some((p) => needles.some((n) => p.includes(n)));
|
|
154
|
+
// Homebrew formula name is `nebula-ai`, so a brew-installed binary lives
|
|
155
|
+
// under `.../Cellar/nebula-ai/<version>/bin/nebula-ai`. Matching the
|
|
156
|
+
// formula segment specifically avoids misdetecting an npm global under a
|
|
157
|
+
// Homebrew-installed Node (which lives at
|
|
158
|
+
// `/opt/homebrew/lib/node_modules/...`, no `/Cellar/nebula-ai/`).
|
|
159
|
+
if (anyMatch("/Cellar/nebula-ai/", "/linuxbrew/Cellar/nebula-ai/")) {
|
|
160
|
+
return "brew";
|
|
161
|
+
}
|
|
162
|
+
if (anyMatch("/.bun/install/global/", "/bun/install/global/")) {
|
|
163
|
+
return "bun";
|
|
164
|
+
}
|
|
165
|
+
if (anyMatch("/pnpm/global/", "/.pnpm/global/", "/Library/pnpm/", "/.local/share/pnpm/", "/pnpm-global/")) {
|
|
166
|
+
return "pnpm";
|
|
167
|
+
}
|
|
168
|
+
if (anyMatch("/.yarn/global/", "/yarn/global/")) {
|
|
169
|
+
return "yarn";
|
|
170
|
+
}
|
|
171
|
+
// Any `/node_modules/` fingerprint that fell through is most likely an
|
|
172
|
+
// npm global (npm installs the wrapper as a symlink inside
|
|
173
|
+
// `<prefix>/lib/node_modules/nebula-ai/`).
|
|
174
|
+
if (anyMatch("/node_modules/")) {
|
|
175
|
+
return "npm";
|
|
176
|
+
}
|
|
177
|
+
// 3. User-agent fallthrough. If we get here, none of the persistent
|
|
178
|
+
// install paths matched, which means a UA match most likely indicates
|
|
179
|
+
// a transient runner (e.g. `bunx nebula-ai`, or a project-local
|
|
180
|
+
// `pnpm exec` that didn't set `npm_command=exec`). Treat these as npx
|
|
181
|
+
// — no persistent install to upgrade.
|
|
182
|
+
const ua = (input.npmUserAgent ?? "").toLowerCase();
|
|
183
|
+
if (ua.startsWith("pnpm") ||
|
|
184
|
+
ua.startsWith("yarn") ||
|
|
185
|
+
ua.startsWith("bun") ||
|
|
186
|
+
ua.startsWith("npm")) {
|
|
187
|
+
return "npx";
|
|
188
|
+
}
|
|
189
|
+
return "unknown";
|
|
190
|
+
}
|
|
191
|
+
// ─── upgrade-command mapping ───────────────────────────────────────────────
|
|
192
|
+
/**
|
|
193
|
+
* Map an install method + release channel to the shell command a user
|
|
194
|
+
* would run to upgrade. Returns an empty string for cases where no
|
|
195
|
+
* single-shot upgrade makes sense (npx / unknown).
|
|
196
|
+
*/
|
|
197
|
+
export function getUpgradeCommand(method, channel) {
|
|
198
|
+
const pkg = channel === "dev" ? "nebula-ai@dev" : "nebula-ai";
|
|
199
|
+
switch (method) {
|
|
200
|
+
case "npm":
|
|
201
|
+
return `npm install -g ${pkg}`;
|
|
202
|
+
case "pnpm":
|
|
203
|
+
return `pnpm add -g ${pkg}`;
|
|
204
|
+
case "yarn":
|
|
205
|
+
// `yarn global add` only exists on classic yarn; modern yarn (berry)
|
|
206
|
+
// drops the global registry in favour of dlx, but there's no better
|
|
207
|
+
// single-command upgrade for a classic-yarn user — this mirrors what
|
|
208
|
+
// their install command would have been.
|
|
209
|
+
return `yarn global add ${pkg}`;
|
|
210
|
+
case "bun":
|
|
211
|
+
return `bun add -g ${pkg}`;
|
|
212
|
+
case "brew":
|
|
213
|
+
// The brew tap is prod-only (dev versions use `-dev.<stamp>.<sha>`
|
|
214
|
+
// suffixes Homebrew mishandles). If someone somehow ended up on dev
|
|
215
|
+
// via brew, redirect them to npm for the dev channel.
|
|
216
|
+
return channel === "dev"
|
|
217
|
+
? `npm install -g nebula-ai@dev`
|
|
218
|
+
: `brew upgrade nebula-ai`;
|
|
219
|
+
case "npx":
|
|
220
|
+
case "unknown":
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
}
|