@cloudbeat/atlas-cli 0.2.0 → 0.4.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.
Files changed (41) hide show
  1. package/README.md +52 -19
  2. package/dist/api.js +70 -18
  3. package/dist/cli.js +34 -6
  4. package/dist/commands/analyze.js +118 -0
  5. package/dist/commands/capacity.js +99 -0
  6. package/dist/commands/docs.js +57 -0
  7. package/dist/commands/files.js +18 -4
  8. package/dist/commands/plan.js +100 -0
  9. package/dist/commands/record.js +11 -1
  10. package/dist/commands/results.js +13 -7
  11. package/dist/commands/schema.js +86 -0
  12. package/dist/commands/skills.js +111 -0
  13. package/dist/commands/tests.js +73 -0
  14. package/dist/docs/agent-setup.md +109 -0
  15. package/dist/docs/agent-tasks.md +169 -0
  16. package/dist/docs/ai-agents.md +90 -0
  17. package/dist/docs/auth.md +74 -0
  18. package/dist/docs/automation.md +150 -0
  19. package/dist/docs/ci.md +105 -0
  20. package/dist/docs/clusters.md +48 -0
  21. package/dist/docs/commands.md +155 -0
  22. package/dist/docs/files.md +86 -0
  23. package/dist/docs/generators.md +52 -0
  24. package/dist/docs/getting-started.md +85 -0
  25. package/dist/docs/index.json +110 -0
  26. package/dist/docs/projects.md +50 -0
  27. package/dist/docs/recorder.md +143 -0
  28. package/dist/docs/results.md +79 -0
  29. package/dist/docs/running.md +80 -0
  30. package/dist/docs/skills.md +88 -0
  31. package/dist/docs/test-plans.md +92 -0
  32. package/dist/docs/troubleshooting.md +79 -0
  33. package/dist/plan.js +250 -0
  34. package/dist/skills/AGENTS.md +70 -0
  35. package/dist/skills/atlas-analyze/SKILL.md +104 -0
  36. package/dist/skills/atlas-correlate/SKILL.md +89 -0
  37. package/dist/skills/atlas-load-test/SKILL.md +154 -0
  38. package/dist/skills/atlas-record/SKILL.md +167 -0
  39. package/dist/skills/atlas-run/SKILL.md +109 -0
  40. package/dist/skills/atlas-test-plan/SKILL.md +281 -0
  41. package/package.json +5 -3
package/README.md CHANGED
@@ -43,15 +43,15 @@ whenever they change — this is independent of running a test, and uploaded
43
43
  files stay in the project until you replace or delete them.
44
44
 
45
45
  ```bash
46
- atlas files upload -p SKT ./SKT-2026.jmx ./data/mosdot.csv ./data/rashut.csv
47
- atlas files list -p SKT
46
+ atlas files upload -p SHOP ./checkout.jmx ./data/users.csv ./data/products.csv
47
+ atlas files list -p SHOP
48
48
  ```
49
49
 
50
50
  Uploading a file with a name that already exists replaces it.
51
51
 
52
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
53
+ atlas files download -p SHOP checkout.jmx # fetch a copy back
54
+ atlas files rm -p SHOP old-data.csv # delete one
55
55
  ```
56
56
 
57
57
  ### How files are laid out — and how to reference them
@@ -64,16 +64,16 @@ generator.
64
64
  So in your test plan, reference data files by a plain relative path:
65
65
 
66
66
  ```
67
- ./mosdot.csv ✅ recommended
68
- mosdot.csv ✅ also works
67
+ ./users.csv ✅ recommended
68
+ users.csv ✅ also works
69
69
  ```
70
70
 
71
71
  Not by an absolute path, and not through a folder:
72
72
 
73
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
74
+ C:\Users\me\Data\users.csv ❌ a Windows path cannot be found on a Linux generator
75
+ /home/me/data/users.csv ❌ absolute paths won't exist there either
76
+ ./data/users.csv ❌ the folder structure is not preserved
77
77
  ```
78
78
 
79
79
  This catches people out most often with test plans authored in JMeter on
@@ -93,7 +93,7 @@ Two related things worth checking before your first run:
93
93
  ## Running a test
94
94
 
