nebula-ai 0.0.15 → 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/nebula-ai.js CHANGED
@@ -46,6 +46,10 @@ import { homedir } from "node:os";
46
46
  import { dirname, join } from "node:path";
47
47
  import { pipeline } from "node:stream/promises";
48
48
  import { fileURLToPath } from "node:url";
49
+ import { detectInstallMethod, getChannel, getUpdateCheckCachePath, getUpgradeCommand, isNewerVersion, isNpx, isUpdateCheckDisabled, UPDATE_CHECK_TTL_MS, } from "./update-check-lib.js";
50
+ // Re-export the pure update-check helpers so older tests / downstream
51
+ // importers that still reach for `./launcher.js` keep working.
52
+ export { detectInstallMethod, getChannel, getUpdateCheckCachePath, getUpgradeCommand, isNewerVersion, isNpx, isUpdateCheckDisabled, } from "./update-check-lib.js";
49
53
  // The public half of the ed25519 key that signs `checksums.json`. The
50
54
  // private half lives only in the `NEBULA_AI_SIGNING_PRIVATE_KEY` GitHub
51
55
  // Actions secret.
@@ -66,68 +70,11 @@ const SUPPORTED_PLATFORMS = new Set([
66
70
  "linux-arm64",
67
71
  "win32-x64",
68
72
  ]);
69
- // ─── update-check constants ────────────────────────────────────────────────
70
- const UPDATE_CHECK_TTL = 4 * 60 * 60 * 1000; // 4 hours
71
73
  // ─── pure helpers (exported for tests) ─────────────────────────────────────
72
- /**
73
- * Return the path to the update-check cache file. Lives at the top of the
74
- * cache directory (NOT under a version-specific subdirectory) so it persists
75
- * across version upgrades.
76
- */
77
- export function getUpdateCheckCachePath() {
78
- const base = process.env.NEBULA_AI_CACHE_DIR ||
79
- (process.platform === "win32"
80
- ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
81
- : process.env.XDG_CACHE_HOME || join(homedir(), ".cache"));
82
- return join(base, "nebula-ai", "update-check.json");
83
- }
84
- /**
85
- * Determine the release channel from a version string.
86
- * Dev versions look like `x.y.z-dev.YYYYMMDDHHMMSS.sha`.
87
- */
88
- export function getChannel(version) {
89
- return version.includes("-dev.") ? "dev" : "prod";
90
- }
91
- /**
92
- * Return true if `latest` is a newer semver than `current` (prod), or simply
93
- * different (dev — timestamps are monotonically increasing).
94
- */
95
- export function isNewerVersion(latest, current) {
96
- if (current.includes("-dev.")) {
97
- return latest !== current;
98
- }
99
- // Strip any prerelease suffix (e.g., "-beta.1") before parsing.
100
- const clean = (v) => v.replace(/-.*$/, "");
101
- const [a1, a2, a3] = clean(latest).split(".").map(Number);
102
- const [b1, b2, b3] = clean(current).split(".").map(Number);
103
- if (a1 !== b1)
104
- return a1 > b1;
105
- if (a2 !== b2)
106
- return a2 > b2;
107
- if (a3 !== b3)
108
- return a3 > b3;
109
- // Same numeric version: a stable release is newer than a prerelease
110
- // (e.g. 1.0.0 is newer than 1.0.0-beta.1).
111
- if (current.includes("-") && !latest.includes("-"))
112
- return true;
113
- return false;
114
- }
115
- /**
116
- * Return true when the update check should be suppressed entirely.
117
- */
118
- export function isUpdateCheckDisabled() {
119
- return (process.env.NEBULA_AI_NO_UPDATE_CHECK === "1" ||
120
- process.env.NEBULA_AI_NO_UPDATE_CHECK === "true" ||
121
- process.env.CI === "true" ||
122
- !process.stderr.isTTY);
123
- }
124
- /**
125
- * Return true when running under `npx` (which always fetches the latest
126
- * wrapper, making the check redundant).
127
- */
128
- export function isNpx() {
129
- return process.env.npm_command === "exec";
130
- }
74
+ //
75
+ // Version/update-check helpers live in ./update-check-lib.ts and are
76
+ // re-exported above. Supply-chain helpers remain here because they import
77
+ // node:crypto and aren't used outside the launcher.
131
78
  export function sha256HexSync(buffer) {
132
79
  return createHash("sha256").update(buffer).digest("hex");
133
80
  }
