nebula-ai 0.0.8 → 0.0.9

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.
Files changed (2) hide show
  1. package/bin/nebula-ai.js +274 -313
  2. package/package.json +1 -1
package/bin/nebula-ai.js CHANGED
@@ -38,26 +38,14 @@
38
38
  // so keep the old key valid (publish one more release with it)
39
39
  // long enough for the dist-tag to catch up, then delete the old
40
40
  // key material.
41
-
42
41
  import { spawn, spawnSync } from "node:child_process";
43
42
  import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
44
- import {
45
- chmodSync,
46
- createWriteStream,
47
- existsSync,
48
- mkdirSync,
49
- readFileSync,
50
- realpathSync,
51
- renameSync,
52
- rmSync,
53
- statSync,
54
- } from "node:fs";
43
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, } from "node:fs";
55
44
  import { readFile } from "node:fs/promises";
56
45
  import { homedir } from "node:os";
57
46
  import { dirname, join } from "node:path";
58
47
  import { pipeline } from "node:stream/promises";
59
48
  import { fileURLToPath } from "node:url";
60
-
61
49
  // The public half of the ed25519 key that signs `checksums.json`. The
62
50
  // private half lives only in the `NEBULA_AI_SIGNING_PRIVATE_KEY` GitHub
63
51
  // Actions secret.
@@ -65,239 +53,231 @@ const EMBEDDED_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
65
53
  MCowBQYDK2VwAyEAd8QFvCTSMY0HmSsJgL6jtFQas00WedvSZhkc9bxdAl0=
66
54
  -----END PUBLIC KEY-----
