rauta-ai 0.4.9 → 0.5.101
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 +396 -53
- package/bin/rauta-box.js +12 -0
- package/package.json +1 -1
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,359 @@ function candidateUrls() {
|
|
|
92
112
|
urls.push(`${base}/${artifact}`);
|
|
93
113
|
}
|
|
94
114
|
}
|
|
95
|
-
// Deduplicate
|
|
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
|
+
* scripts/build-cli.sh now emits one beside every artifact it publishes,
|
|
270
|
+
* including the back-compat version directories, so on a current deploy this
|
|
271
|
+
* returns a digest and the caller verifies for real.
|
|
272
|
+
*
|
|
273
|
+
* Degrading to null rather than failing is still deliberate, for one release
|
|
274
|
+
* more. A shim published BEFORE the build emitted checksums is pinned to its
|
|
275
|
+
* own /cli/<version>/ directory, and hard-failing here would break those
|
|
276
|
+
* installs on the one path they cannot update from. Once no live shim predates
|
|
277
|
+
* the change, absence becomes worth escalating to a refusal.
|
|
278
|
+
*
|
|
279
|
+
* That escalation matters more than it sounds: an unverified download is how a
|
|
280
|
+
* 2048-byte file full of 'B' came to be installed as the binary on the
|
|
281
|
+
* maintainer's own machine (found 2026-08-12), where it had been silently
|
|
282
|
+
* failing every session-start hook with nothing anywhere reporting it.
|
|
283
|
+
*/
|
|
284
|
+
async function publishedChecksum(url) {
|
|
285
|
+
let res;
|
|
286
|
+
try {
|
|
287
|
+
res = await fetchUrl(`${url}.sha256`);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
if (res.status !== 200) return null;
|
|
292
|
+
// The sidecar is served by the same CDN, so it can come back as the same HTML
|
|
293
|
+
// fallback. A real one is a short line of `<digest> <filename>`; anything
|
|
294
|
+
// else is treated as "not published" rather than as a mismatch, because
|
|
295
|
+
// failing an install on a stray hex string in a web page would be absurd.
|
|
296
|
+
const text = res.body.toString('utf8');
|
|
297
|
+
if (text.length > 4096 || /^\s*</.test(text)) return null;
|
|
298
|
+
const match = /^\s*([a-f0-9]{64})\b/i.exec(text);
|
|
299
|
+
return match ? match[1].toLowerCase() : null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sha256(buf) {
|
|
303
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Land verified bytes at `dest` atomically: write a private temp file in the
|
|
308
|
+
* same directory, make it executable only once it is complete, then rename.
|
|
309
|
+
* The temp name carries randomness as well as the pid because a cache directory
|
|
310
|
+
* can be a shared mount where pids repeat.
|
|
311
|
+
*/
|
|
312
|
+
function installBinary(buf, dest) {
|
|
313
|
+
const tmp = `${dest}.download-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
314
|
+
try {
|
|
315
|
+
fs.writeFileSync(tmp, buf, { mode: 0o600 });
|
|
316
|
+
fs.chmodSync(tmp, 0o755);
|
|
317
|
+
fs.renameSync(tmp, dest);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
removeQuietly(tmp);
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Read the first bytes of a cached file without loading the whole binary. */
|
|
325
|
+
function cachedLooksExecutable(bin) {
|
|
326
|
+
let fd;
|
|
327
|
+
try {
|
|
328
|
+
fd = fs.openSync(bin, 'r');
|
|
329
|
+
const head = Buffer.alloc(4);
|
|
330
|
+
const read = fs.readSync(fd, head, 0, 4, 0);
|
|
331
|
+
return read === 4 && looksExecutable(head);
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return false;
|
|
334
|
+
} finally {
|
|
335
|
+
if (fd !== undefined) {
|
|
336
|
+
try {
|
|
337
|
+
fs.closeSync(fd);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Nothing useful to do about a failed close on a read-only probe.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Build the message for "every candidate URL failed". */
|
|
346
|
+
function downloadFailure(attempts) {
|
|
347
|
+
const lines = [
|
|
348
|
+
`could not download the Rauta CLI v${pkg.version} for ${process.platform}/${process.arch}.`,
|
|
349
|
+
'Tried, in order:',
|
|
350
|
+
];
|
|
351
|
+
for (const attempt of attempts) {
|
|
352
|
+
lines.push(` ${attempt.url}`);
|
|
353
|
+
lines.push(` ${attempt.message}`);
|
|
354
|
+
}
|
|
355
|
+
lines.push('Nothing was installed and the cache is unchanged.');
|
|
356
|
+
if (attempts.every((a) => a.network)) {
|
|
357
|
+
lines.push(
|
|
358
|
+
'Every attempt failed before the CDN answered, which usually means no route out.',
|
|
359
|
+
'If you are behind an egress proxy or use an internal mirror, point the shim at it:',
|
|
360
|
+
' RAUTA_CLI_BASE=https://mirror.example.com/cli npx rauta-ai <command>',
|
|
361
|
+
);
|
|
362
|
+
} else {
|
|
363
|
+
lines.push(
|
|
364
|
+
`Next step: check ${DOCS} for the current install command, or set RAUTA_CLI_BASE`,
|
|
365
|
+
'to a mirror that carries the artifact above.',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return lines;
|
|
369
|
+
}
|
|
370
|
+
|
|
100
371
|
async function ensureBinary(bin) {
|
|
101
|
-
if (fs.existsSync(bin))
|
|
102
|
-
|
|
372
|
+
if (fs.existsSync(bin)) {
|
|
373
|
+
if (cachedLooksExecutable(bin)) return;
|
|
374
|
+
// A cache poisoned by an older shim that accepted an error page. Left in
|
|
375
|
+
// place it fails ENOEXEC on every single run, forever, and deleting the
|
|
376
|
+
// cache by hand just downloads the same page again.
|
|
377
|
+
removeQuietly(bin);
|
|
378
|
+
process.stderr.write(
|
|
379
|
+
`rauta-ai: the cached file at ${bin} was not an executable, so it was deleted.\n` +
|
|
380
|
+
'rauta-ai: downloading a fresh copy.\n',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
fs.mkdirSync(path.dirname(bin), { recursive: true });
|
|
385
|
+
} catch (err) {
|
|
386
|
+
fail(
|
|
387
|
+
`cannot create the cache directory ${path.dirname(bin)} (${err.code || err.message}).`,
|
|
388
|
+
'The CLI is downloaded once and cached there, so nothing can run without it.',
|
|
389
|
+
'Nothing was installed.',
|
|
390
|
+
'Next step: point the cache at a directory you own --',
|
|
391
|
+
' XDG_CACHE_HOME=$HOME/.cache npx rauta-ai <command>',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
103
394
|
const urls = candidateUrls();
|
|
104
395
|
process.stderr.write(
|
|
105
|
-
`Fetching rauta-ai v${pkg.version} for ${process.platform}/${process.arch}
|
|
396
|
+
`Fetching rauta-ai v${pkg.version} for ${process.platform}/${process.arch}...\n`,
|
|
106
397
|
);
|
|
107
|
-
|
|
398
|
+
const attempts = [];
|
|
108
399
|
for (const url of urls) {
|
|
400
|
+
let body;
|
|
109
401
|
try {
|
|
110
|
-
await
|
|
111
|
-
return;
|
|
402
|
+
body = await fetchArtifact(url);
|
|
112
403
|
} catch (err) {
|
|
113
|
-
|
|
404
|
+
attempts.push({
|
|
405
|
+
url,
|
|
406
|
+
message: err.message,
|
|
407
|
+
network: isNetworkError(err),
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
114
410
|
}
|
|
411
|
+
const expected = await publishedChecksum(url);
|
|
412
|
+
if (expected === null) {
|
|
413
|
+
process.stderr.write(
|
|
414
|
+
`rauta-ai: no checksum published at ${url}.sha256, so integrity was not verified.\n`,
|
|
415
|
+
);
|
|
416
|
+
} else {
|
|
417
|
+
const actual = sha256(body);
|
|
418
|
+
if (actual !== expected) {
|
|
419
|
+
fail(
|
|
420
|
+
`the binary at ${url} does not match its published checksum.`,
|
|
421
|
+
` expected ${expected}`,
|
|
422
|
+
` actual ${actual}`,
|
|
423
|
+
'That means the download was corrupted or tampered with in transit.',
|
|
424
|
+
'Nothing was installed and the cache is unchanged.',
|
|
425
|
+
`Next step: retry on a different network, or report it at ${DOCS}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
process.stderr.write(`rauta-ai: checksum verified (sha256 ${actual.slice(0, 12)}...).\n`);
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
installBinary(body, bin);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
fail(
|
|
434
|
+
`downloaded the CLI but could not save it to ${bin} (${err.code || err.message}).`,
|
|
435
|
+
'The download itself was fine, so this is about permissions or disk space.',
|
|
436
|
+
'Nothing was installed.',
|
|
437
|
+
'Next step: free some space, or use a cache directory you own --',
|
|
438
|
+
' XDG_CACHE_HOME=$HOME/.cache npx rauta-ai <command>',
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
115
442
|
}
|
|
116
|
-
|
|
443
|
+
fail.apply(null, downloadFailure(attempts));
|
|
117
444
|
}
|
|
118
445
|
|
|
119
446
|
async function main() {
|
|
120
447
|
const bin = cachedBinaryPath();
|
|
121
448
|
await ensureBinary(bin);
|
|
122
449
|
const result = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
|
|
123
|
-
if (result.error)
|
|
450
|
+
if (result.error) {
|
|
451
|
+
const code = result.error.code;
|
|
452
|
+
if (code === 'ENOEXEC' || code === 'EACCES') {
|
|
453
|
+
removeQuietly(bin);
|
|
454
|
+
fail(
|
|
455
|
+
`the cached CLI at ${bin} could not be executed (${code}).`,
|
|
456
|
+
'It has been deleted, so the next run downloads a fresh copy.',
|
|
457
|
+
'If this repeats, something on the network is replacing the download:',
|
|
458
|
+
`set RAUTA_CLI_BASE to a mirror you trust, or install from ${DOCS}`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
fail(
|
|
462
|
+
`could not start the Rauta CLI: ${result.error.message}`,
|
|
463
|
+
`The binary is installed at ${bin} and was left in place.`,
|
|
464
|
+
`Next step: run it directly to see the underlying error -- ${bin} version`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
124
467
|
process.exit(result.status === null ? 1 : result.status);
|
|
125
468
|
}
|
|
126
469
|
|
|
127
|
-
main().catch((err) => fail(err.message));
|
|
470
|
+
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.
|
|
3
|
+
"version": "0.5.101",
|
|
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",
|