@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 +48 -0
- package/README.md +458 -0
- package/bin/runinfra.mjs +46 -0
- package/dist/args.js +142 -0
- package/dist/artifact-name.js +62 -0
- package/dist/auth-api.js +177 -0
- package/dist/browser.js +70 -0
- package/dist/catalog-api.js +89 -0
- package/dist/checksum.js +28 -0
- package/dist/context.js +52 -0
- package/dist/credentials.js +136 -0
- package/dist/disk.js +33 -0
- package/dist/downloader.js +265 -0
- package/dist/endpoints.js +58 -0
- package/dist/env.js +1 -0
- package/dist/errors.js +54 -0
- package/dist/help.js +47 -0
- package/dist/http.js +204 -0
- package/dist/lease.js +91 -0
- package/dist/login.js +207 -0
- package/dist/logout.js +17 -0
- package/dist/loopback.js +142 -0
- package/dist/main.js +70 -0
- package/dist/output.js +58 -0
- package/dist/paths.js +20 -0
- package/dist/pkce.js +24 -0
- package/dist/progress.js +63 -0
- package/dist/prompt.js +46 -0
- package/dist/pull.js +236 -0
- package/dist/range-plan.js +112 -0
- package/dist/redact.js +8 -0
- package/dist/scopes.js +2 -0
- package/dist/sidecar.js +108 -0
- package/dist/timers.js +13 -0
- package/dist/version.js +14 -0
- package/dist/whoami.js +28 -0
- package/package.json +43 -0
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.
|
package/bin/runinfra.mjs
ADDED
|
@@ -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
|
+
}
|