@runinfra/cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,48 @@
1
+ RunInfra CLI License
2
+
3
+ Copyright (c) 2026 RightNow AI. All rights reserved.
4
+
5
+ 1. LICENSE GRANT
6
+
7
+ RightNow AI grants you a non-exclusive, non-transferable, revocable license
8
+ to install and use this software ("the CLI") for the purpose of accessing
9
+ the RunInfra service and downloading model packages your workspace is
10
+ entitled to.
11
+
12
+ 2. RESTRICTIONS
13
+
14
+ You may not redistribute, sell, sublicense or publish the CLI or any part of
15
+ it. You may not modify the CLI and distribute the result. You may not remove
16
+ or alter this notice.
17
+
18
+ These restrictions apply to the CLI itself. They do NOT apply to the model
19
+ packages the CLI downloads: each package carries its own license, stated on
20
+ its product page and included in its kit, and that license governs your use
21
+ of that package.
22
+
23
+ 3. NO WARRANTY
24
+
25
+ THE CLI IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
28
+
29
+ The CLI writes large files to disk and transfers large amounts of data over
30
+ your network. You are responsible for available disk space, for any metered
31
+ bandwidth costs your provider charges, and for where you choose to store
32
+ downloaded packages.
33
+
34
+ 4. LIMITATION OF LIABILITY
35
+
36
+ IN NO EVENT SHALL RIGHTNOW AI BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
37
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
38
+ OUT OF OR IN CONNECTION WITH THE CLI OR THE USE OR OTHER DEALINGS IN THE CLI.
39
+
40
+ 5. TERMINATION
41
+
42
+ This license ends if you breach it, or when your entitlement to the RunInfra
43
+ service ends. On termination, stop using the CLI and delete it. Packages you
44
+ have already lawfully downloaded remain governed by their own licenses.
45
+
46
+ 6. CONTACT
47
+
48
+ https://runinfra.ai
package/README.md ADDED
@@ -0,0 +1,458 @@
1
+ # @runinfra/cli
2
+
3
+ Sign in from your terminal and pull the optimized model packages your workspace
4
+ owns.
5
+
6
+ Node 20 or newer. **Zero runtime dependencies** by design, so it installs
7
+ quickly and runs cleanly inside a slim container or a locked-down host.
8
+
9
+ ```console
10
+ $ npm install -g @runinfra/cli
11
+ $ runinfra login
12
+ $ runinfra pull <slug> --out ./models
13
+ ```
14
+
15
+ Replace `<slug>` with the slug shown on your package's page under
16
+ **Optimized models** on runinfra.ai, for example
17
+ `qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm`.
18
+
19
+ If your registry cannot find `@runinfra/cli`, the release has not reached it
20
+ yet. Run the CLI from a checkout instead, see [Development](#development).
21
+
22
+ ---
23
+
24
+ ## What the CLI never does
25
+
26
+ - **It never sees your password.** Sign-in happens in your browser, in your
27
+ existing RunInfra session. The CLI only ever holds an authorization code and,
28
+ after the exchange, the key that code bought.
29
+ - **It never asks you to paste an API key.** You do not create a key, copy a
30
+ key, or store a key by hand. Approving in the browser mints one, the CLI
31
+ writes it to a file only you can read, and revoking it in Settings kills it.
32
+ - **It never prints the key.** `whoami` and `logout` show a redacted form
33
+ (`rp_k...8fq2`). No other code path renders it.
34
+ - **The key it holds cannot spend money.** CLI keys carry `purpose = 'cli'`.
35
+ The inference gateway refuses every purpose that is not `customer`, so a
36
+ leaked credentials file cannot be turned into inference spend. It opens
37
+ exactly one door: downloading a package your workspace already owns.
38
+
39
+ ---
40
+
41
+ ## Commands
42
+
43
+ | Command | What it does |
44
+ | --- | --- |
45
+ | `runinfra login [--device]` | Connects this terminal by having you approve it in a browser. |
46
+ | `runinfra pull <slug> [--out DIR] [--concurrency N]` | Downloads a package this workspace owns. Resumable. |
47
+ | `runinfra logout` | Deletes the local credential and tells you where to revoke the key. |
48
+ | `runinfra whoami` | Shows what this machine has stored, and when its access expires. |
49
+
50
+ ---
51
+
52
+ ## `runinfra login`
53
+
54
+ ### What actually happens today
55
+
56
+ There are two flows, and the CLI picks for you.
57
+
58
+ **On a machine with a browser**, `runinfra login` opens the approval page
59
+ itself, and the approved code is delivered straight back to a listener on
60
+ `127.0.0.1`. You click Approve and the terminal connects on its own. If that
61
+ listener cannot be reached, the page shows the code so you can paste it into
62
+ the prompt that is already waiting, and the sign-in still completes.
63
+
64
+ **On a headless box** reached over SSH, in CI, or with no graphical display,
65
+ there is nothing to open, so the CLI prints a short code and you type it into
66
+ the page on whatever device does have a browser. It never puts the code in a
67
+ link: a link that arrives with the code already in it did not come from
68
+ RunInfra.
69
+
70
+ ### On a laptop
71
+
72
+ ```console
73
+ $ runinfra login
74
+ Opened your browser to approve this terminal.
75
+ https://runinfra.ai/cli/authorize?request=3b1f2e9c-illustrative-request-id
76
+ Waiting for approval. This request expires in 10 minutes.
77
+ Or paste the code shown on the approval page and press Enter:
78
+ Connected to workspace <your-workspace-id>.
79
+ Credentials stored at /home/mona/.config/runinfra/credentials.json
80
+ This access expires 2026-10-24T09:14:02.118Z.
81
+ Revoke this terminal any time from Settings, API keys.
82
+ ```
83
+
84
+ `Waiting for approval` repaints itself in place on a terminal, and is logged
85
+ once every ten seconds when the output is piped to a file.
86
+
87
+ ### On a headless SSH box
88
+
89
+ Identical, with a different reason line. The CLI does not try to open a browser
90
+ over SSH, in CI, or on a machine with no graphical display: launching
91
+ `xdg-open` there prints a stack trace and hangs.
92
+
93
+ ```console
94
+ mona@gpu-07:~$ runinfra login
95
+ Using the device flow because this is an SSH session.
96
+ To connect this terminal:
97
+ 1. On any device, open https://runinfra.ai/cli/authorize
98
+ 2. Type this code: T4WX-9BQH
99
+ The page will not fill the code in for you. If a link ever arrives with the code already in it, it did not come from RunInfra.
100
+ Waiting for approval, 891s left
101
+ Connected to workspace <your-workspace-id>.
102
+ Credentials stored at /home/mona/.config/runinfra/credentials.json
103
+ This access expires 2026-10-24T09:14:02.118Z.
104
+ Revoke this terminal any time from Settings, API keys.
105
+ ```
106
+
107
+ `runinfra login --device` skips the browser attempt entirely and prints the
108
+ code immediately. It is a convenience, not a requirement.
109
+
110
+ The code is what you type, never what you click. The page has no field that can
111
+ be prefilled from a link, and the CLI discards the RFC 8628
112
+ `verification_uri_complete` field precisely because a code-bearing link is how
113
+ an attacker gets a victim to approve the **attacker's** terminal.
114
+
115
+ ### What the browser shows
116
+
117
+ The approval page names the account, the workspace, the device label
118
+ (`user@host`, sent by the CLI), the code you typed, and the one thing it
119
+ grants: downloading optimized model packages the workspace already owns.
120
+ Approving requires a role that can deploy in that workspace. A viewer sees
121
+ "Not available for this role" instead of an Approve button, because a viewer
122
+ approval would mint a key that is refused on its first use.
123
+
124
+ ### Timing and limits
125
+
126
+ - A device code is valid for **15 minutes**. After that the CLI stops polling
127
+ and says so.
128
+ - Polling starts at one request every **5 seconds** and backs off by five more
129
+ seconds each time the server says `slow_down`.
130
+ - The minted key lives **90 days**, then `pull` refuses locally and asks you to
131
+ sign in again. Revoking from Settings ends it sooner.
132
+
133
+ ---
134
+
135
+ ## Where credentials live
136
+
137
+ One file, `credentials.json`, holding the key, the API base it was minted
138
+ against, the workspace id, the granted scope, and the expiry. No password, no
139
+ refresh token, no cookie.
140
+
141
+ | Platform | Path | Protection |
142
+ | --- | --- | --- |
143
+ | Linux, macOS | `$XDG_CONFIG_HOME/runinfra/credentials.json`, else `~/.config/runinfra/credentials.json` | Directory `0700`, file `0600` |
144
+ | Windows | `%LOCALAPPDATA%\runinfra\credentials.json` | Directory ACL rewritten to the current user only |
145
+
146
+ `RUNINFRA_CONFIG_DIR` overrides both, for CI and for mounted volumes.
147
+
148
+ macOS purists would expect `~/Library/Application Support`. Every CLI already on
149
+ that machine (gh, aws, docker, kubectl) uses `~/.config`, and matching the
150
+ neighbours beats matching the platform guideline for a file people occasionally
151
+ delete by hand.
152
+
153
+ Windows uses `%LOCALAPPDATA%` rather than `%APPDATA%` so a roaming profile never
154
+ syncs a live API key around a domain. `chmod` means nothing there (Node maps it
155
+ to the read-only attribute), so the directory ACL is rewritten instead:
156
+
157
+ ```console
158
+ icacls "%LOCALAPPDATA%\runinfra" /inheritance:r /grant:r "DOMAIN\user":(OI)(CI)F
159
+ ```
160
+
161
+ `/inheritance:r` drops inherited entries, which on a domain machine routinely
162
+ include Administrators and SYSTEM. `/grant:r` replaces any existing grant
163
+ instead of adding a second one. `(OI)(CI)F` is object- and container-inherit
164
+ full control, so the file created inside inherits it.
165
+
166
+ If that call fails, the CLI **says so** rather than implying a protection it did
167
+ not apply:
168
+
169
+ ```console
170
+ warning: Could not restrict C:\Users\mona\AppData\Local\runinfra (icacls exited with code 5). The API key is protected only by your Windows profile permissions.
171
+ ```
172
+
173
+ The mode is also applied with an explicit `chmod` on POSIX, because
174
+ `writeFile`'s `mode` option only applies when it creates the file: overwriting an
175
+ existing `0644` credentials file would otherwise keep the loose mode.
176
+
177
+ ---
178
+
179
+ ## `runinfra whoami`
180
+
181
+ Reports what **this machine** has stored, and labels it as such. There is no
182
+ identity endpoint on the CLI surface, so this does not claim a round trip it did
183
+ not make. A key revoked from Settings five minutes ago still shows here, and
184
+ fails on your next `pull`.
185
+
186
+ ```console
187
+ $ runinfra whoami
188
+ Workspace <your-workspace-id>
189
+ Key rp_k3n9 (rp_k...8fq2)
190
+ Grants catalog:download
191
+ Endpoint https://runinfra.ai
192
+ Connected 2026-07-26T09:14:02.118Z
193
+ Expires 2026-10-24T09:14:02.118Z (in 2159h 58m)
194
+ This is what this machine has stored. Access may have been revoked from Settings, API keys since it was granted.
195
+ ```
196
+
197
+ The fields go to stdout and the closing sentence to stderr, so
198
+ `runinfra whoami > terminal.txt` captures the facts without the caveat.
199
+
200
+ ---
201
+
202
+ ## Revoking access
203
+
204
+ Two different actions, and the CLI is careful not to confuse them.
205
+
206
+ **`runinfra logout` removes the local file. It does not revoke the key.**
207
+
208
+ ```console
209
+ $ runinfra logout
210
+ Removed this machine's credentials.
211
+ The key rp_k3n9 (rp_k...8fq2) is still valid until 2026-10-24T09:14:02.118Z.
212
+ To end its access now, revoke it in Settings, API keys on runinfra.ai.
213
+ ```
214
+
215
+ That wording is deliberate. A lost laptop is not handled by deleting a file you
216
+ no longer have access to.
217
+
218
+ **To actually end a terminal's access:** open **Settings, API keys** on
219
+ runinfra.ai. Every terminal you connected is listed there as
220
+ `RunInfra CLI: user@host`, with its prefix, when it was created, and when it was
221
+ last used. Revoke the row. The next command that terminal runs is refused:
222
+
223
+ ```console
224
+ $ runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm
225
+ error: This terminal's access was revoked.
226
+ Run `runinfra login` to connect it again.
227
+ ```
228
+
229
+ Revocation takes effect on the next request, and your role in the workspace is
230
+ re-read on every request too. Someone removed from a workspace, or demoted below
231
+ deploy, loses CLI access immediately rather than at their next key rotation.
232
+
233
+ One honest limit: a download URL already minted stays valid for its 30 minute
234
+ lifetime. Revoking stops new downloads from starting, it does not reach into a
235
+ transfer already in flight.
236
+
237
+ ---
238
+
239
+ ## `runinfra pull`
240
+
241
+ ```console
242
+ $ runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm --out /data/models
243
+ Downloading kit.zip (41.2 GB) into /data/models
244
+ kit.zip [########----------------] 33.4% 13.8 GB / 41.2 GB 92.4 MB/s ETA 49m 12s
245
+ Transferred 41.2 GB in 1h 14m.
246
+ Checksum verified.
247
+ Saved kit.zip.
248
+ /data/models/kit.zip
249
+ ```
250
+
251
+ The final path is the only thing on stdout, so `MODEL=$(runinfra pull ... )`
252
+ works and progress does not pollute it.
253
+
254
+ Options: `--out DIR` (defaults to the working directory) and `--concurrency N`
255
+ (1 to 16, defaults to 8).
256
+
257
+ ### How resume works
258
+
259
+ Interrupt it. Run the same command again.
260
+
261
+ ```console
262
+ ^C
263
+ Stopping. Run the same command again to resume.
264
+ error: Download interrupted.
265
+ Run the same command again to resume from the bytes already on disk.
266
+
267
+ $ runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm --out /data/models
268
+ Resuming kit.zip: 13.8 GB of 41.2 GB already on disk.
269
+ kit.zip [##########--------------] 41.7% 17.2 GB / 41.2 GB 88.1 MB/s ETA 46m 30s
270
+ ```
271
+
272
+ A second Ctrl-C exits immediately without finishing the write in progress.
273
+
274
+ Two files carry the state. `<name>.part` holds the bytes, preallocated to the
275
+ final size so a full disk is discovered now rather than at 90%.
276
+ `<name>.part.json` records which byte ranges are **durable**: a range is written
277
+ to that file only after the data is flushed to disk, so a power cut can lose
278
+ work but can never claim bytes that are not there.
279
+
280
+ Resume is refused, and the partial file discarded, in three cases. Each one
281
+ prints why:
282
+
283
+ - **The sidecar is missing or unreadable.** The `.part` is preallocated, so its
284
+ length says nothing about which bytes are real. Guessing would produce a file
285
+ that passes every check except the checksum, hours later.
286
+ - **The package was republished while you were downloading.** Version, size or
287
+ ETag moved. Continuing would splice two different artifacts into one file.
288
+ - **The final file already exists at a different size.** Nothing is overwritten;
289
+ you are told to move it or pass a different `--out`.
290
+
291
+ Everything else resumes. A dropped connection retries the affected chunk a few
292
+ times with backoff before giving up. The download URL is valid for 30 minutes and a large kit is not, so the
293
+ CLI renews it automatically 2 minutes before expiry and whenever storage refuses
294
+ a stale signature. Every renewal re-checks that the artifact is still the same
295
+ one.
296
+
297
+ ### Verification
298
+
299
+ When the catalog published a sha256 for the package, the CLI hashes the file and
300
+ compares. A mismatch renames nothing, so bad bytes never appear under the final
301
+ name:
302
+
303
+ ```console
304
+ error: The downloaded bytes do not match the published checksum for qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm.
305
+ The partial file was left at /data/models/kit.zip.part. Delete it and run the command again.
306
+ ```
307
+
308
+ When the package publishes no checksum, the CLI says the file is **UNVERIFIED**
309
+ rather than implying it checked something.
310
+
311
+ ### Before it writes anything
312
+
313
+ `pull` proves entitlement first, then probes the real size, then checks free
314
+ space against what is **left** plus 5% headroom. The failure you are most likely
315
+ to hit happens in the first two seconds rather than in hour four:
316
+
317
+ ```console
318
+ error: Not enough free space in /data/models: 12.4 GB available, 28.7 GB needed.
319
+ Free some space or pass --out to a larger volume, then run the same command again to resume.
320
+ ```
321
+
322
+ ---
323
+
324
+ ## Exit codes
325
+
326
+ Scripted use is a first-class case, so failures are grouped rather than all
327
+ being `1`.
328
+
329
+ | Code | Meaning | Retry? |
330
+ | --- | --- | --- |
331
+ | 0 | Success | |
332
+ | 2 | Bad usage, or an unsupported Node runtime | No |
333
+ | 3 | Not signed in, denied, expired, or revoked | After `runinfra login` |
334
+ | 4 | The workspace does not own the package, or its files are not published yet | No |
335
+ | 5 | Network or server failure | Yes |
336
+ | 6 | Integrity failure: checksum mismatch, or the artifact changed mid-download | Start over |
337
+ | 7 | No space, or the destination is not writable | After freeing space |
338
+ | 130 | Interrupted | Yes, it resumes |
339
+
340
+ ---
341
+
342
+ ## Environment
343
+
344
+ | Variable | Purpose |
345
+ | --- | --- |
346
+ | `RUNINFRA_API_BASE` | Point at another deployment. Plaintext `http://` is accepted **only** for a loopback host, because the sign-in response carries a live API key. |
347
+ | `RUNINFRA_CONFIG_DIR` | Where credentials are stored. Overrides the platform default. |
348
+
349
+ The base a key was minted against travels with the key. If you sign in against a
350
+ preview deploy and then run against production, the CLI warns and uses the base
351
+ the key belongs to rather than sending that credential to a host that never
352
+ issued it.
353
+
354
+ ---
355
+
356
+ ## The HTTP contract
357
+
358
+ Every path and field this CLI speaks lives in `src/endpoints.ts`, read off the
359
+ routes as built.
360
+
361
+ | Route | Purpose |
362
+ | --- | --- |
363
+ | `POST /api/cli/device/init` | Start the device flow, returns the device code and the code you type |
364
+ | `POST /api/cli/device/poll` | Poll for approval, returns the key once approved |
365
+ | `POST /api/cli/device/loopback-init` | Start the browser flow, returns the request id the approval page opens |
366
+ | `POST /api/cli/token` | Loopback authorization code exchange, the browser flow's final step |
367
+ | `GET /cli/authorize` | The approval page, browser only |
368
+ | `GET /api/catalog/<slug>/download` | Dual auth, accepts a `purpose='cli'` bearer key |
369
+
370
+ Path choices are forced by the CSRF origin check: `/api/cli/token` and
371
+ `/api/cli/device/` are exempt from it, because no non-browser HTTP client sends
372
+ `Origin`. `/api/cli/authorize` is **not** exempt (that one is the browser
373
+ approving with session cookies), so the CLI never calls it. **Any new
374
+ CLI-called POST route must live under one of the two exempt prefixes or it will
375
+ 403 on every platform.**
376
+
377
+ ### Refusal envelope
378
+
379
+ Sign-in routes answer OAuth-shaped `{ "error": "<code>" }` (RFC 6749 5.2, RFC
380
+ 8628 3.5) with `Retry-After` in the **header**, not the body:
381
+
382
+ ```
383
+ invalid_request invalid_grant expired_token access_denied
384
+ authorization_pending slow_down rate_limited temporarily_unavailable
385
+ server_error unsupported_code_challenge_method invalid_scope
386
+ ```
387
+
388
+ `invalid_grant` deliberately fuses replayed, unapproved, wrong-verifier,
389
+ wrong-redirect and never-existed, and the device poll answers `expired_token`
390
+ for a code it has never seen. The server refuses to be an oracle, so the CLI
391
+ does not try to reconstruct the distinction either.
392
+
393
+ The download route answers `{ ok: false, code }` with two vocabularies:
394
+ `not_found`, `artifact_not_ready`, `rate_limited` for the download itself, and
395
+ `missing_credentials`, `invalid_key`, `key_revoked`, `key_expired`,
396
+ `workspace_access_revoked`, `auth_unavailable` when the key did not hold up.
397
+ They map to different exit codes, so "your key was revoked" and "your workspace
398
+ does not own this" never collapse into one sentence.
399
+
400
+ ### Sign-in success body
401
+
402
+ ```jsonc
403
+ { "apiKey": "rp_...", "keyPrefix": "rp_k3n9", "tokenType": "Bearer",
404
+ "scope": "catalog:download", "workspaceId": "<your-workspace-id>",
405
+ "expiresAt": "2026-10-24T09:14:02.118Z", "expiresIn": 7776000 }
406
+ ```
407
+
408
+ Deliberately not called `access_token`: this is a RunInfra API key with no
409
+ refresh and no introspection endpoint, and borrowing the OAuth name would
410
+ promise a contract that does not exist.
411
+
412
+ ### How sign-in is secured
413
+
414
+ OAuth 2.0 authorization code with PKCE (RFC 7636), plus the device shape
415
+ (RFC 8628) for headless hosts. The code verifier stays in the CLI process and is
416
+ never written, printed, or sent: only its SHA-256 challenge goes on the wire at
417
+ request time. Codes are stored hashed, so reading the database cannot complete a
418
+ pending sign-in. An approval is consumed by a single conditional UPDATE, so two
419
+ racing exchanges cannot both mint a key. Redirect targets are restricted to
420
+ loopback literals in the route, in the shared core, and in a database CHECK.
421
+
422
+ ### Server-side gaps
423
+
424
+ Two things this CLI wants still do not exist. It degrades honestly rather than
425
+ pretending, and each becomes a small change here when it lands.
426
+
427
+ 1. **Identity.** There is no `GET /api/cli/whoami`, so `whoami` reports the
428
+ local credential and says so in those words. It makes no network call, and
429
+ access it reports may already have been revoked from Settings.
430
+ 2. **Revocation from the terminal.** `logout` removes the local credential and
431
+ points you at Settings; it does not claim to have revoked the key, which
432
+ stays valid until its expiry. Ending a key from the terminal itself is not
433
+ offered yet: revoke it from **Settings, API keys** on runinfra.ai, which
434
+ takes effect on that terminal's next request.
435
+
436
+ The browser sign-in flow that this document once listed as unbuilt now exists.
437
+ It is created by `POST /api/cli/device/loopback-init`, which takes
438
+ `{ codeChallenge, codeChallengeMethod, scope, redirectUri, deviceLabel }` and
439
+ returns `{ requestId }`. It sits under `/api/cli/device/` so it inherits the
440
+ origin exemption, which is why a route that is not a device route lives at
441
+ that path.
442
+
443
+ ---
444
+
445
+ ## Development
446
+
447
+ ```console
448
+ node ../node_modules/typescript/bin/tsc -p tsconfig.json # build to dist/
449
+ node ../node_modules/vitest/vitest.mjs run cli/src --root .. # tests
450
+ node bin/runinfra.mjs --help # run it
451
+ RUNINFRA_API_BASE=http://localhost:3000 node bin/runinfra.mjs login
452
+ ```
453
+
454
+ Tests cover the pure logic, deliberately: range planning and resume arithmetic,
455
+ sidecar validation, the lease-refresh decision, per-platform browser command
456
+ selection, redirect construction for IPv4 and IPv6, credential and path
457
+ resolution, and every response parser. Nothing in the test suite opens a socket
458
+ to the internet.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // The published entry point.
3
+ //
4
+ // Kept as hand-written JavaScript rather than compiled output so the shebang
5
+ // and the exit-code handling are readable in the installed package, and so a
6
+ // broken build surfaces as "cannot find dist/main.js" instead of a shebang
7
+ // that silently disappeared.
8
+
9
+ import { run } from "../dist/main.js";
10
+
11
+ // Ctrl-C must stop the transfer, not kill the process mid-write: the
12
+ // download engine watches this signal, finishes the write it is on, records
13
+ // the last durable range, and returns 130. A second Ctrl-C exits hard, which
14
+ // is the behaviour every terminal user expects when the first one is not
15
+ // working.
16
+ const controller = new AbortController();
17
+ let interrupts = 0;
18
+
19
+ process.on("SIGINT", () => {
20
+ interrupts += 1;
21
+ if (interrupts > 1) {
22
+ process.exit(130);
23
+ }
24
+ process.stderr.write("\nStopping. Run the same command again to resume.\n");
25
+ controller.abort();
26
+ });
27
+
28
+ // EPIPE is what `runinfra whoami | head -1` produces. It is not an error.
29
+ // Anything else on stdout is reported and exits; throwing from inside an
30
+ // error handler would crash with a stack trace instead of a sentence.
31
+ process.stdout.on("error", (error) => {
32
+ if (error && error.code === "EPIPE") {
33
+ process.exit(0);
34
+ }
35
+ process.stderr.write(`error: could not write output: ${error?.message ?? error}\n`);
36
+ process.exit(1);
37
+ });
38
+
39
+ try {
40
+ process.exitCode = await run(process.argv.slice(2), { signal: controller.signal });
41
+ } catch (error) {
42
+ process.stderr.write(
43
+ `error: ${error instanceof Error ? error.message : String(error)}\n`,
44
+ );
45
+ process.exitCode = 1;
46
+ }
package/dist/args.js ADDED
@@ -0,0 +1,142 @@
1
+ export const COMMANDS = ["login", "logout", "whoami", "pull"];
2
+ export const MIN_CONCURRENCY = 1;
3
+ export const MAX_CONCURRENCY = 16;
4
+ function emptyArgs(command) {
5
+ return {
6
+ command,
7
+ device: false,
8
+ slug: null,
9
+ out: null,
10
+ concurrency: null,
11
+ };
12
+ }
13
+ function isFlag(token) {
14
+ return token.startsWith("-") && token !== "-";
15
+ }
16
+ function splitInlineValue(token) {
17
+ const equals = token.indexOf("=");
18
+ if (equals === -1)
19
+ return { name: token, value: null };
20
+ return { name: token.slice(0, equals), value: token.slice(equals + 1) };
21
+ }
22
+ export function parseArgs(argv) {
23
+ const tokens = [...argv];
24
+ if (tokens.some((token) => token === "--help" || token === "-h")) {
25
+ return { ok: true, args: emptyArgs("help") };
26
+ }
27
+ if (tokens.some((token) => token === "--version" || token === "-V")) {
28
+ return { ok: true, args: emptyArgs("version") };
29
+ }
30
+ const first = tokens[0];
31
+ if (first === undefined) {
32
+ return { ok: true, args: emptyArgs("help") };
33
+ }
34
+ if (isFlag(first)) {
35
+ return { ok: false, message: `Unknown option ${first}. Run \`runinfra --help\`.` };
36
+ }
37
+ if (!COMMANDS.includes(first)) {
38
+ return {
39
+ ok: false,
40
+ message: `Unknown command "${first}". Expected one of: ${COMMANDS.join(", ")}.`,
41
+ };
42
+ }
43
+ const command = first;
44
+ const args = emptyArgs(command);
45
+ const positionals = [];
46
+ for (let index = 1; index < tokens.length; index += 1) {
47
+ const raw = tokens[index];
48
+ if (raw === undefined)
49
+ break;
50
+ if (!isFlag(raw)) {
51
+ positionals.push(raw);
52
+ continue;
53
+ }
54
+ const { name, value: inline } = splitInlineValue(raw);
55
+ const takeValue = () => {
56
+ if (inline !== null)
57
+ return inline.length > 0 ? inline : null;
58
+ const next = tokens[index + 1];
59
+ if (next === undefined || isFlag(next))
60
+ return null;
61
+ index += 1;
62
+ return next;
63
+ };
64
+ switch (name) {
65
+ case "--device": {
66
+ if (command !== "login") {
67
+ return { ok: false, message: `--device applies to \`runinfra login\` only.` };
68
+ }
69
+ args.device = true;
70
+ break;
71
+ }
72
+ case "--out":
73
+ case "-o": {
74
+ if (command !== "pull") {
75
+ return { ok: false, message: `--out applies to \`runinfra pull\` only.` };
76
+ }
77
+ const value = takeValue();
78
+ if (value === null) {
79
+ return { ok: false, message: "--out needs a directory, for example --out ./models." };
80
+ }
81
+ args.out = value;
82
+ break;
83
+ }
84
+ case "--concurrency":
85
+ case "-c": {
86
+ if (command !== "pull") {
87
+ return { ok: false, message: `--concurrency applies to \`runinfra pull\` only.` };
88
+ }
89
+ const value = takeValue();
90
+ if (value === null) {
91
+ return {
92
+ ok: false,
93
+ message: `--concurrency needs a number between ${MIN_CONCURRENCY} and ${MAX_CONCURRENCY}.`,
94
+ };
95
+ }
96
+ if (!/^[0-9]+$/u.test(value)) {
97
+ return {
98
+ ok: false,
99
+ message: `--concurrency must be a whole number between ${MIN_CONCURRENCY} and ${MAX_CONCURRENCY}, got "${value}".`,
100
+ };
101
+ }
102
+ const parsed = Number.parseInt(value, 10);
103
+ if (parsed < MIN_CONCURRENCY || parsed > MAX_CONCURRENCY) {
104
+ return {
105
+ ok: false,
106
+ message: `--concurrency must be between ${MIN_CONCURRENCY} and ${MAX_CONCURRENCY}, got ${parsed}.`,
107
+ };
108
+ }
109
+ args.concurrency = parsed;
110
+ break;
111
+ }
112
+ default:
113
+ return {
114
+ ok: false,
115
+ message: `Unknown option ${name}. Run \`runinfra ${command} --help\`.`,
116
+ };
117
+ }
118
+ }
119
+ if (command === "pull") {
120
+ const slug = positionals[0];
121
+ if (slug === undefined) {
122
+ return {
123
+ ok: false,
124
+ message: "runinfra pull needs a package slug. Copy the exact slug from your package page on runinfra.ai, for example `runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm`.",
125
+ };
126
+ }
127
+ if (positionals.length > 1) {
128
+ return {
129
+ ok: false,
130
+ message: `runinfra pull takes one package slug, got ${positionals.length}.`,
131
+ };
132
+ }
133
+ args.slug = slug;
134
+ }
135
+ else if (positionals.length > 0) {
136
+ return {
137
+ ok: false,
138
+ message: `runinfra ${command} takes no arguments, got "${positionals[0] ?? ""}".`,
139
+ };
140
+ }
141
+ return { ok: true, args };
142
+ }