rauta-ai 0.4.8 → 0.5.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/bin/rauta-ai.js CHANGED
@@ -2,13 +2,24 @@
2
2
  // npx wrapper for the Rauta CLI (https://rauta.ai/docs).
3
3
  //
4
4
  // Package name is `rauta-ai` until npm clears unscoped `rauta` (similarity
5
- // filter vs ramda/auto/oauth). Prefer CDN artifacts named rauta-*; fall back
6
- // to infra-arena-* on both rauta.ai and rauta.ai during the interim
7
- // rename window. This shim downloads once, caches it, and execs with stdio
8
- // passed through.
5
+ // filter vs ramda/auto/oauth). Prefer CDN artifacts named rauta-*; fall back to
6
+ // infra-arena-* during the interim rename window. This shim downloads the
7
+ // platform binary once, caches it, and execs it with stdio passed through.
8
+ //
9
+ // It verifies before it trusts, because the CDN's failure mode is not a 404.
10
+ // When a path is missing, Pages answers 200 with the website's index.html --
11
+ // the outage shape scripts/build-cli.sh documents. The previous version of this
12
+ // file rejected only on statusCode !== 200, so it wrote that HTML into the
13
+ // cache at mode 0755 and exec'd it. Every later run then failed ENOEXEC against
14
+ // the cached page, and `rm -rf ~/.cache/rauta-ai` only downloaded the same HTML
15
+ // again: an infinite loop with no way out. So the content type, the magic
16
+ // bytes, the declared length and (once published) a sha256 are all checked
17
+ // BEFORE the bytes are allowed near the cache, and a cache entry that is not an
18
+ // executable image is discarded on sight rather than exec'd.
9
19
  'use strict';
10
20
 
11
21
  const { spawnSync } = require('node:child_process');
22
+ const crypto = require('node:crypto');
12
23
  const fs = require('node:fs');
13
24
  const https = require('node:https');
14
25
  const os = require('node:os');
@@ -16,13 +27,49 @@ const path = require('node:path');
16
27
 
17
28
  const pkg = require('../package.json');
18
29
  const PRIMARY_BASE = process.env.RAUTA_CLI_BASE || process.env.INFRA_ARENA_CLI_BASE || 'https://rauta.ai/cli';
30
+ // Currently identical to PRIMARY_BASE, so candidateUrls() dedupes down to the
31
+ // versioned and unversioned paths of two artifact names -- four URLs, not
32
+ // eight. It stays a separate constant so an env override of PRIMARY_BASE (an
33
+ // internal mirror) still falls back to the public CDN.
19
34
  const FALLBACK_BASE = 'https://rauta.ai/cli';
20
35
 
