@newly/cli 0.1.2 → 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 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 pull <project-id> # download project code + link this directory
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 dev
20
- newly deploy --env prod # ship to prod
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
- ## Dev vs prod deploys
45
+ ## Selected-environment deploys
44
46
 
45
- | | Source | Notes |
46
- |---|---|---|
47
- | `newly deploy` (dev) | **your local code** | Fast inner loop. Ephemeral — overwritten the next time the project is built on the Newly platform. Not a rollback target. |
48
- | `newly deploy --env prod` | **project code at HEAD** | Runs server-side, ignores local edits. Push your changes into the project first. |
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
- Prod actions prompt for confirmation. Pass `--yes` to skip it in scripts.
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 new backend-only project here, scaffold `backend/`, and link the directory. Provisions the dev backend by default.
63
- - `--no-provision` — create without provisioning.
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
- Provision the backend if needed, then deploy.
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
- - `--service <names>` — comma-separated service names from `backend/newly.services.json`
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
+ ![The run.cloud dashboard: fleet totals, reserved vCPU and memory, billed uptime, estimated spend, and recent sandboxes.](https://run.cloud/media/dashboard.png)
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
- constructor(status, detail) {
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
- let detail = await res.text();
28
- try {
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
- let detail = await res.text();
61
- try {
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
- let detail = await res.text();
81
- try {
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
- let detail = await res.text().catch(() => '');
109
- try {
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
- if (!(err instanceof ApiError))
127
- return String(err);
128
- if (err.status === 401)
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
  }