@cloudbeat/atlas-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/README.md +247 -0
- package/dist/api.js +159 -0
- package/dist/cli.js +83 -0
- package/dist/commands/auth.js +89 -0
- package/dist/commands/files.js +149 -0
- package/dist/commands/projects.js +37 -0
- package/dist/commands/record.js +105 -0
- package/dist/commands/results.js +127 -0
- package/dist/commands/run.js +173 -0
- package/dist/config.js +54 -0
- package/dist/gates.js +67 -0
- package/dist/junit.js +63 -0
- package/dist/ui.js +70 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# @cloudbeat/atlas-cli
|
|
2
|
+
|
|
3
|
+
Run Atlas load tests from your terminal or a CI pipeline, and record browser
|
|
4
|
+
sessions into JMeter test plans.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install -g @cloudbeat/atlas-cli
|
|
8
|
+
atlas --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18 or newer.
|
|
12
|
+
|
|
13
|
+
## Getting started
|
|
14
|
+
|
|
15
|
+
Point the CLI at your Atlas instance and sign in.
|
|
16
|
+
|
|
17
|
+
For everyday use:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
atlas login --url https://atlas.cloudbeat.io -u you@company.com
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
You'll be prompted for your password. The session lasts 8 hours.
|
|
24
|
+
|
|
25
|
+
For CI, create an **API token** on the Profile page of your Atlas instance and
|
|
26
|
+
use that instead — it doesn't expire on its own and can be revoked at any time:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
atlas login --token atlas_pat_...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Credentials are stored in `~/.atlas/config.json`, readable only by you.
|
|
33
|
+
|
|
34
|
+
An API token acts as the person who created it and has the same permissions.
|
|
35
|
+
It cannot be used to create more tokens or to change a password, so it is safe
|
|
36
|
+
to hand to a build pipeline. Revoking it on the Profile page stops it working
|
|
37
|
+
immediately.
|
|
38
|
+
|
|
39
|
+
## Uploading files
|
|
40
|
+
|
|
41
|
+
A project holds your test plan and any data files it reads. Upload them
|
|
42
|
+
whenever they change — this is independent of running a test, and uploaded
|
|
43
|
+
files stay in the project until you replace or delete them.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
atlas files upload -p SKT ./SKT-2026.jmx ./data/mosdot.csv ./data/rashut.csv
|
|
47
|
+
atlas files list -p SKT
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Uploading a file with a name that already exists replaces it.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
atlas files download -p SKT SKT-2026.jmx # fetch a copy back
|
|
54
|
+
atlas files rm -p SKT old-data.csv # delete one
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### How files are laid out — and how to reference them
|
|
58
|
+
|
|
59
|
+
**All of a project's files live together in one flat folder.** There are no
|
|
60
|
+
subdirectories: whatever you upload lands side by side, and when a test runs,
|
|
61
|
+
the test plan and its data files sit in the same directory on every load
|
|
62
|
+
generator.
|
|
63
|
+
|
|
64
|
+
So in your test plan, reference data files by a plain relative path:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
./mosdot.csv ✅ recommended
|
|
68
|
+
mosdot.csv ✅ also works
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Not by an absolute path, and not through a folder:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
C:\Users\me\Data\mosdot.csv ❌ a Windows path cannot be found on a Linux generator
|
|
75
|
+
/home/me/data/mosdot.csv ❌ absolute paths won't exist there either
|
|
76
|
+
./data/mosdot.csv ❌ the folder structure is not preserved
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This catches people out most often with test plans authored in JMeter on
|
|
80
|
+
Windows, where **CSV Data Set Config** entries usually hold a full `C:\…` path.
|
|
81
|
+
Open each one and reduce it to `./filename`. If a path can't be resolved at run
|
|
82
|
+
time, every thread in that thread group stops immediately — the test appears to
|
|
83
|
+
run but that group reports no results at all.
|
|
84
|
+
|
|
85
|
+
Two related things worth checking before your first run:
|
|
86
|
+
|
|
87
|
+
- Every file the plan references must actually be uploaded. Upload the plan and
|
|
88
|
+
its data files together.
|
|
89
|
+
- Disable GUI listeners left switched on in the plan — **View Results Tree**,
|
|
90
|
+
**Aggregate Report**, **Simple Data Writer**. They consume a lot of memory
|
|
91
|
+
during a headless run, and Atlas collects its results separately.
|
|
92
|
+
|
|
93
|
+
## Running a test
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
atlas run -p SKT --jmx SKT-2026.jmx --servers 5 --wait
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`--wait` follows the run to completion and prints the aggregation report.
|
|
100
|
+
Without it, the command returns as soon as the test has been submitted, and you
|
|
101
|
+
can check on it later:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
atlas status SKT-20260818-194201
|
|
105
|
+
atlas results SKT-20260818-194201
|
|
106
|
+
atlas stop SKT-20260818-194201
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`--jmx` is only needed when a project holds more than one test plan.
|
|
110
|
+
|
|
111
|
+
Load generators that are switched off are started for you and shut down again
|
|
112
|
+
when the test finishes.
|
|
113
|
+
|
|
114
|
+
### Choosing where it runs
|
|
115
|
+
|
|
116
|
+
| | |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `--servers 5` | use any 5 available load generators |
|
|
119
|
+
| `--server gen-01 --server gen-02` | pick specific ones by name |
|
|
120
|
+
| `--cluster my-cluster --units 10` | run on 10 containers in an elastic cluster |
|
|
121
|
+
|
|
122
|
+
### Other run options
|
|
123
|
+
|
|
124
|
+
| | |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `--jmx <filename>` | which uploaded plan to run (defaults to the project's) |
|
|
127
|
+
| `--args "<jmeter args>"` | extra JMeter arguments |
|
|
128
|
+
| `--no-split` | send whole data files to every generator instead of dividing them up |
|
|
129
|
+
| `--timeout 30m` | how long `--wait` will wait before giving up (default `2h`) |
|
|
130
|
+
| `--json` | machine-readable output |
|
|
131
|
+
|
|
132
|
+
## Using it in CI
|
|
133
|
+
|
|
134
|
+
Set the connection details as environment variables and let the exit code fail
|
|
135
|
+
the build:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
export ATLAS_URL=https://atlas.cloudbeat.io
|
|
139
|
+
export ATLAS_TOKEN=atlas_pat_...
|
|
140
|
+
|
|
141
|
+
# Only if the plan or its data live in this repository and may have changed.
|
|
142
|
+
atlas files upload -p SKT ./SKT-2026.jmx ./data/mosdot.csv
|
|
143
|
+
|
|
144
|
+
atlas run -p SKT --jmx SKT-2026.jmx --servers 5 \
|
|
145
|
+
--gate "error_rate<1,p95<2000" \
|
|
146
|
+
--junit results.xml
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
If your test plan is maintained in Atlas rather than in the repository, drop the
|
|
150
|
+
upload step — the run command works on its own.
|
|
151
|
+
|
|
152
|
+
### Pass/fail thresholds
|
|
153
|
+
|
|
154
|
+
`--gate` fails the command when a threshold is breached. Thresholds are checked
|
|
155
|
+
against the run's overall totals:
|
|
156
|
+
|
|
157
|
+
| metric | meaning |
|
|
158
|
+
|--------|---------|
|
|
159
|
+
| `error_rate` | percentage of failed requests |
|
|
160
|
+
| `avg` | average response time, ms |
|
|
161
|
+
| `p90`, `p95`, `p99` | percentile response times, ms |
|
|
162
|
+
| `max` | slowest response, ms |
|
|
163
|
+
| `throughput` | requests per second |
|
|
164
|
+
| `requests` | total requests |
|
|
165
|
+
|
|
166
|
+
Use `<`, `<=`, `>` or `>=`. Separate several with commas, or repeat `--gate`.
|
|
167
|
+
Setting a gate implies `--wait`, since there is nothing to check until the test
|
|
168
|
+
has finished.
|
|
169
|
+
|
|
170
|
+
### Exit codes
|
|
171
|
+
|
|
172
|
+
| code | meaning |
|
|
173
|
+
|------|---------|
|
|
174
|
+
| `0` | the test ran and every threshold passed |
|
|
175
|
+
| `1` | the test ran but a `--gate` threshold was breached |
|
|
176
|
+
| `2` | something was wrong with the request — bad option, unknown project, not signed in |
|
|
177
|
+
| `3` | the test failed or timed out, or the server could not be reached |
|
|
178
|
+
|
|
179
|
+
### JUnit report
|
|
180
|
+
|
|
181
|
+
`--junit results.xml` writes a standard JUnit file with one test case per
|
|
182
|
+
threshold, so CI shows each one as its own pass or fail. The run's headline
|
|
183
|
+
numbers are included as properties.
|
|
184
|
+
|
|
185
|
+
## Recording a scenario
|
|
186
|
+
|
|
187
|
+
`atlas record` opens a browser, records what you do, and writes a JMeter test
|
|
188
|
+
plan:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
atlas record https://example.com -o flow.jmx
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Add `--upload -p SKT` to put the finished plan straight into a project, ready
|
|
195
|
+
to run.
|
|
196
|
+
|
|
197
|
+
| | |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `-o, --out <file>` | output file (an existing plan is added to, not replaced) |
|
|
200
|
+
| `-n, --name <name>` | name for the recorded scenario |
|
|
201
|
+
| `--cookies <file>` | preload cookies to skip a login step |
|
|
202
|
+
| `--profile <dir>` | reuse a browser profile for a persistent session |
|
|
203
|
+
|
|
204
|
+
The recorder is distributed separately because it downloads its own browser,
|
|
205
|
+
which most CI machines don't need. `atlas record` will tell you how to install
|
|
206
|
+
it if it isn't present.
|
|
207
|
+
|
|
208
|
+
## Command reference
|
|
209
|
+
|
|
210
|
+
| | |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `atlas login` | sign in with a password or an API token |
|
|
213
|
+
| `atlas logout` | forget the stored credential |
|
|
214
|
+
| `atlas whoami` | show who you're signed in as |
|
|
215
|
+
| `atlas projects` | list the projects you can access |
|
|
216
|
+
| `atlas files list` | list a project's files |
|
|
217
|
+
| `atlas files upload <files...>` | upload test plans and data files |
|
|
218
|
+
| `atlas files download <filename>` | download one file |
|
|
219
|
+
| `atlas files rm <filename>` | delete a file (`--yes` to skip the prompt) |
|
|
220
|
+
| `atlas run` | start a test |
|
|
221
|
+
| `atlas status <testId>` | check whether a test is still running |
|
|
222
|
+
| `atlas results <testId>` | show the aggregation report for a test |
|
|
223
|
+
| `atlas stop <testId>` | stop a running test |
|
|
224
|
+
| `atlas record [url]` | record a browser session |
|
|
225
|
+
| `atlas config get` / `set` | default instance URL and project |
|
|
226
|
+
|
|
227
|
+
Most commands take `-p, --project <name>` and `--json`. Run
|
|
228
|
+
`atlas <command> --help` for the full list of options.
|
|
229
|
+
|
|
230
|
+
## Settings
|
|
231
|
+
|
|
232
|
+
Command-line options win over environment variables, which win over the saved
|
|
233
|
+
configuration file.
|
|
234
|
+
|
|
235
|
+
| variable | meaning |
|
|
236
|
+
|----------|---------|
|
|
237
|
+
| `ATLAS_URL` | your Atlas instance |
|
|
238
|
+
| `ATLAS_TOKEN` | API token |
|
|
239
|
+
| `ATLAS_PROJECT` | project to use when `--project` is omitted |
|
|
240
|
+
| `ATLAS_DEBUG` | set to any value to print full error details |
|
|
241
|
+
|
|
242
|
+
To avoid repeating yourself:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
atlas config set project SKT
|
|
246
|
+
atlas config set url https://atlas.cloudbeat.io
|
|
247
|
+
```
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AtlasApi = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const config_1 = require("./config");
|
|
10
|
+
const ui_1 = require("./ui");
|
|
11
|
+
// ── Client ───────────────────────────────────────────────────────────────────
|
|
12
|
+
class AtlasApi {
|
|
13
|
+
url;
|
|
14
|
+
token;
|
|
15
|
+
constructor(cfg) {
|
|
16
|
+
this.url = (cfg.url ?? '').replace(/\/+$/, '');
|
|
17
|
+
this.token = cfg.token;
|
|
18
|
+
}
|
|
19
|
+
static fromConfig(flags = {}) {
|
|
20
|
+
return new AtlasApi((0, config_1.loadConfig)(flags));
|
|
21
|
+
}
|
|
22
|
+
get baseUrl() {
|
|
23
|
+
return this.url;
|
|
24
|
+
}
|
|
25
|
+
authHeaders() {
|
|
26
|
+
if (!this.token) {
|
|
27
|
+
throw new ui_1.CliError('Not authenticated. Run `atlas login`, or set ATLAS_TOKEN to an API token.', ui_1.EXIT.USAGE);
|
|
28
|
+
}
|
|
29
|
+
return { Authorization: `Bearer ${this.token}` };
|
|
30
|
+
}
|
|
31
|
+
/** Any request against the control plane; `auth: false` for the public API. */
|
|
32
|
+
async request(method, routePath, opts = {}) {
|
|
33
|
+
const auth = opts.auth !== false;
|
|
34
|
+
const headers = auth ? this.authHeaders() : {};
|
|
35
|
+
if (opts.body !== undefined)
|
|
36
|
+
headers['Content-Type'] = 'application/json';
|
|
37
|
+
let res;
|
|
38
|
+
try {
|
|
39
|
+
res = await fetch(`${this.url}${routePath}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers,
|
|
42
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new ui_1.CliError(`Cannot reach ${this.url}: ${err.message}`, ui_1.EXIT.ERROR);
|
|
47
|
+
}
|
|
48
|
+
return this.parse(method, routePath, res);
|
|
49
|
+
}
|
|
50
|
+
async parse(method, routePath, res) {
|
|
51
|
+
const text = await res.text();
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
payload = text ? JSON.parse(text) : {};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
payload = { error: text.slice(0, 200) };
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const message = payload.error ?? res.statusText;
|
|
61
|
+
// 401/403 are almost always a credential problem the user must fix, not a
|
|
62
|
+
// transient failure worth retrying — surface them as usage errors.
|
|
63
|
+
const code = res.status === 401 || res.status === 403 ? ui_1.EXIT.USAGE : ui_1.EXIT.ERROR;
|
|
64
|
+
throw new ui_1.CliError(`${method} ${routePath} → ${res.status}: ${message}`, code);
|
|
65
|
+
}
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
// ── auth ──────────────────────────────────────────────────────────────────
|
|
69
|
+
async login(username, password) {
|
|
70
|
+
let res;
|
|
71
|
+
try {
|
|
72
|
+
res = await fetch(`${this.url}/admin/api/auth/login`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ username, password }),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new ui_1.CliError(`Cannot reach ${this.url}: ${err.message}`, ui_1.EXIT.ERROR);
|
|
80
|
+
}
|
|
81
|
+
return this.parse('POST', '/admin/api/auth/login', res);
|
|
82
|
+
}
|
|
83
|
+
async me() {
|
|
84
|
+
return this.request('GET', '/admin/api/auth/me');
|
|
85
|
+
}
|
|
86
|
+
// ── projects & files ──────────────────────────────────────────────────────
|
|
87
|
+
async projects() {
|
|
88
|
+
return this.request('GET', '/admin/api/projects');
|
|
89
|
+
}
|
|
90
|
+
/** Projects are addressed by name on the CLI; the API wants the id. */
|
|
91
|
+
async projectByName(name) {
|
|
92
|
+
const all = await this.projects();
|
|
93
|
+
const hit = all.find(p => p.name.toLowerCase() === name.toLowerCase());
|
|
94
|
+
if (!hit) {
|
|
95
|
+
const known = all.map(p => p.name).join(', ') || '(none visible to this token)';
|
|
96
|
+
throw new ui_1.CliError(`No project named "${name}". Available: ${known}`, ui_1.EXIT.USAGE);
|
|
97
|
+
}
|
|
98
|
+
return hit;
|
|
99
|
+
}
|
|
100
|
+
async files(projectId) {
|
|
101
|
+
return this.request('GET', `/admin/api/projects/${projectId}/files`);
|
|
102
|
+
}
|
|
103
|
+
async uploadFiles(projectId, paths) {
|
|
104
|
+
const form = new FormData();
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
if (!fs_1.default.existsSync(p))
|
|
107
|
+
throw new ui_1.CliError(`File not found: ${p}`, ui_1.EXIT.USAGE);
|
|
108
|
+
const buf = fs_1.default.readFileSync(p);
|
|
109
|
+
form.append('files', new Blob([buf]), path_1.default.basename(p));
|
|
110
|
+
}
|
|
111
|
+
let res;
|
|
112
|
+
try {
|
|
113
|
+
res = await fetch(`${this.url}/admin/api/projects/${projectId}/files`, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: this.authHeaders(), // no Content-Type: fetch sets the multipart boundary
|
|
116
|
+
body: form,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
throw new ui_1.CliError(`Upload failed: ${err.message}`, ui_1.EXIT.ERROR);
|
|
121
|
+
}
|
|
122
|
+
return this.parse('POST', `/admin/api/projects/${projectId}/files`, res);
|
|
123
|
+
}
|
|
124
|
+
async downloadFile(projectId, filename) {
|
|
125
|
+
const res = await fetch(`${this.url}/admin/api/projects/${projectId}/files/${encodeURIComponent(filename)}/download`, { headers: this.authHeaders() });
|
|
126
|
+
if (!res.ok)
|
|
127
|
+
throw new ui_1.CliError(`Download failed: ${res.status} ${res.statusText}`, ui_1.EXIT.ERROR);
|
|
128
|
+
return Buffer.from(await res.arrayBuffer());
|
|
129
|
+
}
|
|
130
|
+
async deleteFile(projectId, filename) {
|
|
131
|
+
await this.request('DELETE', `/admin/api/projects/${projectId}/files/${encodeURIComponent(filename)}`);
|
|
132
|
+
}
|
|
133
|
+
// ── fleet ─────────────────────────────────────────────────────────────────
|
|
134
|
+
async servers() {
|
|
135
|
+
return this.request('GET', '/admin/api/servers');
|
|
136
|
+
}
|
|
137
|
+
async clusters() {
|
|
138
|
+
return this.request('GET', '/admin/api/clusters');
|
|
139
|
+
}
|
|
140
|
+
// ── runs ──────────────────────────────────────────────────────────────────
|
|
141
|
+
async run(projectId, body) {
|
|
142
|
+
return this.request('POST', `/admin/api/projects/${projectId}/run`, { body });
|
|
143
|
+
}
|
|
144
|
+
async queueEntry(queueId) {
|
|
145
|
+
return this.request('GET', `/admin/api/tests/queue/${queueId}`);
|
|
146
|
+
}
|
|
147
|
+
async stop(testId) {
|
|
148
|
+
return this.request('POST', `/admin/api/tests/${encodeURIComponent(testId)}/stop`, { body: {} });
|
|
149
|
+
}
|
|
150
|
+
// ── results (public dashboard API) ─────────────────────────────────────────
|
|
151
|
+
async summary(testId) {
|
|
152
|
+
return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/summary`, { auth: false });
|
|
153
|
+
}
|
|
154
|
+
async testStatus(testId) {
|
|
155
|
+
return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/status`, { auth: false });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
exports.AtlasApi = AtlasApi;
|
|
159
|
+
//# sourceMappingURL=api.js.map
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const ui_1 = require("./ui");
|
|
10
|
+
const config_1 = require("./config");
|
|
11
|
+
const auth_1 = require("./commands/auth");
|
|
12
|
+
const projects_1 = require("./commands/projects");
|
|
13
|
+
const files_1 = require("./commands/files");
|
|
14
|
+
const run_1 = require("./commands/run");
|
|
15
|
+
const results_1 = require("./commands/results");
|
|
16
|
+
const record_1 = require("./commands/record");
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
|
+
const { version } = require('../package.json');
|
|
19
|
+
const program = new commander_1.Command();
|
|
20
|
+
program
|
|
21
|
+
.name('atlas')
|
|
22
|
+
.description('Atlas — run JMeter load tests from CI and record scenarios')
|
|
23
|
+
.version(version)
|
|
24
|
+
.addHelpText('after', `
|
|
25
|
+
Environment:
|
|
26
|
+
ATLAS_URL control plane URL (default https://atlas.cloudbeat.io)
|
|
27
|
+
ATLAS_TOKEN API token — create one on the Profile page
|
|
28
|
+
ATLAS_PROJECT default project for --project
|
|
29
|
+
|
|
30
|
+
Exit codes:
|
|
31
|
+
0 success 1 a --gate threshold was breached
|
|
32
|
+
2 usage error 3 the run failed, timed out, or the API was unreachable
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
atlas login --token atlas_pat_...
|
|
36
|
+
atlas files upload -p SKT ./SKT-2026.jmx ./data/*.txt
|
|
37
|
+
atlas run -p SKT --jmx SKT-2026.jmx --servers 5 --wait \\
|
|
38
|
+
--gate "error_rate<1,p95<2000" --junit results.xml
|
|
39
|
+
atlas record https://example.com -o flow.jmx --upload -p SKT
|
|
40
|
+
`);
|
|
41
|
+
(0, auth_1.registerAuth)(program);
|
|
42
|
+
(0, projects_1.registerProjects)(program);
|
|
43
|
+
(0, files_1.registerFiles)(program);
|
|
44
|
+
(0, run_1.registerRun)(program);
|
|
45
|
+
(0, results_1.registerResults)(program);
|
|
46
|
+
(0, record_1.registerRecord)(program);
|
|
47
|
+
// Small config helper so CI/dev can pin a default project or URL without env.
|
|
48
|
+
const config = program.command('config').description('Read or write CLI settings');
|
|
49
|
+
config
|
|
50
|
+
.command('get')
|
|
51
|
+
.description('Show the effective settings')
|
|
52
|
+
.action(() => {
|
|
53
|
+
const cfg = (0, config_1.loadConfig)();
|
|
54
|
+
ui_1.log.info(`url ${cfg.url}`);
|
|
55
|
+
ui_1.log.info(`project ${cfg.project ?? chalk_1.default.dim('(unset)')}`);
|
|
56
|
+
ui_1.log.info(`token ${cfg.token ? chalk_1.default.dim(cfg.token.slice(0, 16) + '…') : chalk_1.default.dim('(unset)')}`);
|
|
57
|
+
ui_1.log.dim((0, config_1.configPath)());
|
|
58
|
+
});
|
|
59
|
+
config
|
|
60
|
+
.command('set <key> <value>')
|
|
61
|
+
.description('Persist a setting (url | project)')
|
|
62
|
+
.action((key, value) => {
|
|
63
|
+
if (key !== 'url' && key !== 'project') {
|
|
64
|
+
throw new ui_1.CliError('Only "url" and "project" can be set. Use `atlas login` for credentials.', ui_1.EXIT.USAGE);
|
|
65
|
+
}
|
|
66
|
+
(0, config_1.saveConfig)({ [key]: value });
|
|
67
|
+
ui_1.log.ok(`${key} = ${value}`);
|
|
68
|
+
});
|
|
69
|
+
async function main() {
|
|
70
|
+
await program.parseAsync(process.argv);
|
|
71
|
+
}
|
|
72
|
+
main().catch((err) => {
|
|
73
|
+
// Expected failures print a message; anything else is a bug and keeps its stack.
|
|
74
|
+
if (err instanceof ui_1.CliError) {
|
|
75
|
+
ui_1.log.error(err.message);
|
|
76
|
+
process.exit(err.code);
|
|
77
|
+
}
|
|
78
|
+
ui_1.log.error(err.message ?? String(err));
|
|
79
|
+
if (process.env.ATLAS_DEBUG)
|
|
80
|
+
console.error(err);
|
|
81
|
+
process.exit(ui_1.EXIT.ERROR);
|
|
82
|
+
});
|
|
83
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerAuth = registerAuth;
|
|
7
|
+
const readline_1 = __importDefault(require("readline"));
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const api_1 = require("../api");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const ui_1 = require("../ui");
|
|
12
|
+
function ask(question, { hidden = false } = {}) {
|
|
13
|
+
const rl = readline_1.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
return new Promise(resolve => {
|
|
15
|
+
if (!hidden) {
|
|
16
|
+
rl.question(question, answer => { rl.close(); resolve(answer.trim()); });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// Suppress echo for the password: intercept the output stream while typing.
|
|
20
|
+
const rlAny = rl;
|
|
21
|
+
process.stdout.write(question);
|
|
22
|
+
rlAny._writeToOutput = () => { };
|
|
23
|
+
rl.question('', answer => {
|
|
24
|
+
rl.close();
|
|
25
|
+
process.stdout.write('\n');
|
|
26
|
+
resolve(answer.trim());
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function registerAuth(program) {
|
|
31
|
+
program
|
|
32
|
+
.command('login')
|
|
33
|
+
.description('Authenticate against an Atlas control plane')
|
|
34
|
+
.option('--url <url>', `control plane URL (default: ${config_1.DEFAULT_URL})`)
|
|
35
|
+
.option('--token <token>', 'API token (atlas_pat_…) — preferred for CI')
|
|
36
|
+
.option('-u, --user <email>', 'email address, for interactive login')
|
|
37
|
+
.action(async (opts) => {
|
|
38
|
+
const cfg = (0, config_1.loadConfig)({ url: opts.url });
|
|
39
|
+
const url = cfg.url;
|
|
40
|
+
if (opts.token) {
|
|
41
|
+
// Prove the token works before writing it — a saved bad token is worse
|
|
42
|
+
// than a failed login, because every later command fails obscurely.
|
|
43
|
+
const api = new api_1.AtlasApi({ url, token: opts.token });
|
|
44
|
+
const me = await api.me();
|
|
45
|
+
(0, config_1.saveConfig)({ url, token: opts.token });
|
|
46
|
+
ui_1.log.ok(`Authenticated as ${chalk_1.default.bold(me.user.username)} (${me.user.role}) at ${url}`);
|
|
47
|
+
ui_1.log.dim(`Token saved to ${(0, config_1.configPath)()}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const username = opts.user ?? (await ask('Email: '));
|
|
51
|
+
if (!username)
|
|
52
|
+
throw new ui_1.CliError('Email required', ui_1.EXIT.USAGE);
|
|
53
|
+
const password = await ask('Password: ', { hidden: true });
|
|
54
|
+
if (!password)
|
|
55
|
+
throw new ui_1.CliError('Password required', ui_1.EXIT.USAGE);
|
|
56
|
+
const api = new api_1.AtlasApi({ url });
|
|
57
|
+
const res = await api.login(username, password);
|
|
58
|
+
(0, config_1.saveConfig)({ url, token: res.token });
|
|
59
|
+
ui_1.log.ok(`Signed in as ${chalk_1.default.bold(res.user.username)} (${res.user.role}) at ${url}`);
|
|
60
|
+
ui_1.log.dim(`Session saved to ${(0, config_1.configPath)()}`);
|
|
61
|
+
ui_1.log.warn('This session expires in 8 hours. For CI, create an API token on the Profile page and use `atlas login --token`.');
|
|
62
|
+
});
|
|
63
|
+
program
|
|
64
|
+
.command('logout')
|
|
65
|
+
.description('Forget the stored credential')
|
|
66
|
+
.action(() => {
|
|
67
|
+
(0, config_1.clearCredentials)();
|
|
68
|
+
ui_1.log.ok('Credential removed.');
|
|
69
|
+
});
|
|
70
|
+
program
|
|
71
|
+
.command('whoami')
|
|
72
|
+
.description('Show the authenticated identity')
|
|
73
|
+
.option('--json', 'machine-readable output')
|
|
74
|
+
.action(async (opts) => {
|
|
75
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
76
|
+
const me = await api.me();
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
console.log(JSON.stringify(me, null, 2));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const active = me.activeAccountId != null
|
|
82
|
+
? me.accounts.find(a => a.id === me.activeAccountId)?.name ?? String(me.activeAccountId)
|
|
83
|
+
: (me.superAdmin ? 'all accounts' : '—');
|
|
84
|
+
ui_1.log.info(`${chalk_1.default.bold(me.user.username)} ${chalk_1.default.dim(me.user.role + (me.superAdmin ? ' · super-admin' : ''))}`);
|
|
85
|
+
ui_1.log.info(`account: ${active}`);
|
|
86
|
+
ui_1.log.info(`server : ${api.baseUrl}`);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=auth.js.map
|