67
55
  `;
68
-
69
56
  // Stamped by `scripts/publish/prepare.mjs` at wrapper staging time with
70
57
  // the channel-appropriate base URL (dev vs prod). The `__NEBULA_AI_…__`
71
58
  // sentinel lets us keep the checked-in source runnable for local tests
72
59
  // without pointing at any real host.
73
60
  const STAMPED_DOWNLOAD_BASE_URL = "https://cli-assets.nebula-dev.ai/production";
74
- const DOWNLOAD_BASE_URL =
75
- process.env.NEBULA_AI_DOWNLOAD_BASE ?? STAMPED_DOWNLOAD_BASE_URL;
76
-
61
+ const DOWNLOAD_BASE_URL = process.env.NEBULA_AI_DOWNLOAD_BASE ?? STAMPED_DOWNLOAD_BASE_URL;
77
62
  const SUPPORTED_PLATFORMS = new Set([
78
- "darwin-arm64",
79
- "darwin-x64",
80
- "linux-x64",
81
- "linux-arm64",
82
- "win32-x64",
63
+ "darwin-arm64",
64
+ "darwin-x64",
65
+ "linux-x64",
66
+ "linux-arm64",
67
+ "win32-x64",
83
68
  ]);
84
-
85
69
  // ─── update-check constants ────────────────────────────────────────────────
86
-
87
70
  const UPDATE_CHECK_TTL = 4 * 60 * 60 * 1000; // 4 hours
88
-
89
71
  // ─── pure helpers (exported for tests) ─────────────────────────────────────
90
-
91
72
  /**
92
73
  * Return the path to the update-check cache file. Lives at the top of the
93
74
  * cache directory (NOT under a version-specific subdirectory) so it persists
94
75
  * across version upgrades.
95
76
  */
96
77
  export function getUpdateCheckCachePath() {
97
- const base =
98
- process.env.NEBULA_AI_CACHE_DIR ||
99
- (process.platform === "win32"
100
- ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
101
- : process.env.XDG_CACHE_HOME || join(homedir(), ".cache"));
102
- return join(base, "nebula-ai", "update-check.json");
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");
103
83
  }
104
-
105
84
  /**
106
85
  * Determine the release channel from a version string.
107
86
  * Dev versions look like `x.y.z-dev.YYYYMMDDHHMMSS.sha`.
108
87
  */
109
88
  export function getChannel(version) {
110
- return version.includes("-dev.") ? "dev" : "prod";
89
+ return version.includes("-dev.") ? "dev" : "prod";
111
90
  }
112
-
113
91
  /**
114
92
  * Return true if `latest` is a newer semver than `current` (prod), or simply
115
93
  * different (dev — timestamps are monotonically increasing).
116
94
  */
117
95
  export function isNewerVersion(latest, current) {
118
- if (current.includes("-dev.")) {
119
- return latest !== current;
120
- }
121
- // Strip any prerelease suffix (e.g., "-beta.1") before parsing.
122
- const clean = (v) => v.replace(/-.*$/, "");
123
- const [a1, a2, a3] = clean(latest).split(".").map(Number);
124
- const [b1, b2, b3] = clean(current).split(".").map(Number);
125
- if (a1 !== b1) return a1 > b1;
126
- if (a2 !== b2) return a2 > b2;
127
- if (a3 !== b3) return a3 > b3;
128
- // Same numeric version: a stable release is newer than a prerelease
129
- // (e.g. 1.0.0 is newer than 1.0.0-beta.1).
130
- if (current.includes("-") && !latest.includes("-")) return true;
131
- return false;
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;
132
114
  }
133
-
134
115
  /**
135
116
  * Return true when the update check should be suppressed entirely.
136
117
  */
137
118
  export function isUpdateCheckDisabled() {
138
- return (
139
- process.env.NEBULA_AI_NO_UPDATE_CHECK === "1" ||
140
- process.env.NEBULA_AI_NO_UPDATE_CHECK === "true" ||
141
- process.env.CI === "true" ||
142
- !process.stderr.isTTY
143
- );
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);
144
123
  }
145
-
146
124
  /**
147
125
  * Return true when running under `npx` (which always fetches the latest
148
126
  * wrapper, making the check redundant).
149
127
  */
150
128
  export function isNpx() {
151
- return process.env.npm_command === "exec";
129
+ return process.env.npm_command === "exec";
152
130
  }
153
-
154
131
  export function sha256HexSync(buffer) {
155
- return createHash("sha256").update(buffer).digest("hex");
132
+ return createHash("sha256").update(buffer).digest("hex");
156
133
  }
157
-
158
134
  /**
159
135
  * Verify a signed checksums.json blob and return the parsed manifest.
160
136
  * Throws if the signature is invalid or the manifest is malformed.
161
137
  */
162
138
  export function verifyManifest(manifestBytes, signatureBase64, publicKeyPem) {
163
- const pubKey = createPublicKey(publicKeyPem);
164
- if (pubKey.asymmetricKeyType !== "ed25519") {
165
- throw new Error(
166
- `embedded public key is not ed25519 (got ${pubKey.asymmetricKeyType})`,
167
- );
168
- }
169
- const sig = Buffer.from(signatureBase64.trim(), "base64");
170
- const ok = cryptoVerify(null, manifestBytes, pubKey, sig);
171
- if (!ok) {
172
- throw new Error("manifest signature verification failed");
173
- }
174
- const manifest = JSON.parse(manifestBytes.toString("utf8"));
175
- if (!manifest || typeof manifest !== "object") {
176
- throw new Error("manifest is not a JSON object");
177
- }
178
- if (manifest.algorithm !== "sha256") {
179
- throw new Error(
180
- `unsupported manifest algorithm: ${manifest.algorithm ?? "(missing)"}`,
181
- );
182
- }
183
- if (!manifest.binaries || typeof manifest.binaries !== "object") {
184
- throw new Error("manifest is missing the `binaries` map");
185
- }
186
- return manifest;
139
+ const pubKey = createPublicKey(publicKeyPem);
140
+ if (pubKey.asymmetricKeyType !== "ed25519") {
141
+ throw new Error(`embedded public key is not ed25519 (got ${pubKey.asymmetricKeyType})`);
142
+ }
143
+ const sig = Buffer.from(signatureBase64.trim(), "base64");
144
+ const ok = cryptoVerify(null, manifestBytes, pubKey, sig);
145
+ if (!ok) {
146
+ throw new Error("manifest signature verification failed");
147
+ }
148
+ const manifest = JSON.parse(manifestBytes.toString("utf8"));
149
+ if (!manifest || typeof manifest !== "object") {
150
+ throw new Error("manifest is not a JSON object");
151
+ }
152
+ const m = manifest;
153
+ if (m.algorithm !== "sha256") {
154
+ throw new Error(`unsupported manifest algorithm: ${m.algorithm ?? "(missing)"}`);
155
+ }
156
+ if (!m.binaries || typeof m.binaries !== "object") {
157
+ throw new Error("manifest is missing the `binaries` map");
158
+ }
159
+ return manifest;
187
160
  }
188
-
189
161
  /**
190
162
  * Given a verified manifest + a platform key + the bytes of a downloaded
191
163
  * binary, confirm the binary's SHA-256 matches the manifest entry. Throws
192
164
  * on mismatch.
193
165
  */
194
166
  export function assertBinaryMatchesManifest(manifest, platformKey, binName, binaryBuffer) {
195
- const entryKey = `${platformKey}/${binName}`;
196
- const expected = manifest.binaries[entryKey];
197
- if (typeof expected !== "string") {
198
- throw new Error(`manifest has no entry for ${entryKey}`);
199
- }
200
- const actual = sha256HexSync(binaryBuffer);
201
- if (actual !== expected) {
202
- throw new Error(
203
- `binary digest mismatch for ${entryKey}\n expected: ${expected}\n actual: ${actual}`,
204
- );
205
- }
167
+ const entryKey = `${platformKey}/${binName}`;
168
+ const expected = manifest.binaries[entryKey];
169
+ if (typeof expected !== "string") {
170
+ throw new Error(`manifest has no entry for ${entryKey}`);
171
+ }
172
+ const actual = sha256HexSync(binaryBuffer);
173
+ if (actual !== expected) {
174
+ throw new Error(`binary digest mismatch for ${entryKey}\n expected: ${expected}\n actual: ${actual}`);
175
+ }
206
176
  }
207
-
208
177
  // ─── runtime (only runs when this file is the entrypoint) ─────────────────
209
-
210
178
  const platformKey = `${process.platform}-${process.arch}`;
211
179
  const binName = process.platform === "win32" ? "nebula-ai.exe" : "nebula-ai";
212
-
213
180
  async function readVersion() {
214
- const here = dirname(fileURLToPath(import.meta.url));
215
- const pkgPath = join(here, "..", "package.json");
216
- const raw = await readFile(pkgPath, "utf8");
217
- const pkg = JSON.parse(raw);
218
- if (typeof pkg.version !== "string") {
219
- throw new Error("nebula-ai: package.json is missing a version field");
220
- }
221
- return pkg.version;
181
+ const here = dirname(fileURLToPath(import.meta.url));
182
+ const pkgPath = join(here, "..", "package.json");
183
+ const raw = await readFile(pkgPath, "utf8");
184
+ const pkg = JSON.parse(raw);
185
+ if (typeof pkg.version !== "string") {
186
+ throw new Error("nebula-ai: package.json is missing a version field");
187
+ }
188
+ return pkg.version;
189
+ }
190
+ function getCacheBaseDir() {
191
+ // Respect XDG_CACHE_HOME on linux, honor LOCALAPPDATA on windows, default
192
+ // to ~/.cache elsewhere. Keeps behavior predictable across platforms.
193
+ const base = process.env.NEBULA_AI_CACHE_DIR ||
194
+ (process.platform === "win32"
195
+ ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
196
+ : process.env.XDG_CACHE_HOME || join(homedir(), ".cache"));
197
+ return join(base, "nebula-ai");
222
198
  }
223
-
224
199
  function getCacheDir(version) {
225
- // Respect XDG_CACHE_HOME on linux, honor LOCALAPPDATA on windows, default
226
- // to ~/.cache elsewhere. Keeps behavior predictable across platforms.
227
- const base =
228
- process.env.NEBULA_AI_CACHE_DIR ||
229
- (process.platform === "win32"
230
- ? process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")
231
- : process.env.XDG_CACHE_HOME || join(homedir(), ".cache"));
232
- return join(base, "nebula-ai", `v${version}`, platformKey);
200
+ return join(getCacheBaseDir(), `v${version}`, platformKey);
201
+ }
202
+ /**
203
+ * Remove cached binaries for versions other than the current one.
204
+ * Only touches directories whose names are `v<semver>` — leaves
205
+ * non-version entries (e.g. `boxlite/`, `update-check.json`) untouched.
206
+ * Best-effort: individual removal failures are silently ignored so one
207
+ * stuck directory doesn't prevent the rest from being cleaned up.
208
+ */
209
+ const VERSION_CACHE_DIR_RE = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
210
+ function cleanupOldVersions(currentVersion) {
211
+ try {
212
+ const base = getCacheBaseDir();
213
+ const currentDir = `v${currentVersion}`;
214
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
215
+ if (entry.isDirectory() &&
216
+ VERSION_CACHE_DIR_RE.test(entry.name) &&
217
+ entry.name !== currentDir) {
218
+ try {
219
+ rmSync(join(base, entry.name), { recursive: true, force: true });
220
+ }
221
+ catch {
222
+ // best-effort — skip this entry and continue
223
+ }
224
+ }
225
+ }
226
+ }
227
+ catch {
228
+ // Parent directory may not exist yet, or permissions issue — ignore.
229
+ }
233
230
  }
234
-
235
231
  async function fetchManifest(version) {
236
- const manifestUrl = `${DOWNLOAD_BASE_URL}/v${version}/checksums.json`;
237
- const sigUrl = `${DOWNLOAD_BASE_URL}/v${version}/checksums.json.sig`;
238
- const [manifestRes, sigRes] = await Promise.all([
239
- fetch(manifestUrl, { redirect: "follow" }),
240
- fetch(sigUrl, { redirect: "follow" }),
241
- ]);
242
- if (!manifestRes.ok) {
243
- throw new Error(
244
- `manifest download failed: ${manifestRes.status} ${manifestRes.statusText}\n url: ${manifestUrl}`,
245
- );
246
- }
247
- if (!sigRes.ok) {
248
- throw new Error(
249
- `signature download failed: ${sigRes.status} ${sigRes.statusText}\n url: ${sigUrl}`,
250
- );
251
- }
252
- const manifestBytes = Buffer.from(await manifestRes.arrayBuffer());
253
- const signatureBase64 = await sigRes.text();
254
- return verifyManifest(manifestBytes, signatureBase64, EMBEDDED_PUBLIC_KEY_PEM);
232
+ const manifestUrl = `${DOWNLOAD_BASE_URL}/v${version}/checksums.json`;
233
+ const sigUrl = `${DOWNLOAD_BASE_URL}/v${version}/checksums.json.sig`;
234
+ const [manifestRes, sigRes] = await Promise.all([
235
+ fetch(manifestUrl, { redirect: "follow" }),
236
+ fetch(sigUrl, { redirect: "follow" }),
237
+ ]);
238
+ if (!manifestRes.ok) {
239
+ throw new Error(`manifest download failed: ${manifestRes.status} ${manifestRes.statusText}\n url: ${manifestUrl}`);
240
+ }
241
+ if (!sigRes.ok) {
242
+ throw new Error(`signature download failed: ${sigRes.status} ${sigRes.statusText}\n url: ${sigUrl}`);
243
+ }
244
+ const manifestBytes = Buffer.from(await manifestRes.arrayBuffer());
245
+ const signatureBase64 = await sigRes.text();
246
+ return verifyManifest(manifestBytes, signatureBase64, EMBEDDED_PUBLIC_KEY_PEM);
255
247
  }
256
-
257
248
  async function downloadBinary(version, destPath) {
258
- const url = `${DOWNLOAD_BASE_URL}/v${version}/${platformKey}/${binName}`;
259
-
260
- process.stderr.write(
261
- `nebula-ai: downloading runtime for your platform (one-time setup)…\n`,
262
- );
263
-
264
- const manifest = await fetchManifest(version);
265
-
266
- const res = await fetch(url, { redirect: "follow" });
267
- if (!res.ok || !res.body) {
268
- throw new Error(
269
- `download failed: ${res.status} ${res.statusText}\n url: ${url}`,
270
- );
271
- }
272
-
273
- mkdirSync(dirname(destPath), { recursive: true });
274
-
275
- // Stage under a unique temp name in the SAME directory as destPath so the
276
- // final renameSync is always an intra-filesystem move. tmpdir() can live on
277
- // a different mount (tmpfs on linux, user-local tmp on some sandboxes) and
278
- // rename across mounts fails with EXDEV.
279
- const tmpPath = join(
280
- dirname(destPath),
281
- `.nebula-ai-${version}-${process.pid}-${Date.now()}.partial`,
282
- );
283
- try {
284
- await pipeline(res.body, createWriteStream(tmpPath));
285
- const tmpBytes = await readFile(tmpPath);
286
- assertBinaryMatchesManifest(manifest, platformKey, binName, tmpBytes);
287
- if (process.platform !== "win32") chmodSync(tmpPath, 0o755);
288
- renameSync(tmpPath, destPath);
289
- } catch (err) {
249
+ const url = `${DOWNLOAD_BASE_URL}/v${version}/${platformKey}/${binName}`;
250
+ process.stderr.write(`nebula-ai: downloading runtime for your platform (one-time setup)…\n`);
251
+ const manifest = await fetchManifest(version);
252
+ const res = await fetch(url, { redirect: "follow" });
253
+ if (!res.ok || !res.body) {
254
+ throw new Error(`download failed: ${res.status} ${res.statusText}\n url: ${url}`);
255
+ }
256
+ mkdirSync(dirname(destPath), { recursive: true });
257
+ // Stage under a unique temp name in the SAME directory as destPath so the
258
+ // final renameSync is always an intra-filesystem move. tmpdir() can live on
259
+ // a different mount (tmpfs on linux, user-local tmp on some sandboxes) and
260
+ // rename across mounts fails with EXDEV.
261
+ const tmpPath = join(dirname(destPath), `.nebula-ai-${version}-${process.pid}-${Date.now()}.partial`);
290
262
  try {
291
- rmSync(tmpPath, { force: true });
292
- } catch {
293
- // best-effort cleanup
263
+ await pipeline(res.body, createWriteStream(tmpPath));
264
+ const tmpBytes = await readFile(tmpPath);
265
+ assertBinaryMatchesManifest(manifest, platformKey, binName, tmpBytes);
266
+ if (process.platform !== "win32")
267
+ chmodSync(tmpPath, 0o755);
268
+ renameSync(tmpPath, destPath);
269
+ }
270
+ catch (err) {
271
+ try {
272
+ rmSync(tmpPath, { force: true });
273
+ }
274
+ catch {
275
+ // best-effort cleanup
276
+ }
277
+ throw err;
294
278
  }
295
- throw err;
296
- }
297
279
  }
298
-
299
280
  // ─── update-check runtime ───────────────────────────────────────────────────
300
-
301
281
  /**
302
282
  * If the cached check result is stale (or missing), spawn a detached
303
283
  * background process to fetch the latest version from the npm registry and
@@ -305,19 +285,18 @@ async function downloadBinary(version, destPath) {
305
285
  * launcher alive.
306
286
  */
307
287
  function maybeSpawnUpdateCheck(currentVersion) {
308
- const cachePath = getUpdateCheckCachePath();
309
-
310
- // Skip if the cache is still fresh (stat is cheaper than read + parse).
311
- try {
312
- if (Date.now() - statSync(cachePath).mtimeMs < UPDATE_CHECK_TTL) return;
313
- } catch {
314
- // File missing — fall through to spawn.
315
- }
316
-
317
- const channel = getChannel(currentVersion);
318
- const distTag = channel === "dev" ? "dev" : "latest";
319
-
320
- const script = `
288
+ const cachePath = getUpdateCheckCachePath();
289
+ // Skip if the cache is still fresh (stat is cheaper than read + parse).
290
+ try {
291
+ if (Date.now() - statSync(cachePath).mtimeMs < UPDATE_CHECK_TTL)
292
+ return;
293
+ }
294
+ catch {
295
+ // File missing — fall through to spawn.
296
+ }
297
+ const channel = getChannel(currentVersion);
298
+ const distTag = channel === "dev" ? "dev" : "latest";
299
+ const script = `
321
300
  const fs = require("node:fs");
322
301
  const path = require("node:path");
323
302
  const cachePath = ${JSON.stringify(cachePath)};
@@ -338,130 +317,112 @@ function maybeSpawnUpdateCheck(currentVersion) {
338
317
  })
339
318
  .catch(() => {});
340
319
  `;