@@ -288,7 +235,7 @@ function maybeSpawnUpdateCheck(currentVersion) {
288
235
  const cachePath = getUpdateCheckCachePath();
289
236
  // Skip if the cache is still fresh (stat is cheaper than read + parse).
290
237
  try {
291
- if (Date.now() - statSync(cachePath).mtimeMs < UPDATE_CHECK_TTL)
238
+ if (Date.now() - statSync(cachePath).mtimeMs < UPDATE_CHECK_TTL_MS)
292
239
  return;
293
240
  }
294
241
  catch {
@@ -331,16 +278,22 @@ function maybeSpawnUpdateCheck(currentVersion) {
331
278
  /**
332
279
  * Read the cached check result (written by a *previous* invocation's
333
280
  * background process) and print a one-line update notification to stderr
334
- * if a newer version is available.
281
+ * if a newer version is available. The suggested upgrade command is
282
+ * chosen based on the detected install method so pnpm/yarn/bun/brew users
283
+ * see an actually-applicable command.
335
284
  */
336
- function maybeShowUpdateNotification(currentVersion) {
285
+ function maybeShowUpdateNotification(currentVersion, installMethod) {
337
286
  try {
338
287
  const raw = readFileSync(getUpdateCheckCachePath(), "utf8");
339
288
  const cached = JSON.parse(raw);
340
289
  if (cached.latest && isNewerVersion(cached.latest, currentVersion)) {
341
- const cmd = currentVersion.includes("-dev.")
342
- ? "npm install -g nebula-ai@dev"
343
- : "npm install -g nebula-ai";
290
+ const channel = getChannel(currentVersion);
291
+ // Fall back to the canonical npm command when the install method
292
+ // is unknown — matches the wrapper's install-time docs.
293
+ const cmd = getUpgradeCommand(installMethod, channel) ||
294
+ (channel === "dev"
295
+ ? "npm install -g nebula-ai@dev"
296
+ : "npm install -g nebula-ai");
344
297
  process.stderr.write(`\nnebula-ai: update available ${currentVersion} \u2192 ${cached.latest}\n` +
345
298
  ` Run: ${cmd}\n`);
346
299
  }
@@ -404,12 +357,23 @@ async function main() {
404
357
  if (!isUpdateCheckDisabled() && !isNpx()) {
405
358
  maybeSpawnUpdateCheck(version);
406
359
  }
360
+ // Figure out how the user installed us (npm/pnpm/yarn/bun/npx/...) from
361
+ // the wrapper's own launch path + env. We forward the result to the Bun
362
+ // binary via NEBULA_AI_INSTALL_METHOD so the in-TUI "What's new" banner
363
+ // can surface the right upgrade command without redoing the detection.
364
+ const installMethod = detectInstallMethod({
365
+ execPath: fileURLToPath(import.meta.url),
366
+ argv0: process.argv[0],
367
+ npmCommand: process.env.npm_command,
368
+ npmUserAgent: process.env.npm_config_user_agent,
369
+ });
407
370
  const result = spawnSync(binaryPath, process.argv.slice(2), {
408
371
  stdio: "inherit",
372
+ env: { ...process.env, NEBULA_AI_INSTALL_METHOD: installMethod },
409
373
  });
410
374
  // Show update notification (reads PREVIOUS check's cached result).
411
375
  if (!isUpdateCheckDisabled() && !isNpx()) {
412
- maybeShowUpdateNotification(version);
376
+ maybeShowUpdateNotification(version, installMethod);
413
377
  }
414
378
  if (result.error) {
415
379
  console.error(`nebula-ai: unable to start the application.\n` +
@@ -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.15",
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": {