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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nebula-ai",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "CLI/TUI for the nebula.gg AI task orchestration platform",
5
5
  "type": "module",
6
6
  "bin": {