95
95
  ```bash
96
- atlas run -p SKT --jmx SKT-2026.jmx --servers 5 --wait
96
+ atlas run -p SHOP --jmx checkout.jmx --servers 5 --wait
97
97
  ```
98
98
 
99
99
  `--wait` follows the run to completion and prints the aggregation report.
@@ -101,9 +101,9 @@ Without it, the command returns as soon as the test has been submitted, and you
101
101
  can check on it later:
102
102
 
103
103
  ```bash
104
- atlas status SKT-20260818-194201
105
- atlas results SKT-20260818-194201
106
- atlas stop SKT-20260818-194201
104
+ atlas status SHOP-20260818-194201
105
+ atlas results SHOP-20260818-194201
106
+ atlas stop SHOP-20260818-194201
107
107
  ```
108
108
 
109
109
  `--jmx` is only needed when a project holds more than one test plan.
@@ -139,9 +139,9 @@ export ATLAS_URL=https://atlas.cloudbeat.io
139
139
  export ATLAS_TOKEN=atlas_pat_...
140
140
 
141
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
142
+ atlas files upload -p SHOP ./checkout.jmx ./data/users.csv
143
143
 
144
- atlas run -p SKT --jmx SKT-2026.jmx --servers 5 \
144
+ atlas run -p SHOP --jmx checkout.jmx --servers 5 \
145
145
  --gate "error_rate<1,p95<2000" \
146
146
  --junit results.xml
147
147
  ```
@@ -191,7 +191,7 @@ plan:
191
191
  atlas record https://example.com -o flow.jmx
192
192
  ```
193
193
 
194
- Add `--upload -p SKT` to put the finished plan straight into a project, ready
194
+ Add `--upload -p SHOP` to put the finished plan straight into a project, ready
195
195
  to run.
196
196
 
197
197
  | | |
@@ -221,11 +221,38 @@ it if it isn't present.
221
221
  | `atlas status <testId>` | check whether a test is still running |
222
222
  | `atlas results <testId>` | show the aggregation report for a test |
223
223
  | `atlas stop <testId>` | stop a running test |
224
+ | `atlas tests` | list a project's recent runs |
225
+ | `atlas timeseries <testId>` | active users, response time and error rate over time |
226
+ | `atlas errors <testId>` | the distinct errors a test produced |
227
+ | `atlas capacity` | what you can run on: machines, clusters and usage limits |
228
+ | `atlas plan check [file]` | check a test plan will run and report before you use it |
224
229
  | `atlas record [url]` | record a browser session |
230
+ | `atlas schema` | describe every command and option (for scripts and AI agents) |
231
+ | `atlas skills install` | set up an AI assistant to drive Atlas properly |
232
+ | `atlas docs [topic]` | read the documentation without leaving the terminal |
225
233
  | `atlas config get` / `set` | default instance URL and project |
226
234
 
227
- Most commands take `-p, --project <name>` and `--json`. Run
228
- `atlas <command> --help` for the full list of options.
235
+ Every command takes `--json`, and `-p, --project <name>` where a project
236
+ applies. Run `atlas <command> --help` for the full list of options.
237
+
238
+ When a command fails with `--json`, the error is printed as JSON on standard
239
+ error, so a script can read clean output on standard out and still see why a
240
+ run stopped.
241
+
242
+ ## Working with an AI assistant
243
+
244
+ ```bash
245
+ atlas skills install
246
+ ```
247
+
248
+ Writes task-specific guidance into the current project — `.claude/skills/` for
249
+ Claude Code, and `AGENTS.md` for Copilot, Cursor and anything else that reads it
250
+ — covering the end-to-end flow, recording, dynamic values, sizing a run and
251
+ reading results. `--user` installs for every project, `--force` replaces files
252
+ you have edited. Start a new assistant session afterwards.
253
+
254
+ `atlas schema --json` describes the whole command surface, and `atlas docs`
255
+ prints the documentation without a network connection.
229
256
 
230
257
  ## Settings
231
258
 
@@ -242,6 +269,12 @@ configuration file.
242
269
  To avoid repeating yourself:
243
270
 
244
271
  ```bash
245
- atlas config set project SKT
272
+ atlas config set project SHOP
246
273
  atlas config set url https://atlas.cloudbeat.io
247
274
  ```
