@newly/cli 0.1.1 → 0.1.27
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/README.md +20 -19
- package/README.runcloud.md +253 -0
- package/dist/api.js +123 -43
- package/dist/commands/appStore.js +162 -0
- package/dist/commands/appStoreEvaluate.js +515 -0
- package/dist/commands/boxAccess.js +349 -0
- package/dist/commands/build.js +489 -3
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +55 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/image.js +145 -0
- package/dist/commands/login.js +266 -19
- package/dist/commands/run-cloud.js +482 -0
- package/dist/commands/sandbox.js +787 -0
- package/dist/commands/sandboxOutput.js +70 -0
- package/dist/commands/secrets.js +203 -0
- package/dist/config.js +72 -7
- package/dist/deviceLogin.js +48 -0
- package/dist/diagnose.js +159 -0
- package/dist/index.js +8 -0
- package/dist/prompt.js +196 -0
- package/dist/run-cloud.js +17 -0
- package/dist/runCloudAuth.js +79 -0
- package/dist/sandboxExec.js +91 -0
- package/dist/services-manifest.js +30 -1
- package/dist/shellSession.js +86 -0
- package/dist/terminal.js +158 -0
- package/dist/version.js +1 -0
- package/package.json +23 -7
package/README.md
CHANGED
|
@@ -14,10 +14,12 @@ Installs the `newly` binary. Requires **Node ≥ 20**.
|
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
newly login # authenticate in the browser
|
|
17
|
-
newly
|
|
17
|
+
newly create my-api # create and deploy production, then select it
|
|
18
|
+
newly env create checkout-fix # fork production into a new environment
|
|
19
|
+
newly env use checkout-fix # select it for this working directory
|
|
18
20
|
# edit code…
|
|
19
|
-
newly deploy # deploy to
|
|
20
|
-
newly deploy
|
|
21
|
+
newly deploy # deploy all declared services to the selected environment
|
|
22
|
+
newly deploy api # deploy only the api service
|
|
21
23
|
newly services list # inspect backend services
|
|
22
24
|
newly email status # inspect Newly Email setup
|
|
23
25
|
```
|
|
@@ -40,16 +42,15 @@ Most commands target the project linked to the current directory, so you don't p
|
|
|
40
42
|
- `newly link <project-id>` links a directory without downloading anything.
|
|
41
43
|
- Pass `--project <id>` to any command to override the link.
|
|
42
44
|
|
|
43
|
-
##
|
|
45
|
+
## Selected-environment deploys
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
`newly deploy` always uses the environment selected by `newly env use <name>`.
|
|
48
|
+
It never falls back to dev or production and has no `--env` option. Deployments
|
|
49
|
+
use local backend code and are immutable attempts; a failed attempt leaves the
|
|
50
|
+
previous active deployment and route untouched.
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
> Only projects on the Newly backend stack are CLI-deployable. Run `newly stack` to check. Other projects must be deployed from the Newly web app.
|
|
52
|
+
Service names are positional. With no names, every service in
|
|
53
|
+
`backend/newly.services.json` is deployed.
|
|
53
54
|
|
|
54
55
|
## Commands
|
|
55
56
|
|
|
@@ -59,8 +60,11 @@ Download the project working tree (app + backend) into the current directory and
|
|
|
59
60
|
- `--force` — overwrite a non-empty directory (discards local changes).
|
|
60
61
|
|
|
61
62
|
### `newly create [name]`
|
|
62
|
-
Create a
|
|
63
|
-
|
|
63
|
+
Create a project, provision its production PostgreSQL primary and standby,
|
|
64
|
+
scaffold `backend/`, select production, and deploy the starter Worker.
|
|
65
|
+
- `--no-deploy` — provision and select production without deploying the starter Worker.
|
|
66
|
+
|
|
67
|
+
Production starts with 2 GiB of storage.
|
|
64
68
|
|
|
65
69
|
Run in a directory with no `backend/` and no existing link.
|
|
66
70
|
|
|
@@ -76,13 +80,9 @@ Show which backend stack the project uses and whether it's CLI-deployable.
|
|
|
76
80
|
- `--project <id>`
|
|
77
81
|
|
|
78
82
|
### `newly deploy`
|
|
79
|
-
|
|
80
|
-
- `--env <dev|prod>` (default `dev`)
|
|
81
|
-
- `--region <region>` — region for first-time dev provisioning (default `auto`)
|
|
83
|
+
Deploy local backend services to the selected environment.
|
|
82
84
|
- `--project <id>`
|
|
83
|
-
-
|
|
84
|
-
- `--git-sha <sha>` — prod git revision to deploy (default `HEAD`)
|
|
85
|
-
- `--yes` — skip the prod confirmation prompt
|
|
85
|
+
- `[services...]` — optional service names from `backend/newly.services.json`
|
|
86
86
|
|
|
87
87
|
### `newly services list`
|
|
88
88
|
List backend services from `backend/newly.services.json`.
|
|
@@ -171,3 +171,4 @@ newly-dev deploy
|
|
|
171
171
|
- `~/.newly/credentials` — session token (mode `600`). Override the directory with `NEWLY_HOME`.
|
|
172
172
|
- `~/.newly-dev/credentials` — `newly-dev` session token. Override the directory with `NEWLY_DEV_HOME`.
|
|
173
173
|
- `.newly/project.json` — per-directory project link.
|
|
174
|
+
- `.newly/environment.json` — per-directory selected environment; ignored by Git.
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# runcloud
|
|
2
|
+
|
|
3
|
+
Create and control **remote mobile simulators** and **cloud sandboxes** from your terminal.
|
|
4
|
+
|
|
5
|
+
- **Simulators** — boot real iOS simulators and Android emulators in the cloud, install your app, open deep links, and tunnel a local dev server into them.
|
|
6
|
+
- **Sandboxes** — start a fresh, isolated Linux environment in seconds, run commands in it, snapshot a ready setup, and restore it later as a warm start.
|
|
7
|
+
|
|
8
|
+
Everything the CLI creates also shows up at [run.cloud/dashboard](https://run.cloud/dashboard):
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i -g runcloud
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Installs the `runcloud` binary. Requires **Node ≥ 20**.
|
|
19
|
+
|
|
20
|
+
Or take the standalone binary — no Node, no sudo. It installs to `~/.run-cloud/bin`
|
|
21
|
+
and hands the terminal to `runcloud login` when it finishes:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
curl -fsSL https://run.cloud/install | sh
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Log in
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
runcloud login # sign in with email, Google, or GitHub; stores a token in ~/.run-cloud/credentials
|
|
31
|
+
runcloud logout # delete the stored credential
|
|
32
|
+
runcloud account # show your entitlement and metered balance
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Email signs you in without leaving the terminal. Google and GitHub open a browser and
|
|
36
|
+
show a short code to confirm, so login works over SSH and on headless machines too —
|
|
37
|
+
pick with `--email`, `--google`, `--github`, or `--browser` to skip the prompt.
|
|
38
|
+
|
|
39
|
+
The token expires — re-run `runcloud login` when a command reports you're logged out.
|
|
40
|
+
|
|
41
|
+
## Remote simulators
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# iOS
|
|
45
|
+
runcloud ios create --model iphone --install ./MyApp.app # boot a simulator and install an app
|
|
46
|
+
runcloud ios list # list active sessions
|
|
47
|
+
runcloud ios get <id> # inspect a session (viewer URL, status)
|
|
48
|
+
runcloud ios open-url myapp://path --id <id> # open a URL or deep link
|
|
49
|
+
runcloud ios delete <id> # release the session
|
|
50
|
+
|
|
51
|
+
# Android works the same under `runcloud android …`
|
|
52
|
+
runcloud android create --model pixel
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Useful `create` flags: `--region`, `--display-name`, `--inactivity-timeout 3m`,
|
|
56
|
+
`--hard-timeout 1h`, `--install-asset <name-or-id>`, `--rm` (release when the
|
|
57
|
+
command exits), `--json`.
|
|
58
|
+
|
|
59
|
+
### Tunnel a local dev server into a simulator
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
runcloud ios tunnel <id> --local-port 8081 # expose local Metro to the remote session
|
|
63
|
+
runcloud ios tunnel-status
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Reusable app assets
|
|
67
|
+
|
|
68
|
+
Upload an app once, install it into as many sessions as you like:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
runcloud asset push ./MyApp.app --name my-app
|
|
72
|
+
runcloud asset list
|
|
73
|
+
runcloud ios create --install-asset my-app
|
|
74
|
+
runcloud asset pull <id> --output ./MyApp.app
|
|
75
|
+
runcloud asset delete <id>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Sandboxes
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
runcloud sandbox create # start a sandbox (default image: runcloud/agent-base)
|
|
82
|
+
runcloud sandbox create --cpu 0.5 --memory 1024 # allocate CPU cores and memory in MiB
|
|
83
|
+
runcloud sandbox create --disk 80 # request an 80 GiB root filesystem
|
|
84
|
+
runcloud sandbox exec <id> npm run build # run a command inside it
|
|
85
|
+
runcloud sandbox shell <id> # interactive shell, streamed over the API
|
|
86
|
+
runcloud sandbox logs <id> --lines 200 # tail its logs
|
|
87
|
+
runcloud sandbox metrics <id> --range 24h # CPU, memory, network, and disk usage
|
|
88
|
+
runcloud sandbox pause <id> # park it; `resume` brings it back warm
|
|
89
|
+
runcloud sandbox list
|
|
90
|
+
runcloud sandbox rm <id> # destroy it
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Browser desktops and computer use
|
|
94
|
+
|
|
95
|
+
Create a desktop sandbox, then open its short-lived signed noVNC URL:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
SANDBOX_ID=$(runcloud sandbox create --image runcloud/desktop --json | jq -r '.id')
|
|
99
|
+
|
|
100
|
+
runcloud sandbox desktop "$SANDBOX_ID" # open the desktop in a browser
|
|
101
|
+
runcloud sandbox desktop "$SANDBOX_ID" --url # print the signed URL
|
|
102
|
+
runcloud sandbox desktop "$SANDBOX_ID" --view-only # open without keyboard or pointer input
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Agents can capture and control the same desktop:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
runcloud sandbox screenshot "$SANDBOX_ID" --output screen.png
|
|
109
|
+
runcloud sandbox click "$SANDBOX_ID" 420 300
|
|
110
|
+
runcloud sandbox type "$SANDBOX_ID" "hello world"
|
|
111
|
+
runcloud sandbox key "$SANDBOX_ID" "ctrl+l"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Treat a signed desktop URL as a secret. It grants temporary access to that
|
|
115
|
+
desktop without asking the browser to send your CLI credential.
|
|
116
|
+
|
|
117
|
+
### Lifetime, pausing, and a public hostname
|
|
118
|
+
|
|
119
|
+
Two clocks stop a sandbox, and the first one bites after five minutes:
|
|
120
|
+
|
|
121
|
+
- `--timeout <seconds>` — maximum lifetime, wall-clock from boot or the last
|
|
122
|
+
resume, busy or not. **Defaults to 300**; `0` removes the limit.
|
|
123
|
+
- `--idle-pause <seconds>` — pause after that much inactivity.
|
|
124
|
+
|
|
125
|
+
Both *pause* rather than destroy. `runcloud sandbox resume <id>` brings a
|
|
126
|
+
sandbox back warm with its filesystem intact, and a paused sandbox is destroyed
|
|
127
|
+
after 48 hours. `--persistent` stops both clocks; `--expose` implies it and
|
|
128
|
+
publishes a guest port at a stable `<name>-box.run.cloud` hostname.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
runcloud sandbox create --timeout 3600 # an hour of wall clock, then pause
|
|
132
|
+
runcloud sandbox create --idle-pause 600 # pause after 10 idle minutes
|
|
133
|
+
runcloud sandbox create --name my-project --persistent # never pauses
|
|
134
|
+
runcloud sandbox create --name my-project --expose 3000 # persistent + https://my-project-box.run.cloud
|
|
135
|
+
runcloud sandbox expose <id> --port 8080 # expose later, or move the published port
|
|
136
|
+
runcloud sandbox get <id> # inspect, including the hostname
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The CLI only sets the timer at create time. To extend it on a running sandbox —
|
|
140
|
+
what a long job or a live agent session needs — call
|
|
141
|
+
`sandbox.setTimeout(id, seconds)` from [`@run-cloud/sdk`](https://www.npmjs.com/package/@run-cloud/sdk).
|
|
142
|
+
|
|
143
|
+
`--expose` needs `--name` — that name is the hostname prefix, and it is fixed
|
|
144
|
+
once minted.
|
|
145
|
+
|
|
146
|
+
Exposed sandboxes support SSH, file transfer, VS Code, and custom domains:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
runcloud sandbox ssh my-project # shell over authenticated SSH (no public port 22)
|
|
150
|
+
runcloud sandbox cp my-project ./f :/workspace/f # scp; prefix the remote path with :
|
|
151
|
+
runcloud sandbox code my-project # VS Code Remote SSH
|
|
152
|
+
runcloud sandbox domain add my-project app.example.com
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`runcloud box …` from earlier releases keeps working as a deprecated alias for
|
|
156
|
+
these commands.
|
|
157
|
+
|
|
158
|
+
### Snapshot warm-start
|
|
159
|
+
|
|
160
|
+
Freeze a "repo + deps ready" sandbox, then fork a new one from it in a fraction of a second instead of re-cloning and re-installing:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
runcloud sandbox snapshot create <id> --label warm # snapshot a sandbox
|
|
164
|
+
runcloud sandbox snapshot list --sandbox <id>
|
|
165
|
+
runcloud sandbox restore <snapshot-id> --name fork # fork a NEW sandbox from the snapshot
|
|
166
|
+
runcloud sandbox snapshot rm <snapshot-id>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Secrets
|
|
170
|
+
|
|
171
|
+
Store secrets in a named group, then attach the group when you create a sandbox:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
runcloud secret-group create aws --key AWS_ACCESS_KEY_ID --key AWS_SECRET_ACCESS_KEY
|
|
175
|
+
runcloud secret-group create prod --from-dotenv ./.env.production
|
|
176
|
+
runcloud secret-group list
|
|
177
|
+
runcloud secret-group show aws # names only, never values
|
|
178
|
+
runcloud secrets set DATABASE_URL --group prod # hidden prompt, or --stdin
|
|
179
|
+
runcloud secrets set-file sa.json --group prod --path .config/gcloud/sa.json --from-file ./sa.json
|
|
180
|
+
|
|
181
|
+
runcloud sandbox create --secret-group prod --env LOG_LEVEL=debug
|
|
182
|
+
runcloud sandbox create --secret prod/DATABASE_URL # one secret out of a group
|
|
183
|
+
runcloud sandbox create --no-secrets # state explicitly that it holds none
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`--secret-group`, `--secret`, and `--env` are repeatable and **order matters** —
|
|
187
|
+
a later one wins a name collision, and `--env` is applied last.
|
|
188
|
+
|
|
189
|
+
`runcloud sandbox secrets <id>` re-attaches on a running sandbox. It *replaces*
|
|
190
|
+
the whole set rather than merging, which is how a credential gets revoked:
|
|
191
|
+
passing no flags at all takes them all back.
|
|
192
|
+
|
|
193
|
+
A value can never be passed as a command argument — it would land in your shell
|
|
194
|
+
history, in `ps` output, and in CI logs. Use a hidden prompt, a file, or stdin.
|
|
195
|
+
|
|
196
|
+
## Custom images
|
|
197
|
+
|
|
198
|
+
A sandbox boots an OCI image. Register your own once, then create sandboxes from
|
|
199
|
+
it:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
runcloud image create --ref python:3.12-slim # registers, and waits for the build
|
|
203
|
+
runcloud image list
|
|
204
|
+
runcloud image refresh python:3.12-slim # re-pin the tag to its current upstream digest
|
|
205
|
+
runcloud sandbox create --image python:3.12-slim
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Built in: `runcloud/agent-base` (the default), `runcloud/desktop`, and
|
|
209
|
+
`runcloud/box` (the default with `--expose`).
|
|
210
|
+
|
|
211
|
+
## Demos
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
runcloud demo run --open # run the default mosaic from any directory
|
|
215
|
+
runcloud demo run live-camera-relay --open # run the camera relay from any directory
|
|
216
|
+
runcloud demo init eight-device-mosaic # optionally scaffold a standalone project
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Bundled demos: `eight-device-mosaic`, `live-camera-relay`.
|
|
220
|
+
|
|
221
|
+
## Onboarding sample apps
|
|
222
|
+
|
|
223
|
+
Download checksum-verified apps that are ready for the remote runtimes:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
runcloud sample download ios
|
|
227
|
+
runcloud ios create --install ./run-cloud-sample-ios.app.tar.gz
|
|
228
|
+
|
|
229
|
+
runcloud sample download android
|
|
230
|
+
runcloud android create --install ./run-cloud-sample-android.apk
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## AI coding agents
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
runcloud skills install # install run.cloud skills into this project
|
|
237
|
+
runcloud skills install --agents claude --scope global
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Supported agents: `claude`, `codex`, `cursor`.
|
|
241
|
+
|
|
242
|
+
Every command accepts `--json` for scripting. For programmatic use from
|
|
243
|
+
TypeScript, see [`@run-cloud/sdk`](https://www.npmjs.com/package/@run-cloud/sdk).
|
|
244
|
+
|
|
245
|
+
## Credentials & environment
|
|
246
|
+
|
|
247
|
+
- `~/.run-cloud/credentials` — your session token (mode `600`).
|
|
248
|
+
|
|
249
|
+
| Env var | Effect |
|
|
250
|
+
|---|---|
|
|
251
|
+
| `RUN_CLOUD_API_TOKEN` / `RUN_CLOUD_API_KEY` | Use this token instead of the stored credential (CI / headless). |
|
|
252
|
+
| `RUN_CLOUD_API_URL` | Override the control-plane API base URL. |
|
|
253
|
+
| `RUN_CLOUD_APP_URL` | Override the web app URL used for browser login (default `https://run.cloud`). |
|
package/dist/api.js
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
|
+
import { loginCommandName } from './config.js';
|
|
1
2
|
export class ApiError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
detail;
|
|
4
|
-
|
|
5
|
+
path;
|
|
6
|
+
constructor(status, detail, path) {
|
|
5
7
|
super(`API ${status}: ${detail}`);
|
|
6
8
|
this.status = status;
|
|
7
9
|
this.detail = detail;
|
|
10
|
+
this.path = path;
|
|
8
11
|
}
|
|
9
12
|
}
|
|
13
|
+
function parsedErrorDetail(body) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(body);
|
|
16
|
+
const detail = parsed.detail ?? parsed.error ?? parsed.message;
|
|
17
|
+
if (typeof detail === 'string')
|
|
18
|
+
return detail;
|
|
19
|
+
if (detail !== undefined)
|
|
20
|
+
return JSON.stringify(detail);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
}
|
|
24
|
+
if (/<(?:!doctype|html)\b/i.test(body)) {
|
|
25
|
+
return 'the API edge returned an HTML error page; retry the request or use the streaming/direct-transfer command';
|
|
26
|
+
}
|
|
27
|
+
return body;
|
|
28
|
+
}
|
|
10
29
|
export class ApiClient {
|
|
11
30
|
baseUrl;
|
|
12
31
|
token;
|
|
@@ -24,13 +43,8 @@ export class ApiClient {
|
|
|
24
43
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
25
44
|
});
|
|
26
45
|
if (!res.ok) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
detail = JSON.parse(detail).detail ?? detail;
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
}
|
|
33
|
-
throw new ApiError(res.status, detail);
|
|
46
|
+
const detail = parsedErrorDetail(await res.text());
|
|
47
|
+
throw new ApiError(res.status, detail, path);
|
|
34
48
|
}
|
|
35
49
|
const contentType = res.headers.get('content-type') ?? '';
|
|
36
50
|
return contentType.includes('application/json') ? res.json() : res;
|
|
@@ -38,9 +52,33 @@ export class ApiClient {
|
|
|
38
52
|
get(path) {
|
|
39
53
|
return this.request('GET', path);
|
|
40
54
|
}
|
|
55
|
+
async getText(path) {
|
|
56
|
+
const response = await this.request('GET', path);
|
|
57
|
+
return response.text();
|
|
58
|
+
}
|
|
41
59
|
post(path, body) {
|
|
42
60
|
return this.request('POST', path, body);
|
|
43
61
|
}
|
|
62
|
+
async postWithHeaders(path, body, headers) {
|
|
63
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
Authorization: `Bearer ${this.token}`,
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
...headers,
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const detail = parsedErrorDetail(await res.text());
|
|
74
|
+
throw new ApiError(res.status, detail, path);
|
|
75
|
+
}
|
|
76
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
77
|
+
return contentType.includes('application/json') ? res.json() : res;
|
|
78
|
+
}
|
|
79
|
+
put(path, body) {
|
|
80
|
+
return this.request('PUT', path, body);
|
|
81
|
+
}
|
|
44
82
|
patch(path, body) {
|
|
45
83
|
return this.request('PATCH', path, body);
|
|
46
84
|
}
|
|
@@ -57,13 +95,8 @@ export class ApiClient {
|
|
|
57
95
|
body: body,
|
|
58
96
|
});
|
|
59
97
|
if (!res.ok) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
detail = JSON.parse(detail).detail ?? detail;
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
}
|
|
66
|
-
throw new ApiError(res.status, detail);
|
|
98
|
+
const detail = parsedErrorDetail(await res.text());
|
|
99
|
+
throw new ApiError(res.status, detail, path);
|
|
67
100
|
}
|
|
68
101
|
const contentType_ = res.headers.get('content-type') ?? '';
|
|
69
102
|
return contentType_.includes('application/json') ? res.json() : res;
|
|
@@ -77,13 +110,8 @@ export class ApiClient {
|
|
|
77
110
|
body: form,
|
|
78
111
|
});
|
|
79
112
|
if (!res.ok) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
detail = JSON.parse(detail).detail ?? detail;
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
}
|
|
86
|
-
throw new ApiError(res.status, detail);
|
|
113
|
+
const detail = parsedErrorDetail(await res.text());
|
|
114
|
+
throw new ApiError(res.status, detail, path);
|
|
87
115
|
}
|
|
88
116
|
const contentType = res.headers.get('content-type') ?? '';
|
|
89
117
|
return contentType.includes('application/json') ? res.json() : res;
|
|
@@ -95,7 +123,7 @@ export class ApiClient {
|
|
|
95
123
|
});
|
|
96
124
|
if (!res.ok) {
|
|
97
125
|
const detail = await res.text().catch(() => '');
|
|
98
|
-
throw new ApiError(res.status, detail || res.statusText);
|
|
126
|
+
throw new ApiError(res.status, detail || res.statusText, path);
|
|
99
127
|
}
|
|
100
128
|
return Buffer.from(await res.arrayBuffer());
|
|
101
129
|
}
|
|
@@ -105,13 +133,8 @@ export class ApiClient {
|
|
|
105
133
|
headers: { Authorization: `Bearer ${this.token}`, Accept: 'text/event-stream' },
|
|
106
134
|
});
|
|
107
135
|
if (!res.ok) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
detail = JSON.parse(detail).detail ?? detail;
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
}
|
|
114
|
-
throw new ApiError(res.status, detail || res.statusText);
|
|
136
|
+
const detail = parsedErrorDetail(await res.text().catch(() => ''));
|
|
137
|
+
throw new ApiError(res.status, detail || res.statusText, path);
|
|
115
138
|
}
|
|
116
139
|
const decoder = new TextDecoder();
|
|
117
140
|
for await (const chunk of res.body) {
|
|
@@ -122,18 +145,75 @@ export class ApiClient {
|
|
|
122
145
|
yield tail;
|
|
123
146
|
}
|
|
124
147
|
}
|
|
148
|
+
function rootCause(err) {
|
|
149
|
+
const cause = err?.cause;
|
|
150
|
+
if (cause && typeof cause === 'object' && Array.isArray(cause.errors)) {
|
|
151
|
+
return cause.errors[0] ?? cause;
|
|
152
|
+
}
|
|
153
|
+
return cause ?? err;
|
|
154
|
+
}
|
|
155
|
+
function networkError(err) {
|
|
156
|
+
const inner = rootCause(err);
|
|
157
|
+
const code = inner?.code;
|
|
158
|
+
if (!code)
|
|
159
|
+
return null;
|
|
160
|
+
const hints = {
|
|
161
|
+
ECONNREFUSED: 'the server refused the connection — is it running, and is the URL/port right?',
|
|
162
|
+
ENOTFOUND: 'the host could not be resolved (DNS) — check the URL',
|
|
163
|
+
EAI_AGAIN: 'temporary DNS failure — check your network connection',
|
|
164
|
+
ETIMEDOUT: 'the connection timed out — the server may be down or unreachable',
|
|
165
|
+
ECONNRESET: 'the connection was reset by the server',
|
|
166
|
+
CERT_HAS_EXPIRED: "the server's TLS certificate has expired",
|
|
167
|
+
};
|
|
168
|
+
const detail = inner instanceof Error ? inner.message : String(inner);
|
|
169
|
+
return `Could not reach the server (${code}): ${detail}${hints[code] ? ` — ${hints[code]}` : ''}`;
|
|
170
|
+
}
|
|
171
|
+
function detailSuffix(err) {
|
|
172
|
+
const stack = err instanceof Error && err.stack ? err.stack : String(err);
|
|
173
|
+
const cause = err?.cause;
|
|
174
|
+
return `\n${stack}${cause ? `\nCaused by: ${cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)}` : ''}`;
|
|
175
|
+
}
|
|
176
|
+
function headline(err) {
|
|
177
|
+
if (err instanceof ApiError) {
|
|
178
|
+
if (err.status === 401) {
|
|
179
|
+
const command = loginCommandName();
|
|
180
|
+
return `Session expired or invalid — run \`${command}\`.`;
|
|
181
|
+
}
|
|
182
|
+
if (err.status === 403)
|
|
183
|
+
return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
|
|
184
|
+
if (err.status === 404) {
|
|
185
|
+
const snapshotRestoreMatch = err.path?.match(/^\/run-cloud\/snapshots\/([^/]+)\/restore$/);
|
|
186
|
+
if (snapshotRestoreMatch) {
|
|
187
|
+
const id = decodeURIComponent(snapshotRestoreMatch[1]);
|
|
188
|
+
if (/^sb(?:x)?_/.test(id)) {
|
|
189
|
+
return `Snapshot ID not found: ${id}. \`sandbox restore\` expects a snapshot ID; to restart this sandbox, run \`runcloud sandbox resume ${id}\`.`;
|
|
190
|
+
}
|
|
191
|
+
return `Snapshot ID not found: ${id}.`;
|
|
192
|
+
}
|
|
193
|
+
const sandboxMatch = err.path?.match(/^\/run-cloud\/sandboxes\/([^/]+)/);
|
|
194
|
+
if (sandboxMatch) {
|
|
195
|
+
return `Sandbox ID not found: ${decodeURIComponent(sandboxMatch[1])}.`;
|
|
196
|
+
}
|
|
197
|
+
if (err.path?.startsWith('/run-cloud/images')) {
|
|
198
|
+
return 'Image not found — check the reference, and that it has been registered.';
|
|
199
|
+
}
|
|
200
|
+
if (!err.detail || /^not found$/i.test(err.detail.trim())) {
|
|
201
|
+
return err.path
|
|
202
|
+
? "This version of the CLI asked for something the server doesn't have."
|
|
203
|
+
: 'Resource not found.';
|
|
204
|
+
}
|
|
205
|
+
return err.detail;
|
|
206
|
+
}
|
|
207
|
+
if (err.status === 409)
|
|
208
|
+
return err.detail;
|
|
209
|
+
if (err.status === 503)
|
|
210
|
+
return 'The platform is at capacity right now — please try again in a few minutes.';
|
|
211
|
+
return err.message;
|
|
212
|
+
}
|
|
213
|
+
return networkError(err) ?? (err instanceof Error ? err.message : String(err));
|
|
214
|
+
}
|
|
125
215
|
export function friendlyApiError(err) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return 'Session expired or invalid — run `newly login`.';
|
|
130
|
-
if (err.status === 403)
|
|
131
|
-
return err.detail ? `Forbidden: ${err.detail}` : 'Forbidden.';
|
|
132
|
-
if (err.status === 404)
|
|
133
|
-
return `Not found: ${err.detail}`;
|
|
134
|
-
if (err.status === 409)
|
|
135
|
-
return err.detail;
|
|
136
|
-
if (err.status === 503)
|
|
137
|
-
return 'The platform is at capacity right now — please try again in a few minutes.';
|
|
138
|
-
return err.message;
|
|
216
|
+
const message = headline(err);
|
|
217
|
+
const debug = process.env.RUNCLOUD_DEBUG === '1' || process.env.NEWLY_DEBUG === '1';
|
|
218
|
+
return !debug || err instanceof ApiError ? message : `${message}${detailSuffix(err)}`;
|
|
139
219
|
}
|