341
-
342
- try {
343
- const child = spawn(process.execPath, ["-e", script], {
344
- detached: true,
345
- stdio: "ignore",
346
- });
347
- child.unref();
348
- } catch {
349
- // If we cannot spawn, silently skip.
350
- }
320
+ try {
321
+ const child = spawn(process.execPath, ["-e", script], {
322
+ detached: true,
323
+ stdio: "ignore",
324
+ });
325
+ child.unref();
326
+ }
327
+ catch {
328
+ // If we cannot spawn, silently skip.
329
+ }
351
330
  }
352
-
353
331
  /**
354
332
  * Read the cached check result (written by a *previous* invocation's
355
333
  * background process) and print a one-line update notification to stderr
356
334
  * if a newer version is available.
357
335
  */
358
336
  function maybeShowUpdateNotification(currentVersion) {
359
- try {
360
- const raw = readFileSync(getUpdateCheckCachePath(), "utf8");
361
- const cached = JSON.parse(raw);
362
- if (cached.latest && isNewerVersion(cached.latest, currentVersion)) {
363
- const cmd = currentVersion.includes("-dev.")
364
- ? "npm install -g nebula-ai@dev"
365
- : "npm install -g nebula-ai";
366
- process.stderr.write(
367
- `\nnebula-ai: update available ${currentVersion} \u2192 ${cached.latest}\n` +
368
- ` Run: ${cmd}\n`,
369
- );
370
- }
371
- } catch {
372
- // Cache missing or corrupt — do nothing.
373
- }
337
+ try {
338
+ const raw = readFileSync(getUpdateCheckCachePath(), "utf8");
339
+ const cached = JSON.parse(raw);
340
+ 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";
344
+ process.stderr.write(`\nnebula-ai: update available ${currentVersion} \u2192 ${cached.latest}\n` +
345
+ ` Run: ${cmd}\n`);
346
+ }
347
+ }
348
+ catch {
349
+ // Cache missing or corrupt — do nothing.
350
+ }
374
351
  }