275
+
276
+ ## Compatibility
277
+
278
+ Works with any Atlas instance. From 0.3.0 the CLI talks to a new API path, and
279
+ falls back automatically when it finds an older instance that does not serve it —
280
+ so you do not need to match versions between the CLI and your Atlas server.
package/dist/api.js CHANGED
@@ -8,6 +8,9 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const config_1 = require("./config");
10
10
  const ui_1 = require("./ui");
11
+ /** Where the control-plane API lives today, and where it used to live. */
12
+ const API_PREFIX = '/app/api';
13
+ const LEGACY_API_PREFIX = '/admin/api';
11
14
  // ── Client ───────────────────────────────────────────────────────────────────
12
15
  class AtlasApi {
13
16
  url;
@@ -22,6 +25,33 @@ class AtlasApi {
22
25
  get baseUrl() {
23
26
  return this.url;
24
27
  }
28
+ /**
29
+ * The control-plane API prefix.
30
+ *
31
+ * It moved from /admin/api to /app/api when the browser UI moved to /app.
32
+ * Servers keep serving the old prefix so CLIs released before that keep
33
+ * working; this handles the other direction — a current CLI pointed at an
34
+ * Atlas that predates the move. The first 404 under the new prefix switches
35
+ * to the old one for the life of the process, so there is no extra round
36
+ * trip against an up-to-date server.
37
+ */
38
+ prefix = API_PREFIX;
39
+ prefixResolved = false;
40
+ get api() {
41
+ return this.prefix;
42
+ }
43
+ /** Fetch that retries once under the legacy prefix if the route is absent. */
44
+ async fetchApi(routePath, init) {
45
+ const res = await fetch(`${this.url}${routePath}`, init);
46
+ if (res.status === 404 && !this.prefixResolved && routePath.startsWith(API_PREFIX)) {
47
+ this.prefix = LEGACY_API_PREFIX;
48
+ this.prefixResolved = true;
49
+ const legacy = routePath.replace(API_PREFIX, LEGACY_API_PREFIX);
50
+ return { res: await fetch(`${this.url}${legacy}`, init), routePath: legacy };
51
+ }
52
+ this.prefixResolved = true;
53
+ return { res, routePath };
54
+ }
25
55
  authHeaders() {
26
56
  if (!this.token) {
27
57
  throw new ui_1.CliError('Not authenticated. Run `atlas login`, or set ATLAS_TOKEN to an API token.', ui_1.EXIT.USAGE);
@@ -34,9 +64,9 @@ class AtlasApi {
34
64
  const headers = auth ? this.authHeaders() : {};
35
65
  if (opts.body !== undefined)
36
66
  headers['Content-Type'] = 'application/json';
37
- let res;
67
+ let attempt;
38
68
  try {
39
- res = await fetch(`${this.url}${routePath}`, {
69
+ attempt = await this.fetchApi(routePath, {
40
70
  method,
41
71
  headers,
42
72
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
@@ -45,7 +75,7 @@ class AtlasApi {
45
75
  catch (err) {
46
76
  throw new ui_1.CliError(`Cannot reach ${this.url}: ${err.message}`, ui_1.EXIT.ERROR);
47
77
  }
48
- return this.parse(method, routePath, res);
78
+ return this.parse(method, attempt.routePath, attempt.res);
49
79
  }
50
80
  async parse(method, routePath, res) {
51
81
  const text = await res.text();
@@ -67,9 +97,11 @@ class AtlasApi {
67
97
  }
68
98
  // ── auth ──────────────────────────────────────────────────────────────────
69
99
  async login(username, password) {
70
- let res;
100
+ // Usually the very first call a CLI makes, so this is where the prefix
101
+ // normally gets resolved against an older server.
102
+ let attempt;
71
103
  try {
72
- res = await fetch(`${this.url}/admin/api/auth/login`, {
104
+ attempt = await this.fetchApi(`${this.api}/auth/login`, {
73
105
  method: 'POST',
74
106
  headers: { 'Content-Type': 'application/json' },
75
107
  body: JSON.stringify({ username, password }),
@@ -78,14 +110,14 @@ class AtlasApi {
78
110
  catch (err) {
79
111
  throw new ui_1.CliError(`Cannot reach ${this.url}: ${err.message}`, ui_1.EXIT.ERROR);
80
112
  }
81
- return this.parse('POST', '/admin/api/auth/login', res);
113
+ return this.parse('POST', attempt.routePath, attempt.res);
82
114
  }
83
115
  async me() {
84
- return this.request('GET', '/admin/api/auth/me');
116
+ return this.request('GET', `${this.api}/auth/me`);
85
117
  }
86
118
  // ── projects & files ──────────────────────────────────────────────────────
87
119
  async projects() {
88
- return this.request('GET', '/admin/api/projects');
120
+ return this.request('GET', `${this.api}/projects`);
89
121
  }
90
122
  /** Projects are addressed by name on the CLI; the API wants the id. */
91
123
  async projectByName(name) {
@@ -98,7 +130,7 @@ class AtlasApi {
98
130
  return hit;
99
131
  }
100
132
  async files(projectId) {
101
- return this.request('GET', `/admin/api/projects/${projectId}/files`);
133
+ return this.request('GET', `${this.api}/projects/${projectId}/files`);
102
134
  }
103
135
  async uploadFiles(projectId, paths) {
104
136
  const form = new FormData();
@@ -110,7 +142,7 @@ class AtlasApi {
110
142
  }
111
143
  let res;
112
144
  try {
113
- res = await fetch(`${this.url}/admin/api/projects/${projectId}/files`, {
145
+ res = await fetch(`${this.url}${this.api}/projects/${projectId}/files`, {
114
146
  method: 'POST',
115
147
  headers: this.authHeaders(), // no Content-Type: fetch sets the multipart boundary
116
148
  body: form,
@@ -119,33 +151,39 @@ class AtlasApi {
119
151
  catch (err) {
120
152
  throw new ui_1.CliError(`Upload failed: ${err.message}`, ui_1.EXIT.ERROR);
121
153
  }
122
- return this.parse('POST', `/admin/api/projects/${projectId}/files`, res);
154
+ return this.parse('POST', `${this.api}/projects/${projectId}/files`, res);
123
155
  }
124
156
  async downloadFile(projectId, filename) {
125
- const res = await fetch(`${this.url}/admin/api/projects/${projectId}/files/${encodeURIComponent(filename)}/download`, { headers: this.authHeaders() });
157
+ const res = await fetch(`${this.url}${this.api}/projects/${projectId}/files/${encodeURIComponent(filename)}/download`, { headers: this.authHeaders() });
126
158
  if (!res.ok)
127
159
  throw new ui_1.CliError(`Download failed: ${res.status} ${res.statusText}`, ui_1.EXIT.ERROR);
128
160
  return Buffer.from(await res.arrayBuffer());
129
161
  }
130
162
  async deleteFile(projectId, filename) {
131
- await this.request('DELETE', `/admin/api/projects/${projectId}/files/${encodeURIComponent(filename)}`);
163
+ await this.request('DELETE', `${this.api}/projects/${projectId}/files/${encodeURIComponent(filename)}`);
132
164
  }
133
165
  // ── fleet ─────────────────────────────────────────────────────────────────
134
166
  async servers() {
135
- return this.request('GET', '/admin/api/servers');
167
+ return this.request('GET', `${this.api}/servers`);
136
168
  }
137
169
  async clusters() {
138
- return this.request('GET', '/admin/api/clusters');
170
+ return this.request('GET', `${this.api}/clusters`);
171
+ }
172
+ async quota() {
173
+ return this.request('GET', `${this.api}/auth/quota`);
174
+ }
175
+ async fleet() {
176
+ return this.request('GET', `${this.api}/auth/fleet`);
139
177
  }
140
178
  // ── runs ──────────────────────────────────────────────────────────────────
141
179
  async run(projectId, body) {
142
- return this.request('POST', `/admin/api/projects/${projectId}/run`, { body });
180
+ return this.request('POST', `${this.api}/projects/${projectId}/run`, { body });
143
181
  }
144
182
  async queueEntry(queueId) {
145
- return this.request('GET', `/admin/api/tests/queue/${queueId}`);
183
+ return this.request('GET', `${this.api}/tests/queue/${queueId}`);
146
184
  }
147
185
  async stop(testId) {
148
- return this.request('POST', `/admin/api/tests/${encodeURIComponent(testId)}/stop`, { body: {} });
186
+ return this.request('POST', `${this.api}/tests/${encodeURIComponent(testId)}/stop`, { body: {} });
149
187
  }
150
188
  // ── results (public dashboard API) ─────────────────────────────────────────
151
189
  async summary(testId) {
@@ -154,6 +192,20 @@ class AtlasApi {
154
192
  async testStatus(testId) {
155
193
  return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/status`, { auth: false });
156
194
  }
195
+ /** Runs for a project, most recent first (the dashboard's test list). */
196
+ async tests(project) {
197
+ return this.request('GET', `/api/projects/${encodeURIComponent(project)}/tests`, { auth: false });
198
+ }
199
+ /** 30s-bucketed points: active users, average response time, error rate. */
200
+ async timeseries(testId, label) {
201
+ const q = label ? `?label=${encodeURIComponent(label)}` : '';
202
+ return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/timeseries${q}`, { auth: false });
203
+ }
204
+ /** One document per distinct error type, with the full request/response. */
205
+ async errors(testId, label) {
206
+ const q = label ? `?label=${encodeURIComponent(label)}` : '';
207
+ return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/errors${q}`, { auth: false });
208
+ }
157
209
  }
158
210
  exports.AtlasApi = AtlasApi;
159
211
  //# sourceMappingURL=api.js.map
package/dist/cli.js CHANGED
@@ -14,6 +14,13 @@ const files_1 = require("./commands/files");
14
14
  const run_1 = require("./commands/run");
15
15
  const results_1 = require("./commands/results");
16
16
  const record_1 = require("./commands/record");
17
+ const tests_1 = require("./commands/tests");
18
+ const analyze_1 = require("./commands/analyze");
19
+ const capacity_1 = require("./commands/capacity");
20
+ const schema_1 = require("./commands/schema");
21
+ const docs_1 = require("./commands/docs");
22
+ const skills_1 = require("./commands/skills");
23
+ const plan_1 = require("./commands/plan");
17
24
  // eslint-disable-next-line @typescript-eslint/no-var-requires
18
25
  const { version } = require('../package.json');
19
26
  const program = new commander_1.Command();
@@ -37,13 +44,26 @@ Examples:
37
44
  atlas run -p SKT --jmx SKT-2026.jmx --servers 5 --wait \\
38
45
  --gate "error_rate<1,p95<2000" --junit results.xml
39
46
  atlas record https://example.com -o flow.jmx --upload -p SKT
47
+
48
+ For scripts and AI agents:
49
+ atlas schema --json every command, argument and option
50
+ atlas docs the documentation, offline
51
+ atlas skills install teach an AI assistant to drive Atlas properly
52
+ --json on every command; failures print JSON on stderr
40
53
  `);
41
54
  (0, auth_1.registerAuth)(program);
42
55
  (0, projects_1.registerProjects)(program);
43
56
  (0, files_1.registerFiles)(program);
44
57
  (0, run_1.registerRun)(program);
45
58
  (0, results_1.registerResults)(program);
59
+ (0, tests_1.registerTests)(program);
60
+ (0, analyze_1.registerAnalyze)(program);
61
+ (0, capacity_1.registerCapacity)(program);
62
+ (0, plan_1.registerPlan)(program);
46
63
  (0, record_1.registerRecord)(program);
64
+ (0, schema_1.registerSchema)(program);
65
+ (0, docs_1.registerDocs)(program);
66
+ (0, skills_1.registerSkills)(program);
47
67
  // Small config helper so CI/dev can pin a default project or URL without env.
48
68
  const config = program.command('config').description('Read or write CLI settings');
49
69
  config
@@ -69,15 +89,23 @@ config
69
89
  async function main() {
70
90
  await program.parseAsync(process.argv);
71
91
  }
92
+ /** A caller that asked for JSON gets JSON on the way out too — on stderr, so a
93
+ * pipeline parsing stdout never has to tell output from failure. */
94
+ function failed(message, code) {
95
+ if (process.argv.includes('--json')) {
96
+ console.error(JSON.stringify({ error: { message, code } }, null, 2));
97
+ }
98
+ else {
99
+ ui_1.log.error(message);
100
+ }
101
+ process.exit(code);
102
+ }
72
103
  main().catch((err) => {
73
104
  // 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));
105
+ if (err instanceof ui_1.CliError)
106
+ failed(err.message, err.code);
79
107
  if (process.env.ATLAS_DEBUG)
80
108
  console.error(err);
81
- process.exit(ui_1.EXIT.ERROR);
109
+ failed(err.message ?? String(err), ui_1.EXIT.ERROR);
82
110
  });
83
111
  //# sourceMappingURL=cli.js.map
@@ -0,0 +1,118 @@
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.registerAnalyze = registerAnalyze;
7
+ const cli_table3_1 = __importDefault(require("cli-table3"));
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const api_1 = require("../api");
10
+ const ui_1 = require("../ui");
11
+ /** Rows a human can scan; --json always carries every point. */
12
+ const MAX_HUMAN_ROWS = 24;
13
+ /** Keep the first, the last and an even spread between — a run is 175 buckets
14
+ * at 30s, and the shape is what a person is reading the table for. */
15
+ function sample(items, max) {
16
+ if (items.length <= max)
17
+ return items;
18
+ const step = (items.length - 1) / (max - 1);
19
+ return Array.from({ length: max }, (_, i) => items[Math.round(i * step)]);
20
+ }
21
+ function clock(iso) {
22
+ return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
23
+ }
24
+ function printPoints(points) {
25
+ const table = new cli_table3_1.default({
26
+ head: ['TIME', 'USERS', 'AVG MS', 'ERR%'].map(h => chalk_1.default.dim(h)),
27
+ colAligns: ['left', 'right', 'right', 'right'],
28
+ style: { head: [], border: [] },
29
+ });
30
+ for (const p of sample(points, MAX_HUMAN_ROWS)) {
31
+ table.push([
32
+ clock(p.timestamp),
33
+ String(p.activeUsers),
34
+ String(p.avgMs),
35
+ p.errorRate === 0 ? chalk_1.default.green('0%') : chalk_1.default.red(`${p.errorRate}%`),
36
+ ]);
37
+ }
38
+ console.log(table.toString());
39
+ }
40
+ function truncate(s, n) {
41
+ const one = (s ?? '').replace(/\s+/g, ' ').trim();
42
+ return one.length > n ? one.slice(0, n - 1) + '…' : one;
43
+ }
44
+ function printErrors(errors) {
45
+ const table = new cli_table3_1.default({
46
+ head: ['CODE', 'COUNT', 'SHARE', 'LABEL', 'MESSAGE'].map(h => chalk_1.default.dim(h)),
47
+ colAligns: ['left', 'right', 'right', 'left', 'left'],
48
+ style: { head: [], border: [] },
49
+ wordWrap: false,
50
+ });
51
+ for (const e of errors) {
52
+ table.push([
53
+ truncate(e.responseCode, 24) || chalk_1.default.dim('—'),
54
+ String(e.count),
55
+ `${e.errorPct}%`,
56
+ truncate(e.label, 32),
57
+ truncate(e.failureMessage, 60),
58
+ ]);
59
+ }
60
+ console.log(table.toString());
61
+ }
62
+ function registerAnalyze(program) {
63
+ program
64
+ .command('timeseries <testId>')
65
+ .description('Active users, response time and error rate in 30s buckets')
66
+ .option('--label <label>', 'one sampler/transaction instead of the whole test')
67
+ .option('--json', 'machine-readable output — every point, not a sample')
68
+ .action(async (testId, opts) => {
69
+ const api = api_1.AtlasApi.fromConfig();
70
+ const { points } = await api.timeseries(testId, opts.label);
71
+ if (opts.json) {
72
+ (0, ui_1.json)({ testId, label: opts.label ?? '_all', points });
73
+ return;
74
+ }
75
+ if (points.length === 0) {
76
+ throw new ui_1.CliError(`No metrics found for "${testId}".`, ui_1.EXIT.ERROR);
77
+ }
78
+ const peakUsers = Math.max(...points.map(p => p.activeUsers));
79
+ const peakAvg = Math.max(...points.map(p => p.avgMs));
80
+ printPoints(points);
81
+ if (points.length > MAX_HUMAN_ROWS) {
82
+ ui_1.log.dim(`${points.length} points, ${MAX_HUMAN_ROWS} shown — use --json for all of them.`);
83
+ }
84
+ ui_1.log.info(`peak users ${chalk_1.default.bold(String(peakUsers))} · peak avg ${chalk_1.default.bold(`${peakAvg}ms`)}`);
85
+ });
86
+ program
87
+ .command('errors <testId>')
88
+ .description('Distinct error types for a run, with request and response detail')
89
+ .option('--label <label>', 'errors for one sampler/transaction only')
90
+ .option('--full', 'print the full request/response of each error')
91
+ .option('--json', 'machine-readable output')
92
+ .action(async (testId, opts) => {
93
+ const api = api_1.AtlasApi.fromConfig();
94
+ const { errors, totalErrors } = await api.errors(testId, opts.label);
95
+ if (opts.json) {
96
+ (0, ui_1.json)({ testId, label: opts.label ?? '_all', totalErrors, errors });
97
+ return;
98
+ }
99
+ if (errors.length === 0) {
100
+ ui_1.log.ok(`No errors recorded for ${testId}${opts.label ? ` on "${opts.label}"` : ''}.`);
101
+ return;
102
+ }
103
+ printErrors(errors);
104
+ ui_1.log.info(`${errors.length} distinct error type(s), ${totalErrors} occurrence(s).`);
105
+ if (opts.full) {
106
+ for (const e of errors) {
107
+ console.log();
108
+ ui_1.log.info(chalk_1.default.bold(`${e.responseCode} ${e.label}`));
109
+ ui_1.log.dim(e.requestUrl);
110
+ if (e.requestBody)
111
+ ui_1.log.dim(`request: ${truncate(e.requestBody, 500)}`);
112
+ if (e.responseBody)
113
+ ui_1.log.dim(`response: ${truncate(e.responseBody, 500)}`);
114
+ }
115
+ }
116
+ });
117
+ }
118
+ //# sourceMappingURL=analyze.js.map
@@ -0,0 +1,99 @@
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.registerCapacity = registerCapacity;
7
+ const cli_table3_1 = __importDefault(require("cli-table3"));
8
+ const chalk_1 = __importDefault(require("chalk"));
9
+ const api_1 = require("../api");
10
+ const ui_1 = require("../ui");
11
+ /** An agent sizing a run needs to know what it is allowed to ask for before it
12
+ * asks the user. This is fleet + clusters + caps + quota in one call. */
13
+ function printServers(servers) {
14
+ const table = new cli_table3_1.default({
15
+ head: ['SERVER', 'STATUS', 'PROVIDER', 'BUSY'].map(h => chalk_1.default.dim(h)),
16
+ style: { head: [], border: [] },
17
+ });
18
+ for (const s of servers) {
19
+ table.push([
20
+ s.name,
21
+ s.status === 'on' ? chalk_1.default.green('on') : s.status === 'off' ? chalk_1.default.dim('off') : chalk_1.default.yellow('unknown'),
22
+ s.provider,
23
+ s.jmeter_running ? chalk_1.default.yellow('running a test') : chalk_1.default.dim('free'),
24
+ ]);
25
+ }
26
+ console.log(table.toString());
27
+ }
28
+ function printClusters(clusters) {
29
+ const table = new cli_table3_1.default({
30
+ head: ['CLUSTER', 'TYPE', 'CONTROLLERS'].map(h => chalk_1.default.dim(h)),
31
+ style: { head: [], border: [] },
32
+ });
33
+ for (const c of clusters) {
34
+ const connected = `${c.connectedControllers}/${c.controllers}`;
35
+ table.push([
36
+ c.name,
37
+ c.provider_type,
38
+ c.connectedControllers > 0 ? chalk_1.default.green(connected) : chalk_1.default.red(connected),
39
+ ]);
40
+ }
41
+ console.log(table.toString());
42
+ }
43
+ function registerCapacity(program) {
44
+ program
45
+ .command('capacity')
46
+ .description('What this credential can run on: servers, clusters, fleet caps and quota')
47
+ .option('--json', 'machine-readable output')
48
+ .action(async (opts) => {
49
+ const api = api_1.AtlasApi.fromConfig();
50
+ const [servers, clusters] = await Promise.all([api.servers(), api.clusters()]);
51
+ // Caps and quota are newer than the fleet endpoints; an Atlas that predates
52
+ // them should still answer the main question rather than failing outright.
53
+ const optional = async (call) => call.catch(() => null);
54
+ const [fleet, quota] = await Promise.all([
55
+ optional(api.fleet()),
56
+ optional(api.quota()),
57
+ ]);
58
+ const free = servers.filter(s => !s.jmeter_running);
59
+ if (opts.json) {
60
+ (0, ui_1.json)({
61
+ servers,
62
+ clusters,
63
+ fleet,
64
+ quota,
65
+ available: { servers: free.length, of: servers.length },
66
+ });
67
+ return;
68
+ }
69
+ if (servers.length) {
70
+ printServers(servers);
71
+ ui_1.log.info(`${free.length} of ${servers.length} server(s) free.`);
72
+ }
73
+ else {
74
+ ui_1.log.warn('No fixed servers available to this credential.');
75
+ }
76
+ if (clusters.length) {
77
+ console.log();
78
+ printClusters(clusters);
79
+ ui_1.log.dim('Clusters allocate containers per run — size them with `atlas run --cluster <name> --units <n>`.');
80
+ }
81
+ if (fleet) {
82
+ console.log();
83
+ const cap = (used, max) => (max == null ? `${used} (no cap)` : `${used} of ${max}`);
84
+ ui_1.log.info(`nodes ${cap(fleet.nodes, fleet.maxNodes)} · concurrent agents ${cap(fleet.agents, fleet.maxAgents)}`);
85
+ }
86
+ if (quota?.limited) {
87
+ const used = quota.usedHours ?? 0;
88
+ const limit = quota.limitHours ?? 0;
89
+ const line = `usage ${used}h of ${limit}h${quota.pct != null ? ` (${quota.pct}%)` : ''}`;
90
+ if (quota.blocked)
91
+ ui_1.log.error(`${line} — runs are blocked until the limit is raised or the period resets.`);
92
+ else if ((quota.pct ?? 0) >= 80)
93
+ ui_1.log.warn(line);
94
+ else
95
+ ui_1.log.info(line);
96
+ }
97
+ });
98
+ }
99
+ //# sourceMappingURL=capacity.js.map
@@ -0,0 +1,57 @@
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.registerDocs = registerDocs;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const chalk_1 = __importDefault(require("chalk"));
10
+ const ui_1 = require("../ui");
11
+ /** Written by scripts/bundle-docs.js at build time. */
12
+ const DOCS_DIR = path_1.default.join(__dirname, '..', 'docs');
13
+ function loadIndex() {
14
+ const file = path_1.default.join(DOCS_DIR, 'index.json');
15
+ if (!fs_1.default.existsSync(file))
16
+ return [];
17
+ try {
18
+ return JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ function registerDocs(program) {
25
+ program
26
+ .command('docs [topic]')
27
+ .description('Print the documentation for a topic (offline); no topic lists them')
28
+ .option('--json', 'machine-readable output')
29
+ .action((topic, opts) => {
30
+ const index = loadIndex();
31
+ if (index.length === 0) {
32
+ throw new ui_1.CliError('No documentation is bundled with this build. Read it at https://atlas.cloudbeat.io/docs', ui_1.EXIT.ERROR);
33
+ }
34
+ if (!topic) {
35
+ if (opts.json) {
36
+ (0, ui_1.json)({ topics: index });
37
+ return;
38
+ }
39
+ ui_1.log.info(chalk_1.default.bold('Topics'));
40
+ for (const t of index) {
41
+ console.log(` ${chalk_1.default.cyan(t.topic.padEnd(16))}${t.summary || t.title}`);
42
+ }
43
+ ui_1.log.dim('atlas docs <topic> · https://atlas.cloudbeat.io/docs');
44
+ return;
45
+ }
46
+ const hit = index.find(t => t.topic === topic.toLowerCase());
47
+ if (!hit) {
48
+ throw new ui_1.CliError(`No topic "${topic}". Known: ${index.map(t => t.topic).join(', ')}`, ui_1.EXIT.USAGE);
49
+ }
50
+ const body = fs_1.default.readFileSync(path_1.default.join(DOCS_DIR, `${hit.topic}.md`), 'utf8');
51
+ if (opts.json)
52
+ (0, ui_1.json)({ topic: hit.topic, title: hit.title, markdown: body });
53
+ else
54
+ console.log(body);
55
+ });
56
+ }
57
+ //# sourceMappingURL=docs.js.map