elestio 1.0.2 → 1.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/README.md +242 -4
- package/package.json +9 -2
- package/src/api.js +59 -21
- package/src/cli.js +16 -813
- package/src/commands/access.js +26 -13
- package/src/commands/auth.js +1 -1
- package/src/commands/cicd-template.js +363 -0
- package/src/commands/cicd.js +40 -15
- package/src/commands/clusters.js +337 -0
- package/src/commands/services.js +38 -26
- package/src/config.js +22 -2
- package/src/constants.js +76 -0
- package/src/deployment.js +33 -0
- package/src/payloads/pipeline.js +191 -0
- package/src/payloads/server.js +130 -0
- package/src/registry.js +663 -0
- package/src/router.js +188 -0
- package/src/templates/elestio-config.js +188 -0
- package/src/utils.js +27 -15
package/README.md
CHANGED
|
@@ -29,6 +29,18 @@ elestio deploy PostgreSQL --project 12345 --name my-db
|
|
|
29
29
|
elestio services --project 12345
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
### Three ways to run software
|
|
33
|
+
|
|
34
|
+
| | Command | You get |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| **Managed service** | `elestio deploy postgresql` | One VM running the software, fully managed |
|
|
37
|
+
| **Cluster** | `elestio deploy postgresql --cluster --nodes 3` | Several VMs with replication |
|
|
38
|
+
| **Pipeline** | `elestio cicd deploy-template n8n --target <vmID>` | Software running on a shared CI/CD target, rebuilt from a repo |
|
|
39
|
+
|
|
40
|
+
Pipelines are cheaper (several pipelines share one target VM) and let you edit
|
|
41
|
+
the software's compose file; managed services get backups, monitoring and
|
|
42
|
+
support. See [CI/CD](#cicd) for the pipeline route.
|
|
43
|
+
|
|
32
44
|
## Commands
|
|
33
45
|
|
|
34
46
|
### Auth & Config
|
|
@@ -72,6 +84,7 @@ elestio services --project 12345
|
|
|
72
84
|
| `elestio services` | List services in default project |
|
|
73
85
|
| `elestio service <vmID>` | Show service details |
|
|
74
86
|
| `elestio deploy <template>` | Deploy a new service |
|
|
87
|
+
| `elestio deploy <template> --cluster` | Deploy as a cluster (see [Clusters](#clusters)) |
|
|
75
88
|
| `elestio deploy <template> --dry-run` | Preview deployment |
|
|
76
89
|
| `elestio delete-service <vmID> --force` | Delete a service |
|
|
77
90
|
| `elestio move-service <vmID> <targetProjectId>` | Move to another project |
|
|
@@ -90,6 +103,63 @@ elestio deploy PostgreSQL \
|
|
|
90
103
|
--support level1
|
|
91
104
|
```
|
|
92
105
|
|
|
106
|
+
### Clusters
|
|
107
|
+
|
|
108
|
+
Clustering is available for 19 templates - PostgreSQL, MySQL, Redis, ClickHouse,
|
|
109
|
+
RabbitMQ, OpenSearch, Keycloak and others. Run `elestio clusters templates` for
|
|
110
|
+
the current list, which is read from the catalog rather than hardcoded.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# What can be clustered, and each one's minimum node count
|
|
114
|
+
elestio clusters templates
|
|
115
|
+
|
|
116
|
+
# 1 primary + 2 replicas. Always check the VM count first: billing is per VM.
|
|
117
|
+
elestio deploy postgresql --cluster --nodes 3 --dry-run
|
|
118
|
+
elestio deploy postgresql --cluster --nodes 3
|
|
119
|
+
|
|
120
|
+
# MySQL is the only software that supports writes on several nodes
|
|
121
|
+
elestio deploy mysql --cluster --cluster-mode multi-master
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Option | Description |
|
|
125
|
+
|--------|-------------|
|
|
126
|
+
| `--cluster` | Deploy a cluster instead of a single node |
|
|
127
|
+
| `--nodes <n>` | **Total** nodes, primary included. Defaults to the template minimum, caps at 15 |
|
|
128
|
+
| `--cluster-mode <mode>` | `primary-replica` (default) or `multi-master` |
|
|
129
|
+
|
|
130
|
+
`--nodes 3` means 1 primary and 2 replicas, and bills 3 VMs.
|
|
131
|
+
|
|
132
|
+
ClickHouse, Vault, OpenSearch, RabbitMQ, rke2 and Nats elect a leader by
|
|
133
|
+
quorum and need at least 3 nodes; everything else starts at 2. The CLI checks
|
|
134
|
+
this before calling the API, because a cluster the API rejects has often
|
|
135
|
+
already started billing its VMs.
|
|
136
|
+
|
|
137
|
+
| Command | Description |
|
|
138
|
+
|---------|-------------|
|
|
139
|
+
| `elestio clusters` | List clusters in the project |
|
|
140
|
+
| `elestio clusters info <clusterID>` | Cluster details and its nodes |
|
|
141
|
+
| `elestio clusters nodes <clusterID>` | List the active nodes |
|
|
142
|
+
| `elestio clusters templates` | Software that supports clustering |
|
|
143
|
+
| `elestio clusters promote <clusterID> <vmID> --force` | Promote a replica to primary |
|
|
144
|
+
| `elestio clusters failover <clusterID> on\|off` | Turn automatic failover on or off |
|
|
145
|
+
| `elestio clusters resync <clusterID> --force` | Re-sync replicas from the primary |
|
|
146
|
+
| `elestio clusters lock <clusterID>` | Enable termination protection |
|
|
147
|
+
| `elestio clusters unlock <clusterID>` | Disable termination protection |
|
|
148
|
+
| `elestio clusters delete <clusterID> --force` | Delete the cluster and all its nodes |
|
|
149
|
+
|
|
150
|
+
`promote`, `resync` and `delete` require `--force`: promotion demotes the
|
|
151
|
+
current primary, re-sync **erases all data on the replicas** and replaces it
|
|
152
|
+
with a copy of the primary, and delete removes every node. A locked cluster
|
|
153
|
+
must be unlocked before it can be deleted.
|
|
154
|
+
|
|
155
|
+
`failover` does not switch the primary itself. It turns on or off the automatic
|
|
156
|
+
failover that promotes a replica when the primary goes down; use `promote` to
|
|
157
|
+
switch by hand. Its state shows in `clusters info`.
|
|
158
|
+
|
|
159
|
+
Replicas are read-only and, unlike the primary, do not accept SSL connections:
|
|
160
|
+
a client with `sslmode=require` can write to the primary but cannot read from a
|
|
161
|
+
replica.
|
|
162
|
+
|
|
93
163
|
### Server Actions
|
|
94
164
|
|
|
95
165
|
| Command | Description |
|
|
@@ -213,8 +283,77 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
|
|
|
213
283
|
|
|
214
284
|
### CI/CD
|
|
215
285
|
|
|
286
|
+
There are two very different things you can put on a CI/CD target, and picking
|
|
287
|
+
the wrong one is the usual reason a pipeline comes up empty:
|
|
288
|
+
|
|
289
|
+
| You want to run | Use | Why |
|
|
290
|
+
|---|---|---|
|
|
291
|
+
| Software from the Elestio catalog (n8n, Rybbit, Plausible...) | `cicd deploy-template` | Reads the template's `elestio.yml` for ports, env vars and lifecycle hooks |
|
|
292
|
+
| Your own application from your own repo | `cicd create --auto` | You supply the build and run commands |
|
|
293
|
+
|
|
294
|
+
#### Deploying catalog software as a pipeline
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
# 1. Create a CI/CD target if you do not have one (this is a VM)
|
|
298
|
+
elestio deploy CI-CD-Target --name my-target
|
|
299
|
+
|
|
300
|
+
# 2. Find the software
|
|
301
|
+
elestio cicd templates n8n
|
|
302
|
+
|
|
303
|
+
# 3. See exactly what will be created, without creating it
|
|
304
|
+
elestio cicd deploy-template n8n --target <vmID> --dry-run
|
|
305
|
+
|
|
306
|
+
# 4. Deploy
|
|
307
|
+
elestio cicd deploy-template n8n --target <vmID>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Every catalog entry has a companion repo at
|
|
311
|
+
`github.com/elestio-examples/<software>` containing a `docker-compose.yml` and
|
|
312
|
+
an `elestio.yml`. The `elestio.yml` is what makes the software actually run:
|
|
313
|
+
|
|
314
|
+
```yaml
|
|
315
|
+
config: { runTime, version, buildCommand, runCommand, buildDir }
|
|
316
|
+
environments: [{ key, value }] # becomes the pipeline's env vars
|
|
317
|
+
ports: [{ protocol, targetPort, public, path }]
|
|
318
|
+
lifeCycleConfig: { preInstallCommand, postInstallCommand, ... }
|
|
319
|
+
webUI: [{ url, label, login, password }] # credentials printed on success
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`deploy-template` reads that file and builds the pipeline from it. Passwords
|
|
323
|
+
written as `random_password` are generated, `[EMAIL]` becomes your account
|
|
324
|
+
email, and `[CI_CD_DOMAIN]` is resolved by the platform once the pipeline has a
|
|
325
|
+
domain.
|
|
326
|
+
|
|
327
|
+
**Two routes:**
|
|
328
|
+
|
|
329
|
+
| | compose (default) | git (`--owner <git-user>`) |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| What it does | Inlines the template's `docker-compose.yml` | Generates the template repo into your Git account, then builds from it |
|
|
332
|
+
| Needs a Git account | No | Yes, connected in the dashboard |
|
|
333
|
+
| Lifecycle scripts | Skipped | Run |
|
|
334
|
+
| Repo files the compose mounts | Unavailable | Available |
|
|
335
|
+
| Works for | Templates that need no files from the repo | Every template |
|
|
336
|
+
|
|
337
|
+
The compose route has no checkout, so a template whose `docker-compose.yml`
|
|
338
|
+
bind-mounts a file from its repo cannot work: Docker creates the missing source
|
|
339
|
+
as an empty directory and the container fails to start. The CLI detects this
|
|
340
|
+
from the compose file and refuses upfront, naming the files, rather than
|
|
341
|
+
letting the build fail a minute later with a `runc` error. `--force` overrides.
|
|
342
|
+
|
|
343
|
+
This affects more templates than you would expect - n8n
|
|
344
|
+
(`./n8n-task-runners.json`), Rybbit (four files under `./configs/`) and
|
|
345
|
+
WordPress (`./php.ini`) are all in this category. Vaultwarden, Redis and
|
|
346
|
+
Metabase deploy cleanly on the compose route.
|
|
347
|
+
|
|
348
|
+
> **The git route is currently unavailable.** It needs
|
|
349
|
+
> `POST /api/cicd/createRepoByTemplate`, which the Elestio API returns 404 for:
|
|
350
|
+
> the controller exists in the backend but is not registered in its route
|
|
351
|
+
> whitelist. The CLI reports this explicitly instead of surfacing a bare 404.
|
|
352
|
+
|
|
216
353
|
| Command | Description |
|
|
217
354
|
|---------|-------------|
|
|
355
|
+
| `elestio cicd templates [query]` | List catalog software deployable as a pipeline |
|
|
356
|
+
| `elestio cicd deploy-template <software> --target <vmID>` | Deploy catalog software as a pipeline |
|
|
218
357
|
| `elestio cicd targets` | List CI/CD targets |
|
|
219
358
|
| `elestio cicd pipelines <vmID>` | List pipelines |
|
|
220
359
|
| `elestio cicd pipeline-info <vmID> <pipelineID>` | Pipeline details |
|
|
@@ -224,12 +363,58 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
|
|
|
224
363
|
| `elestio cicd pipeline-resync <vmID> <pipelineID>` | Re-sync pipeline |
|
|
225
364
|
| `elestio cicd pipeline-logs <vmID> <pipelineID>` | View pipeline logs |
|
|
226
365
|
| `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history |
|
|
227
|
-
| `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` |
|
|
366
|
+
| `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` | Pipeline from your own repo |
|
|
228
367
|
| `elestio cicd create <config.json>` | Create from config file |
|
|
229
368
|
| `elestio cicd template [mode]` | Generate config template |
|
|
230
369
|
| `elestio cicd domains <vmID> <pipelineID>` | List pipeline domains |
|
|
231
370
|
| `elestio cicd registries` | List Docker registries |
|
|
232
|
-
|
|
371
|
+
|
|
372
|
+
**`deploy-template` options:**
|
|
373
|
+
|
|
374
|
+
| Option | Description |
|
|
375
|
+
|--------|-------------|
|
|
376
|
+
| `--target <vmID>` | **Required.** CI/CD target to deploy onto (`elestio cicd targets`) |
|
|
377
|
+
| `--owner <user-or-org>` | Switches to the git route and names the account to create the repo in |
|
|
378
|
+
| `--no-git` | Force the compose route even when `--owner` is given |
|
|
379
|
+
| `--name <name>` | Pipeline name (defaults to the software name) |
|
|
380
|
+
| `--branch <branch>` | Template branch (default `main`) |
|
|
381
|
+
| `--private` | Create the generated repo as private |
|
|
382
|
+
| `--non-org` | The owner is a personal account, not an organisation |
|
|
383
|
+
| `--auth-id <id>` | Git auth ID, when you have more than one account connected |
|
|
384
|
+
| `--git-type <type>` | `GITHUB` (default) or `GITLAB` |
|
|
385
|
+
| `--repo-name <name>` | Template repo to use, when it differs from the software name |
|
|
386
|
+
| `--build-cmd`, `--run-cmd`, `--install-cmd`, `--build-dir` | Override the values from `elestio.yml` (git route only) |
|
|
387
|
+
| `--variables <KEY=VALUE...>` | Override the environment variables, newline-separated (git route only) |
|
|
388
|
+
| `--force` | Deploy on the compose route even when the compose mounts a repo file |
|
|
389
|
+
| `--dry-run` | Print the plan, create nothing |
|
|
390
|
+
|
|
391
|
+
#### Pipelines from your own repository
|
|
392
|
+
|
|
393
|
+
```bash
|
|
394
|
+
elestio cicd create --auto --target <vmID> --name my-app --repo acme/my-app \
|
|
395
|
+
--mode github --build-cmd "npm run build" --run-cmd "npm start"
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Modes: `github`, `github-fullstack`, `gitlab`, `gitlab-fullstack`, `docker`.
|
|
399
|
+
|
|
400
|
+
#### Docker registries
|
|
401
|
+
|
|
402
|
+
| Command | Description |
|
|
403
|
+
|---------|-------------|
|
|
404
|
+
| `elestio cicd registry-add --name X --username U --password P --url REPO` | Docker Hub |
|
|
405
|
+
| `... --registry-type registry.gitlab.com --repo-id ID` | GitLab.com |
|
|
406
|
+
| `... --registry-type gitlab-self-hosted --repo-id ID --gitlab-url gitlab.company.com` | Self-hosted GitLab |
|
|
407
|
+
| `... --registry-type ghcr.io` | GitHub Container Registry |
|
|
408
|
+
|
|
409
|
+
| Option | Description |
|
|
410
|
+
|--------|-------------|
|
|
411
|
+
| `--name` | Unique identity nickname for the registry credential |
|
|
412
|
+
| `--username` | Registry username |
|
|
413
|
+
| `--password` | Registry password or access token |
|
|
414
|
+
| `--url` | Repository path (e.g. `myuser/myrepo`) - **not** the registry host |
|
|
415
|
+
| `--registry-type` | `docker.io` (default), `registry.gitlab.com`, `gitlab-self-hosted`, `ghcr.io` |
|
|
416
|
+
| `--repo-id` | GitLab project/repo ID - required for both GitLab types |
|
|
417
|
+
| `--gitlab-url` | Self-hosted GitLab hostname - required for `gitlab-self-hosted` |
|
|
233
418
|
|
|
234
419
|
### Billing
|
|
235
420
|
|
|
@@ -248,15 +433,68 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
|
|
|
248
433
|
| `--version`, `-v` | Show version |
|
|
249
434
|
| `--debug` | Show full error stack traces |
|
|
250
435
|
|
|
436
|
+
### Passing values that start with `-`
|
|
437
|
+
|
|
438
|
+
`--flag value` treats a value like `-p` or `--port` as the next flag. For any
|
|
439
|
+
value that begins with a dash - passwords, negative numbers - use the
|
|
440
|
+
`--flag=value` form instead:
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
elestio cicd registry-add --name ghcr --username me --password='-Xk9secret'
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Everything after a bare `--` is treated as a positional argument.
|
|
447
|
+
|
|
251
448
|
## Configuration
|
|
252
449
|
|
|
253
|
-
Credentials and config are stored in `~/.elestio
|
|
450
|
+
Credentials and config are stored in `~/.elestio/` (directory mode 0700):
|
|
254
451
|
|
|
255
452
|
- `~/.elestio/credentials` - Email and API token (mode 0600)
|
|
256
|
-
- `~/.elestio/config.json` - JWT cache, default project, provider defaults
|
|
453
|
+
- `~/.elestio/config.json` - JWT cache, default project, provider defaults (mode 0600; the cached JWT is a bearer credential)
|
|
454
|
+
|
|
455
|
+
Requests to the API time out after 60s rather than hanging.
|
|
257
456
|
|
|
258
457
|
Get your API token from [Elestio Dashboard > Security](https://dash.elest.io/account/security).
|
|
259
458
|
|
|
459
|
+
## Troubleshooting
|
|
460
|
+
|
|
461
|
+
**My pipeline deployed but nothing is running.**
|
|
462
|
+
The pipeline was created without the template's configuration. Use
|
|
463
|
+
`elestio cicd deploy-template <software>` rather than `cicd create` for catalog
|
|
464
|
+
software: it reads the template's `elestio.yml` for ports, environment
|
|
465
|
+
variables and lifecycle hooks. `cicd create` builds a bare pipeline and expects
|
|
466
|
+
you to supply all of that yourself.
|
|
467
|
+
|
|
468
|
+
**The software starts, then exits, or the build fails.**
|
|
469
|
+
Check the build log: `elestio cicd pipeline-history <vmID> <pipelineID>`, then
|
|
470
|
+
`elestio cicd pipeline-log <vmID> --pipeline <id> --file <log>`. On the compose
|
|
471
|
+
route the usual causes are a repo file the compose mounts (the CLI refuses
|
|
472
|
+
these upfront unless you passed `--force`) or a template that genuinely needs
|
|
473
|
+
its lifecycle scripts. Both need the git route, which is currently
|
|
474
|
+
unavailable (see above), so for now there is no workaround for those
|
|
475
|
+
templates.
|
|
476
|
+
|
|
477
|
+
**`variables.trim is not a function` (500 Pipeline.CreateFailed).**
|
|
478
|
+
The `variables` field must be a newline-separated string, never an array. The
|
|
479
|
+
CLI enforces this from 1.1.0; if you are on an older version, upgrade with
|
|
480
|
+
`npm install -g elestio@latest`.
|
|
481
|
+
|
|
482
|
+
**`does not support clustering`.**
|
|
483
|
+
Only some templates can be clustered. Run `elestio clusters templates` for the
|
|
484
|
+
current list.
|
|
485
|
+
|
|
486
|
+
**`needs at least 3 nodes`.**
|
|
487
|
+
ClickHouse, Vault, OpenSearch, RabbitMQ, rke2 and Nats elect a leader by
|
|
488
|
+
quorum and cannot run on two nodes.
|
|
489
|
+
|
|
490
|
+
**Authentication keeps failing.**
|
|
491
|
+
API tokens can be revoked or expire. Get a new one from
|
|
492
|
+
[Dashboard > Security](https://dash.elest.io/account/security) and re-run
|
|
493
|
+
`elestio login`. Check what is stored with `elestio config`.
|
|
494
|
+
|
|
495
|
+
**A value starting with `-` is ignored.**
|
|
496
|
+
Use `--flag=value`. See [Global Options](#global-options).
|
|
497
|
+
|
|
260
498
|
## Documentation
|
|
261
499
|
https://docs.elest.io/books/elestio-cli-skill/page/overview
|
|
262
500
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elestio",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Elestio CLI - Deploy and manage services on the Elestio DevOps platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/cli.js",
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
"elestio": "./bin/elestio.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"start": "node bin/elestio.js"
|
|
11
|
+
"start": "node bin/elestio.js",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"test:watch": "vitest",
|
|
14
|
+
"test:coverage": "vitest run --coverage"
|
|
12
15
|
},
|
|
13
16
|
"keywords": [
|
|
14
17
|
"elestio",
|
|
@@ -38,5 +41,9 @@
|
|
|
38
41
|
"homepage": "https://elest.io",
|
|
39
42
|
"bugs": {
|
|
40
43
|
"url": "https://github.com/elestio/elestio-cli/issues"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
47
|
+
"vitest": "^2.1.8"
|
|
41
48
|
}
|
|
42
49
|
}
|
package/src/api.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { loadConfig, saveConfig
|
|
2
|
-
import { log } from './utils.js';
|
|
1
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
3
2
|
|
|
4
3
|
const BASE_URL = 'https://api.elest.io';
|
|
5
4
|
|
|
5
|
+
// Requests that exceed this are aborted rather than hanging the CLI forever.
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 60000;
|
|
7
|
+
|
|
6
8
|
// ── JWT management ──
|
|
7
9
|
|
|
8
10
|
function isJwtExpired(config) {
|
|
@@ -10,14 +12,52 @@ function isJwtExpired(config) {
|
|
|
10
12
|
return Date.now() > (config.jwtExpiry - 300000); // 5 min buffer
|
|
11
13
|
}
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Wraps fetch with a timeout and turns transport failures into readable errors.
|
|
17
|
+
*/
|
|
18
|
+
async function httpRequest(url, options) {
|
|
19
|
+
let response;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
response = await fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
25
|
+
throw new Error(`Request to ${BASE_URL} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Cannot reach ${BASE_URL}: ${err.message}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return response;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The API returns HTML on gateway errors, so response.json() alone would surface
|
|
35
|
+
* "Unexpected token <" instead of the real problem.
|
|
36
|
+
*/
|
|
37
|
+
async function parseJson(response, endpoint) {
|
|
38
|
+
const text = await response.text();
|
|
39
|
+
|
|
40
|
+
if (text === '') {
|
|
41
|
+
if (response.ok) return {};
|
|
42
|
+
throw new Error(`${endpoint} failed: HTTP ${response.status} ${response.statusText} (empty response)`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
const snippet = text.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
49
|
+
throw new Error(`${endpoint} returned a non-JSON response (HTTP ${response.status}): ${snippet}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
async function authenticate(email, token) {
|
|
14
|
-
const response = await
|
|
54
|
+
const response = await httpRequest(`${BASE_URL}/api/auth/checkAPIToken`, {
|
|
15
55
|
method: 'POST',
|
|
16
56
|
headers: { 'Content-Type': 'application/json' },
|
|
17
57
|
body: JSON.stringify({ email, token })
|
|
18
58
|
});
|
|
19
59
|
|
|
20
|
-
const data = await response
|
|
60
|
+
const data = await parseJson(response, '/api/auth/checkAPIToken');
|
|
21
61
|
|
|
22
62
|
if (data.status !== 'OK' || !data.jwt) {
|
|
23
63
|
throw new Error(data.message || 'Authentication failed');
|
|
@@ -38,9 +78,8 @@ export async function getJwt() {
|
|
|
38
78
|
|
|
39
79
|
if (isJwtExpired(config)) {
|
|
40
80
|
const auth = await authenticate(config.email, config.apiToken);
|
|
41
|
-
config.jwt
|
|
42
|
-
|
|
43
|
-
saveConfig(config);
|
|
81
|
+
saveConfig({ ...config, jwt: auth.jwt, jwtExpiry: auth.jwtExpiry });
|
|
82
|
+
return auth.jwt;
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
return config.jwt;
|
|
@@ -48,6 +87,11 @@ export async function getJwt() {
|
|
|
48
87
|
|
|
49
88
|
// ── API requests ──
|
|
50
89
|
|
|
90
|
+
function clearJwt() {
|
|
91
|
+
const config = loadConfig();
|
|
92
|
+
saveConfig({ ...config, jwt: null, jwtExpiry: null });
|
|
93
|
+
}
|
|
94
|
+
|
|
51
95
|
export async function apiRequest(endpoint, method = 'POST', body = {}, retried = false) {
|
|
52
96
|
const jwt = await getJwt();
|
|
53
97
|
|
|
@@ -60,21 +104,19 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
|
|
|
60
104
|
options.body = JSON.stringify({ jwt, ...body });
|
|
61
105
|
}
|
|
62
106
|
|
|
63
|
-
|
|
107
|
+
// GET routes read parameters from the query string only (backend requirement).
|
|
108
|
+
const url = method === 'GET'
|
|
64
109
|
? `${BASE_URL}${endpoint}?${new URLSearchParams({ jwt, ...body })}`
|
|
65
110
|
: `${BASE_URL}${endpoint}`;
|
|
66
111
|
|
|
67
|
-
const response = await
|
|
112
|
+
const response = await httpRequest(url, options);
|
|
68
113
|
|
|
69
114
|
if (response.status === 401 && !retried) {
|
|
70
|
-
|
|
71
|
-
config.jwt = null;
|
|
72
|
-
config.jwtExpiry = null;
|
|
73
|
-
saveConfig(config);
|
|
115
|
+
clearJwt();
|
|
74
116
|
return apiRequest(endpoint, method, body, true);
|
|
75
117
|
}
|
|
76
118
|
|
|
77
|
-
const data = await response
|
|
119
|
+
const data = await parseJson(response, endpoint);
|
|
78
120
|
|
|
79
121
|
const isAuthError = !retried && (
|
|
80
122
|
(data.status === 'error' && data.message?.toLowerCase().includes('auth')) ||
|
|
@@ -83,10 +125,7 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
|
|
|
83
125
|
);
|
|
84
126
|
|
|
85
127
|
if (isAuthError) {
|
|
86
|
-
|
|
87
|
-
config.jwt = null;
|
|
88
|
-
config.jwtExpiry = null;
|
|
89
|
-
saveConfig(config);
|
|
128
|
+
clearJwt();
|
|
90
129
|
return apiRequest(endpoint, method, body, true);
|
|
91
130
|
}
|
|
92
131
|
|
|
@@ -94,12 +133,11 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
|
|
|
94
133
|
}
|
|
95
134
|
|
|
96
135
|
export async function apiRequestNoAuth(endpoint, method = 'GET') {
|
|
97
|
-
const
|
|
98
|
-
const response = await fetch(url, {
|
|
136
|
+
const response = await httpRequest(`${BASE_URL}${endpoint}`, {
|
|
99
137
|
method,
|
|
100
138
|
headers: { 'Content-Type': 'application/json' }
|
|
101
139
|
});
|
|
102
|
-
return response
|
|
140
|
+
return parseJson(response, endpoint);
|
|
103
141
|
}
|
|
104
142
|
|
|
105
143
|
export { BASE_URL, authenticate };
|