21
- function fail(msg) {
22
- process.stderr.write(`rauta-ai: ${msg}\n`);
36
+ // Socket inactivity, not total transfer time: a slow link downloading 30MB must
37
+ // not be cut off, but a proxy that accepts the connection and then says nothing
38
+ // must not hang the terminal forever.
39
+ const IDLE_TIMEOUT_MS = 60000;
40
+
41
+ const DOCS = 'https://rauta.ai/docs';
42
+
43
+ // The executable images we publish: linux (ELF) and darwin (Mach-O, thin or
44
+ // universal). The check answers "is this a binary or an error page", not "is
45
+ // this the right architecture" -- a wrong-arch binary is a build bug and shows
46
+ // up as a clear ENOEXEC below, whereas an error page is the failure that used
47
+ // to poison the cache.
48
+ const EXECUTABLE_MAGIC = [
49
+ '7f454c46', // ELF
50
+ 'feedface', // Mach-O 32, big-endian
51
+ 'feedfacf', // Mach-O 64, big-endian
52
+ 'cefaedfe', // Mach-O 32, little-endian
53
+ 'cffaedfe', // Mach-O 64, little-endian
54
+ 'cafebabe', // Mach-O universal
55
+ 'bebafeca', // Mach-O universal, byte-swapped
56
+ ];
57
+
58
+ /** Print one or more stderr lines prefixed with the tool name, then exit 1. */
59
+ function fail() {
60
+ const lines = Array.prototype.slice.call(arguments);
61
+ process.stderr.write(`rauta-ai: ${lines.join('\n')}\n`);
23
62
  process.exit(1);
24
63
  }
25
64
 
65
+ function removeQuietly(file) {
66
+ try {
67
+ fs.unlinkSync(file);
68
+ } catch (err) {
69
+ // Already gone, or never created. Either way there is nothing to clean up.
70
+ }
71
+ }
72
+
26
73
  function platformArch() {
27
74
  const platforms = { darwin: 'darwin', linux: 'linux' };
28
75
  const arches = { arm64: 'arm64', x64: 'amd64' };
@@ -30,8 +77,9 @@ function platformArch() {
30
77
  const arch = arches[process.arch];
31
78
  if (!platform || !arch) {
32
79
  fail(
33
- `no prebuilt binary for ${process.platform}/${process.arch} — ` +
34
- 'darwin/linux on arm64/amd64 are published. See https://rauta.ai/docs',
80
+ `no prebuilt binary for ${process.platform}/${process.arch}.`,
81
+ 'Published builds are darwin and linux, on arm64 and amd64.',
82
+ `Nothing was installed. Build from source or ask for this target: ${DOCS}`,
35
83
  );
36
84
  }
37
85
  return { platform, arch };
@@ -49,41 +97,13 @@ function cachedBinaryPath() {
49
97
  return path.join(cacheRoot, 'rauta-ai', `v${pkg.version}`, 'rauta-ai');
50
98
  }
51
99
 
52
- function download(url, dest, redirects = 0) {
53
- return new Promise((resolve, reject) => {
54
- if (redirects > 5) return reject(new Error('too many redirects'));
55
- https
56
- .get(url, { headers: { 'user-agent': `rauta-npx/${pkg.version}` } }, (res) => {
57
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
58
- res.resume();
59
- return resolve(download(new URL(res.headers.location, url).href, dest, redirects + 1));
60
- }
61
- if (res.statusCode !== 200) {
62
- res.resume();
63
- return reject(new Error(`download failed: ${res.statusCode} ${url}`));
64
- }
65
- const tmp = `${dest}.download-${process.pid}`;
66
- const out = fs.createWriteStream(tmp, { mode: 0o755 });
67
- res.pipe(out);
68
- out.on('finish', () =>
69
- out.close(() => {
70
- try {
71
- fs.renameSync(tmp, dest);
72
- resolve();
73
- } catch (err) {
74
- reject(err);
75
- }
76
- }),
77
- );
78
- out.on('error', reject);
79
- res.on('error', reject);
80
- })
81
- .on('error', reject);
82
- });
83
- }
84
-
85
100
  function candidateUrls() {
86
- const bases = [PRIMARY_BASE, FALLBACK_BASE];
101
+ // An explicitly configured mirror is used on its own. Silently reaching out
102
+ // to the public CDN behind an operator's back can breach an egress policy,
103
+ // and the second base exists for the artifact RENAME window rather than for
104
+ // host failover -- by default both constants are the same host anyway.
105
+ const overridden = Boolean(process.env.RAUTA_CLI_BASE || process.env.INFRA_ARENA_CLI_BASE);
106
+ const bases = overridden ? [PRIMARY_BASE] : [PRIMARY_BASE, FALLBACK_BASE];
87
107
  const artifacts = artifactNames();
88
108
  const urls = [];
89
109
  for (const artifact of artifacts) {
@@ -92,36 +112,349 @@ function candidateUrls() {
92
112
  urls.push(`${base}/${artifact}`);
93
113
  }
94
114
  }
95
- // Deduplicate if PRIMARY === FALLBACK via env override.
115
+ // Deduplicate, preserving order: the first entry is the canonical artifact at
116
+ // the version this package pins, and error reporting leads with it.
96
117
  const seen = new Set();
97
118
  return urls.filter((u) => (seen.has(u) ? false : (seen.add(u), true)));
98
119
  }
99
120
 
121
+ /** First four bytes as lowercase hex, or '' when the file is shorter. */
122
+ function magicOf(buf) {
123
+ return buf.length >= 4 ? buf.subarray(0, 4).toString('hex') : '';
124
+ }
125
+
126
+ function looksExecutable(buf) {
127
+ return EXECUTABLE_MAGIC.indexOf(magicOf(buf)) !== -1;
128
+ }
129
+
130
+ /** Plain-English name for whatever arrived, for an error a human can act on. */
131
+ function describeContent(buf, contentType) {
132
+ const head = buf.subarray(0, 512).toString('utf8').trim().toLowerCase();
133
+ if (head.indexOf('<!doctype html') === 0 || head.indexOf('<html') === 0) {
134
+ return 'an HTML page';
135
+ }
136
+ if (head.charAt(0) === '{' || head.charAt(0) === '[') return 'a JSON document';
137
+ if (contentType) return `content of type ${contentType.split(';')[0]}`;
138
+ return 'something that is not an executable';
139
+ }
140
+
141
+ function statusExplanation(status) {
142
+ if (status === 404) return 'not found -- nothing is published at that path';
143
+ if (status === 403) return 'forbidden -- the CDN refused the request';
144
+ if (status >= 500) return 'server error at the CDN';
145
+ return 'unexpected status';
146
+ }
147
+
148
+ /** True for transport failures (no HTTP response at all). */
149
+ function isNetworkError(err) {
150
+ const code = err && err.code;
151
+ return (
152
+ code === 'ENOTFOUND' ||
153
+ code === 'EAI_AGAIN' ||
154
+ code === 'ECONNREFUSED' ||
155
+ code === 'ECONNRESET' ||
156
+ code === 'ETIMEDOUT' ||
157
+ code === 'EHOSTUNREACH' ||
158
+ code === 'ENETUNREACH' ||
159
+ code === 'CERT_HAS_EXPIRED' ||
160
+ code === 'RAUTA_TIMEOUT'
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Fetch a URL into memory, following redirects. Resolves with the HTTP status,
166
+ * headers and body for ANY status -- callers decide what a status means. It
167
+ * rejects only when no complete response arrived (DNS, TLS, timeout, a
168
+ * connection dropped mid-body).
169
+ *
170
+ * The body is buffered rather than streamed to disk on purpose: nothing touches
171
+ * the filesystem until it has been verified, so a rejected download cannot
172
+ * leave a temp file behind or race an exec.
173
+ */
174
+ function fetchUrl(url, redirects) {
175
+ redirects = redirects || 0;
176
+ return new Promise((resolve, reject) => {
177
+ if (redirects > 5) {
178
+ reject(new Error(`too many redirects fetching ${url}`));
179
+ return;
180
+ }
181
+ const options = {
182
+ headers: {
183
+ 'user-agent': `rauta-npx/${pkg.version}`,
184
+ // Identity, explicitly: Node does not decompress responses, so a gzipped
185
+ // body would arrive as gzip bytes and fail the magic-byte check even
186
+ // though the artifact was perfectly good.
187
+ 'accept-encoding': 'identity',
188
+ accept: 'application/octet-stream, */*',
189
+ },
190
+ };
191
+ let settled = false;
192
+ const req = https.get(url, options, (res) => {
193
+ const status = res.statusCode;
194
+ if (status >= 300 && status < 400 && res.headers.location) {
195
+ res.resume();
196
+ const next = new URL(res.headers.location, url).href;
197
+ settled = true;
198
+ fetchUrl(next, redirects + 1).then(resolve, reject);
199
+ return;
200
+ }
201
+ const chunks = [];
202
+ res.on('data', (chunk) => chunks.push(chunk));
203
+ res.on('aborted', () => {
204
+ if (settled) return;
205
+ settled = true;
206
+ const err = new Error(`the connection closed before ${url} finished downloading`);
207
+ err.code = 'ECONNRESET';
208
+ reject(err);
209
+ });
210
+ res.on('error', (err) => {
211
+ if (settled) return;
212
+ settled = true;
213
+ reject(err);
214
+ });
215
+ res.on('end', () => {
216
+ if (settled) return;
217
+ settled = true;
218
+ resolve({ status, headers: res.headers, body: Buffer.concat(chunks) });
219
+ });
220
+ });
221
+ req.on('error', (err) => {
222
+ if (settled) return;
223
+ settled = true;
224
+ reject(err);
225
+ });
226
+ req.setTimeout(IDLE_TIMEOUT_MS, () => {
227
+ const err = new Error(
228
+ `${url} stopped responding after ${IDLE_TIMEOUT_MS / 1000}s`,
229
+ );
230
+ err.code = 'RAUTA_TIMEOUT';
231
+ req.destroy(err);
232
+ });
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Fetch one artifact URL and prove it is an executable before returning it.
238
+ * Rejects with a message that names the URL, so the caller can attribute a
239
+ * failure to the exact path that produced it.
240
+ */
241
+ async function fetchArtifact(url) {
242
+ const res = await fetchUrl(url);
243
+ if (res.status !== 200) {
244
+ const err = new Error(`HTTP ${res.status} (${statusExplanation(res.status)})`);
245
+ err.httpStatus = res.status;
246
+ throw err;
247
+ }
248
+ const contentType = String(res.headers['content-type'] || '');
249
+ const declared = Number(res.headers['content-length']);
250
+ if (Number.isFinite(declared) && declared !== res.body.length) {
251
+ throw new Error(
252
+ `truncated: the CDN promised ${declared} bytes and sent ${res.body.length}`,
253
+ );
254
+ }
255
+ if (!looksExecutable(res.body)) {
256
+ // The cached-HTML loop starts here, and this is where it now stops.
257
+ throw new Error(
258
+ `answered 200 with ${describeContent(res.body, contentType)}, not an executable` +
259
+ ' (a CDN serves the website when the artifact path is missing)',
260
+ );
261
+ }
262
+ return res.body;
263
+ }
264
+
265
+ /**
266
+ * Look for `<artifact>.sha256` beside the binary. Returns the hex digest, or
267
+ * null when none is published.
268
+ *
269
+ * Degrading to null rather than failing is deliberate and temporary: no
270
+ * `.sha256` file is published today (scripts/build-cli.sh does not emit one),
271
+ * so hard-failing here would break every install the moment this ships. Once
272
+ * the build publishes checksums, absence becomes worth escalating.
273
+ */
274
+ async function publishedChecksum(url) {
275
+ let res;
276
+ try {
277
+ res = await fetchUrl(`${url}.sha256`);
278
+ } catch (err) {
279
+ return null;
280
+ }
281
+ if (res.status !== 200) return null;
282
+ // The sidecar is served by the same CDN, so it can come back as the same HTML
283
+ // fallback. A real one is a short line of `<digest> <filename>`; anything
284
+ // else is treated as "not published" rather than as a mismatch, because
285
+ // failing an install on a stray hex string in a web page would be absurd.
286
+ const text = res.body.toString('utf8');
287
+ if (text.length > 4096 || /^\s*</.test(text)) return null;
288
+ const match = /^\s*([a-f0-9]{64})\b/i.exec(text);
289
+ return match ? match[1].toLowerCase() : null;
290
+ }
291
+
292
+ function sha256(buf) {
293
+ return crypto.createHash('sha256').update(buf).digest('hex');
294
+ }
295
+
296
+ /**
297
+ * Land verified bytes at `dest` atomically: write a private temp file in the
298
+ * same directory, make it executable only once it is complete, then rename.
299
+ * The temp name carries randomness as well as the pid because a cache directory
300
+ * can be a shared mount where pids repeat.
301
+ */
302
+ function installBinary(buf, dest) {
303
+ const tmp = `${dest}.download-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
304
+ try {
305
+ fs.writeFileSync(tmp, buf, { mode: 0o600 });
306
+ fs.chmodSync(tmp, 0o755);
307
+ fs.renameSync(tmp, dest);
308
+ } catch (err) {
309
+ removeQuietly(tmp);
310
+ throw err;
311
+ }
312
+ }
313
+
314
+ /** Read the first bytes of a cached file without loading the whole binary. */
315
+ function cachedLooksExecutable(bin) {
316
+ let fd;
317
+ try {
318
+ fd = fs.openSync(bin, 'r');
319
+ const head = Buffer.alloc(4);
320
+ const read = fs.readSync(fd, head, 0, 4, 0);
321
+ return read === 4 && looksExecutable(head);
322
+ } catch (err) {
323
+ return false;
324
+ } finally {
325
+ if (fd !== undefined) {
326
+ try {
327
+ fs.closeSync(fd);
328
+ } catch (err) {
329
+ // Nothing useful to do about a failed close on a read-only probe.
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ /** Build the message for "every candidate URL failed". */
336
+ function downloadFailure(attempts) {
337
+ const lines = [
338
+ `could not download the Rauta CLI v${pkg.version} for ${process.platform}/${process.arch}.`,
339
+ 'Tried, in order:',
340
+ ];
341
+ for (const attempt of attempts) {
342
+ lines.push(` ${attempt.url}`);
343
+ lines.push(` ${attempt.message}`);
344
+ }
345
+ lines.push('Nothing was installed and the cache is unchanged.');
346
+ if (attempts.every((a) => a.network)) {
347
+ lines.push(
348
+ 'Every attempt failed before the CDN answered, which usually means no route out.',
349
+ 'If you are behind an egress proxy or use an internal mirror, point the shim at it:',
350
+ ' RAUTA_CLI_BASE=https://mirror.example.com/cli npx rauta-ai <command>',
351
+ );
352
+ } else {
353
+ lines.push(
354
+ `Next step: check ${DOCS} for the current install command, or set RAUTA_CLI_BASE`,
355
+ 'to a mirror that carries the artifact above.',
356
+ );
357
+ }
358
+ return lines;
359
+ }
360
+
100
361
  async function ensureBinary(bin) {
101
- if (fs.existsSync(bin)) return;
102
- fs.mkdirSync(path.dirname(bin), { recursive: true });
362
+ if (fs.existsSync(bin)) {
363
+ if (cachedLooksExecutable(bin)) return;
364
+ // A cache poisoned by an older shim that accepted an error page. Left in
365
+ // place it fails ENOEXEC on every single run, forever, and deleting the
366
+ // cache by hand just downloads the same page again.
367
+ removeQuietly(bin);
368
+ process.stderr.write(
369
+ `rauta-ai: the cached file at ${bin} was not an executable, so it was deleted.\n` +
370
+ 'rauta-ai: downloading a fresh copy.\n',
371
+ );
372
+ }
373
+ try {
374
+ fs.mkdirSync(path.dirname(bin), { recursive: true });
375
+ } catch (err) {
376
+ fail(
377
+ `cannot create the cache directory ${path.dirname(bin)} (${err.code || err.message}).`,
378
+ 'The CLI is downloaded once and cached there, so nothing can run without it.',
379
+ 'Nothing was installed.',
380
+ 'Next step: point the cache at a directory you own --',
381
+ ' XDG_CACHE_HOME=$HOME/.cache npx rauta-ai <command>',
382
+ );
383
+ }
103
384
  const urls = candidateUrls();
104
385
  process.stderr.write(
105
- `Fetching rauta-ai v${pkg.version} for ${process.platform}/${process.arch}…\n`,
386
+ `Fetching rauta-ai v${pkg.version} for ${process.platform}/${process.arch}...\n`,
106
387
  );
107
- let lastErr;
388
+ const attempts = [];
108
389
  for (const url of urls) {
390
+ let body;
109
391
  try {
110
- await download(url, bin);
111
- return;
392
+ body = await fetchArtifact(url);
112
393
  } catch (err) {
113
- lastErr = err;
394
+ attempts.push({
395
+ url,
396
+ message: err.message,
397
+ network: isNetworkError(err),
398
+ });
399
+ continue;
114
400
  }
401
+ const expected = await publishedChecksum(url);
402
+ if (expected === null) {
403
+ process.stderr.write(
404
+ `rauta-ai: no checksum published at ${url}.sha256, so integrity was not verified.\n`,
405
+ );
406
+ } else {
407
+ const actual = sha256(body);
408
+ if (actual !== expected) {
409
+ fail(
410
+ `the binary at ${url} does not match its published checksum.`,
411
+ ` expected ${expected}`,
412
+ ` actual ${actual}`,
413
+ 'That means the download was corrupted or tampered with in transit.',
414
+ 'Nothing was installed and the cache is unchanged.',
415
+ `Next step: retry on a different network, or report it at ${DOCS}`,
416
+ );
417
+ }
418
+ process.stderr.write(`rauta-ai: checksum verified (sha256 ${actual.slice(0, 12)}...).\n`);
419
+ }
420
+ try {
421
+ installBinary(body, bin);
422
+ } catch (err) {
423
+ fail(
424
+ `downloaded the CLI but could not save it to ${bin} (${err.code || err.message}).`,
425
+ 'The download itself was fine, so this is about permissions or disk space.',
426
+ 'Nothing was installed.',
427
+ 'Next step: free some space, or use a cache directory you own --',
428
+ ' XDG_CACHE_HOME=$HOME/.cache npx rauta-ai <command>',
429
+ );
430
+ }
431
+ return;
115
432
  }
116
- throw lastErr || new Error('download failed');
433
+ fail.apply(null, downloadFailure(attempts));
117
434
  }
118
435
 
119
436
  async function main() {
120
437
  const bin = cachedBinaryPath();
121
438
  await ensureBinary(bin);
122
439
  const result = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
123
- if (result.error) fail(result.error.message);
440
+ if (result.error) {
441
+ const code = result.error.code;
442
+ if (code === 'ENOEXEC' || code === 'EACCES') {
443
+ removeQuietly(bin);
444
+ fail(
445
+ `the cached CLI at ${bin} could not be executed (${code}).`,
446
+ 'It has been deleted, so the next run downloads a fresh copy.',
447
+ 'If this repeats, something on the network is replacing the download:',
448
+ `set RAUTA_CLI_BASE to a mirror you trust, or install from ${DOCS}`,
449
+ );
450
+ }
451
+ fail(
452
+ `could not start the Rauta CLI: ${result.error.message}`,
453
+ `The binary is installed at ${bin} and was left in place.`,
454
+ `Next step: run it directly to see the underlying error -- ${bin} version`,
455
+ );
456
+ }
124
457
  process.exit(result.status === null ? 1 : result.status);
125
458
  }
126
459
 
127
- main().catch((err) => fail(err.message));
460
+ main().catch((err) => fail(err && err.message ? err.message : String(err)));
package/bin/rauta-box.js CHANGED
@@ -25,4 +25,16 @@ const argv = args[0] === 'box' ? args : ['box', ...args];
25
25
  const result = spawnSync(process.execPath, [path.join(__dirname, 'rauta-ai.js'), ...argv], {
26
26
  stdio: 'inherit',
27
27
  });
28
+ // When the spawn itself fails, `status` is null and the delegate never ran, so
29
+ // exiting on status alone gave the user an empty terminal and a bare exit 1 --
30
+ // no output at all, nothing to search for. Say what broke and what to run.
31
+ if (result.error) {
32
+ process.stderr.write(
33
+ `rauta-box: could not start the Rauta CLI -- ${result.error.message}\n` +
34
+ 'Nothing was installed or changed.\n' +
35
+ 'Next step: run the canonical form directly -- npx rauta-ai box ' +
36
+ `${argv.slice(1).join(' ')}\n`,
37
+ );
38
+ process.exit(1);
39
+ }
28
40
  process.exit(result.status === null ? 1 : result.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rauta-ai",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for Rauta (rauta.ai) \u2014 GPU infrastructure, compared in public. Canonical install: `npx rauta-ai`. Interim package name while unscoped `rauta` awaits npm similarity clearance. Sign up, connect provider keys (BYOK), buy prepaid credits, provision GPUs, and track AI spend from your terminal.",
5
5
  "bin": {
6
6
  "rauta-ai": "bin/rauta-ai.js",