375
-
376
352
  async function main() {
377
- if (DOWNLOAD_BASE_URL.includes("__NEBULA_AI_DOWNLOAD_BASE__")) {
378
- console.error(
379
- "nebula-ai: unable to download — this installation appears to be incomplete.\n" +
380
- "\n" +
381
- "To fix this, reinstall nebula-ai:\n" +
382
- " npm install -g nebula-ai\n" +
383
- "\n" +
384
- "If the problem persists, please open an issue:\n" +
385
- " https://github.com/thirdweb-dev/nebula-cli/issues",
386
- );
387
- process.exit(1);
388
- }
389
-
390
- if (!SUPPORTED_PLATFORMS.has(platformKey)) {
391
- console.error(
392
- `nebula-ai: your platform (${platformKey}) is not yet supported.\n` +
393
- `\n` +
394
- `Supported platforms: ${[...SUPPORTED_PLATFORMS].join(", ")}.\n` +
395
- `\n` +
396
- `Please open an issue to request support:\n` +
397
- ` https://github.com/thirdweb-dev/nebula-cli/issues`,
398
- );
399
- process.exit(1);
400
- }
401
-
402
- let version;
403
- try {
404
- version = await readVersion();
405
- } catch (err) {
406
- console.error(
407
- `nebula-ai: unable to determine the installed version.\n` +
408
- `\n` +
409
- `This usually means the installation is corrupted. To fix this, reinstall:\n` +
410
- ` npm install -g nebula-ai`,
411
- );
412
- process.exit(1);
413
- }
414
-
415
- const cacheDir = getCacheDir(version);
416
- const binaryPath = join(cacheDir, binName);
417
-
418
- if (!existsSync(binaryPath)) {
353
+ if (DOWNLOAD_BASE_URL.includes("__NEBULA_AI_DOWNLOAD_BASE__")) {
354
+ console.error("nebula-ai: unable to download — this installation appears to be incomplete.\n" +
355
+ "\n" +
356
+ "To fix this, reinstall nebula-ai:\n" +
357
+ " npm install -g nebula-ai\n" +
358
+ "\n" +
359
+ "If the problem persists, please open an issue:\n" +
360
+ " https://github.com/thirdweb-dev/nebula-cli/issues");
361
+ process.exit(1);
362
+ }
363
+ if (!SUPPORTED_PLATFORMS.has(platformKey)) {
364
+ console.error(`nebula-ai: your platform (${platformKey}) is not yet supported.\n` +
365
+ `\n` +
366
+ `Supported platforms: ${[...SUPPORTED_PLATFORMS].join(", ")}.\n` +
367
+ `\n` +
368
+ `Please open an issue to request support:\n` +
369
+ ` https://github.com/thirdweb-dev/nebula-cli/issues`);
370
+ process.exit(1);
371
+ }
372
+ let version;
419
373
  try {
420
- await downloadBinary(version, binaryPath);
421
- } catch (err) {
422
- console.error(
423
- `nebula-ai: could not download the runtime.\n` +
424
- `\n` +
425
- `This is usually a network issue. Please check your internet connection and try again.\n` +
426
- `\n` +
427
- `If the problem persists:\n` +
428
- ` • Clear the download cache: rm -rf ${getCacheDir(version)}\n` +
429
- ` • Reinstall: npm install -g nebula-ai\n` +
430
- ` • Open an issue: https://github.com/thirdweb-dev/nebula-cli/issues`,
431
- );
432
- process.exit(1);
433
- }
434
- }
435
-
436
- // Fire-and-forget background version check (writes cache for next run).
437
- if (!isUpdateCheckDisabled() && !isNpx()) {
438
- maybeSpawnUpdateCheck(version);
439
- }
440
-
441
- const result = spawnSync(binaryPath, process.argv.slice(2), {
442
- stdio: "inherit",
443
- });
444
-
445
- // Show update notification (reads PREVIOUS check's cached result).
446
- if (!isUpdateCheckDisabled() && !isNpx()) {
447
- maybeShowUpdateNotification(version);
448
- }
449
-
450
- if (result.error) {
451
- console.error(
452
- `nebula-ai: unable to start the application.\n` +
453
- `\n` +
454
- `This can happen if the downloaded runtime was corrupted.\n` +
455
- `\n` +
456
- `To fix this:\n` +
457
- ` 1. Clear the cache: rm -rf ${getCacheDir(version)}\n` +
458
- ` 2. Run nebula-ai again to re-download`,
459
- );
460
- process.exit(1);
461
- }
462
- process.exit(result.status ?? 0);
374
+ version = await readVersion();
375
+ }
376
+ catch (err) {
377
+ console.error(`nebula-ai: unable to determine the installed version.\n` +
378
+ `\n` +
379
+ `This usually means the installation is corrupted. To fix this, reinstall:\n` +
380
+ ` npm install -g nebula-ai`);
381
+ process.exit(1);
382
+ }
383
+ const cacheDir = getCacheDir(version);
384
+ const binaryPath = join(cacheDir, binName);
385
+ if (!existsSync(binaryPath)) {
386
+ try {
387
+ await downloadBinary(version, binaryPath);
388
+ }
389
+ catch (err) {
390
+ console.error(`nebula-ai: could not download the runtime.\n` +
391
+ `\n` +
392
+ `This is usually a network issue. Please check your internet connection and try again.\n` +
393
+ `\n` +
394
+ `If the problem persists:\n` +
395
+ ` • Clear the download cache: rm -rf ${getCacheDir(version)}\n` +
396
+ ` • Reinstall: npm install -g nebula-ai\n` +
397
+ ` • Open an issue: https://github.com/thirdweb-dev/nebula-cli/issues`);
398
+ process.exit(1);
399
+ }
400
+ // New version downloaded — purge stale version directories.
401
+ cleanupOldVersions(version);
402
+ }
403
+ // Fire-and-forget background version check (writes cache for next run).
404
+ if (!isUpdateCheckDisabled() && !isNpx()) {
405
+ maybeSpawnUpdateCheck(version);
406
+ }
407
+ const result = spawnSync(binaryPath, process.argv.slice(2), {
408
+ stdio: "inherit",
409
+ });
410
+ // Show update notification (reads PREVIOUS check's cached result).
411
+ if (!isUpdateCheckDisabled() && !isNpx()) {
412
+ maybeShowUpdateNotification(version);
413
+ }
414
+ if (result.error) {
415
+ console.error(`nebula-ai: unable to start the application.\n` +
416
+ `\n` +
417
+ `This can happen if the downloaded runtime was corrupted.\n` +
418
+ `\n` +
419
+ `To fix this:\n` +
420
+ ` 1. Clear the cache: rm -rf ${getCacheDir(version)}\n` +
421
+ ` 2. Run nebula-ai again to re-download`);
422
+ process.exit(1);
423
+ }
424
+ process.exit(result.status ?? 0);
463
425
  }
464
-
465
426
  // Only run main() when this file is the CLI entrypoint, so tests can
466
427
  // import the pure helpers above without triggering a download.
467
428
  //
@@ -471,16 +432,16 @@ async function main() {
471
432
  // a naive string compare would always fail and main() would silently never
472
433
  // run. realpathSync both sides so the comparison survives the bin shim.
473
434
  function isCliEntrypoint() {
474
- if (!process.argv[1]) return false;
475
- try {
476
- return (
477
- realpathSync(fileURLToPath(import.meta.url)) ===
478
- realpathSync(process.argv[1])
479
- );
480
- } catch {
481
- return false;
482
- }
435
+ if (!process.argv[1])
436
+ return false;
437
+ try {
438
+ return (realpathSync(fileURLToPath(import.meta.url)) ===
439
+ realpathSync(process.argv[1]));
440
+ }
441
+ catch {
442
+ return false;
443
+ }
483
444
  }
484
445
  if (isCliEntrypoint()) {
485
- main();
446
+ main();
486
447
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nebula-ai",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "CLI/TUI for the nebula.gg AI task orchestration platform",
5
5
  "type": "module",
6
6
  "bin": {