@siteqwality/cli 0.1.1 → 0.1.2
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 +21 -1
- package/bin/siteqwality.js +58 -5
- package/package.json +2 -2
- package/src/api.js +18 -9
- package/src/incidents.js +67 -0
- package/src/index.d.ts +35 -2
- package/src/index.js +1 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @siteqwality/cli
|
|
2
2
|
|
|
3
|
-
Version 0.1.
|
|
3
|
+
Version 0.1.2, Node.js 22+. Install:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install --save-dev @siteqwality/cli
|
|
@@ -26,6 +26,26 @@ Store the key in your CI secret store. Mutations require `write:rum`; release li
|
|
|
26
26
|
|
|
27
27
|
Authenticated commands accept `--app <uuid>`, `--api-url <url>`, `--retries <count>` (default 2, range 0 to 5), and `--timeout <ms>` (default 30000, maximum 300000). Upload also accepts `--release <version>`, `--commit <sha>`, `--env <environment>`, `--concurrency <count>` (default 4, range 1 to 16), and `--delete-after`. Explicit flags override environment defaults. JSON results go to stdout, errors to stderr with exit code 1. Keys are intentionally not accepted as command-line flags.
|
|
28
28
|
|
|
29
|
+
## Incidents
|
|
30
|
+
|
|
31
|
+
Incident commands use an account API key with `read:incidents` for listing and `write:incidents` for changes. They do not need a RUM application or `--app`.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
export SITEQWALITY_API_KEY='your-incident-api-key'
|
|
35
|
+
npx siteqwality incidents list --status active --limit 50 --offset 0
|
|
36
|
+
npx siteqwality incidents ack "$INCIDENT_ID" --by "$MEMBER_ID"
|
|
37
|
+
npx siteqwality incidents resolve "$INCIDENT_ID" --message 'Service recovered'
|
|
38
|
+
npx siteqwality incidents update "$INCIDENT_ID" --title 'Checkout unavailable' --severity critical
|
|
39
|
+
npx siteqwality incidents update "$INCIDENT_ID" --service "$SERVICE_ID"
|
|
40
|
+
npx siteqwality incidents update "$INCIDENT_ID" --clear-service
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`list` defaults to active incidents and returns `{ data, total }` for one page. Status is `active`, `resolved` or `all`; limit is 1 to 100. Increase `--offset` to fetch later pages.
|
|
44
|
+
|
|
45
|
+
`ack` requires `--by` because an API key does not identify a member. The server checks that the member belongs to the account. Acknowledgement stops escalation. `resolve` always posts a public resolution for published incidents and emails their subscribers by default; `--no-notify-subscribers` suppresses the email. `update` changes only the supplied title, severity or service, without setting lifecycle status. Moving an incident to a service can start its escalation policy.
|
|
46
|
+
|
|
47
|
+
All four commands accept `--api-url`, `--timeout` and `--retries`. Only reads retry transient failures. Incident mutations make one request even if `--retries` is set, so an ambiguous failure cannot repeat a page or public update. Inspect the incident before retrying a failed mutation. HTTP 400, 403, 404 and 409 errors return a nonzero exit code without printing response bodies or the API key.
|
|
48
|
+
|
|
29
49
|
Upload creates the release idempotently before presigning. With no release it uses the reserved `__debug_id__` release. `releases deploy <version>` requires an existing release and records server time. It does not deploy application files or configure the browser SDK's release value.
|
|
30
50
|
|
|
31
51
|
## Injection
|
package/bin/siteqwality.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command, InvalidArgumentError } from 'commander';
|
|
3
|
-
import { ApiClient, injectDirectory, uploadDirectory } from '../src/index.js';
|
|
3
|
+
import { ApiClient, IncidentClient, injectDirectory, uploadDirectory } from '../src/index.js';
|
|
4
4
|
|
|
5
5
|
const program = new Command()
|
|
6
6
|
.name('siteqwality')
|
|
7
|
-
.version('0.1.
|
|
8
|
-
.description('Site Qwality
|
|
7
|
+
.version('0.1.2')
|
|
8
|
+
.description('Site Qwality source maps, releases and incidents');
|
|
9
9
|
const integer = (value) => {
|
|
10
10
|
if (!/^\d+$/.test(value)) throw new InvalidArgumentError('Expected an integer');
|
|
11
11
|
return Number(value);
|
|
12
12
|
};
|
|
13
|
-
function
|
|
13
|
+
function accountAuthenticated(command) {
|
|
14
14
|
return command
|
|
15
|
-
.option('--app <uuid>', 'Application UUID', process.env.SITEQWALITY_APP_ID)
|
|
16
15
|
.option('--api-url <url>', 'Site Qwality API base URL', process.env.SITEQWALITY_API_URL)
|
|
17
16
|
.option('--retries <count>', 'Transient retries per request, 0 to 5', integer, 2)
|
|
18
17
|
.option('--timeout <ms>', 'Request timeout, 1 to 300000 milliseconds', integer, 30000);
|
|
19
18
|
}
|
|
19
|
+
function authenticated(command) {
|
|
20
|
+
return accountAuthenticated(command).option(
|
|
21
|
+
'--app <uuid>',
|
|
22
|
+
'Application UUID',
|
|
23
|
+
process.env.SITEQWALITY_APP_ID,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
20
26
|
function options(command) {
|
|
21
27
|
return {
|
|
22
28
|
...command,
|
|
@@ -67,6 +73,53 @@ authenticated(
|
|
|
67
73
|
authenticated(releases.command('list').description('List releases, newest created first')).action(
|
|
68
74
|
async (opts) => print(await new ApiClient(options(opts)).listReleases()),
|
|
69
75
|
);
|
|
76
|
+
const incidents = program.command('incidents').description('List and manage account incidents');
|
|
77
|
+
accountAuthenticated(
|
|
78
|
+
incidents.command('list').description('List one page, with the total matching count'),
|
|
79
|
+
)
|
|
80
|
+
.option('--status <status>', 'active, resolved or all', 'active')
|
|
81
|
+
.option('--limit <count>', 'Page size, 1 to 100', integer, 50)
|
|
82
|
+
.option('--offset <count>', 'Row offset', integer, 0)
|
|
83
|
+
.action(async (opts) => print(await new IncidentClient(options(opts)).listIncidents(opts)));
|
|
84
|
+
accountAuthenticated(
|
|
85
|
+
incidents
|
|
86
|
+
.command('ack <incident>')
|
|
87
|
+
.description('Acknowledge as an account member and stop escalation'),
|
|
88
|
+
)
|
|
89
|
+
.requiredOption('--by <uuid>', 'Acknowledging member UUID, required for API keys')
|
|
90
|
+
.action(async (id, opts) =>
|
|
91
|
+
print(await new IncidentClient(options(opts)).acknowledgeIncident(id, opts.by)),
|
|
92
|
+
);
|
|
93
|
+
accountAuthenticated(
|
|
94
|
+
incidents
|
|
95
|
+
.command('resolve <incident>')
|
|
96
|
+
.description('Resolve; published incidents get a public resolution'),
|
|
97
|
+
)
|
|
98
|
+
.option('--message <text>', 'Public resolution text')
|
|
99
|
+
.option(
|
|
100
|
+
'--no-notify-subscribers',
|
|
101
|
+
'Suppress subscriber email; the public resolution is still posted',
|
|
102
|
+
)
|
|
103
|
+
.action(async (id, opts) =>
|
|
104
|
+
print(await new IncidentClient(options(opts)).resolveIncident(id, opts)),
|
|
105
|
+
);
|
|
106
|
+
accountAuthenticated(
|
|
107
|
+
incidents.command('update <incident>').description('Change title, severity or service'),
|
|
108
|
+
)
|
|
109
|
+
.option('--title <text>', 'Incident title')
|
|
110
|
+
.option('--severity <severity>', 'minor, major or critical')
|
|
111
|
+
.option('--service <uuid>', 'Move to a service, which may start paging')
|
|
112
|
+
.option('--clear-service', 'Detach the incident from its service')
|
|
113
|
+
.action(async (id, opts) => {
|
|
114
|
+
if (opts.service && opts.clearService) throw new Error('Choose --service or --clear-service');
|
|
115
|
+
print(
|
|
116
|
+
await new IncidentClient(options(opts)).updateIncident(id, {
|
|
117
|
+
title: opts.title,
|
|
118
|
+
severity: opts.severity,
|
|
119
|
+
serviceId: opts.clearService ? null : opts.service,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
70
123
|
try {
|
|
71
124
|
await program.parseAsync();
|
|
72
125
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siteqwality/cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Site Qwality
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Site Qwality source maps, releases and incident tooling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22"
|
package/src/api.js
CHANGED
|
@@ -31,11 +31,10 @@ export class HttpError extends Error {
|
|
|
31
31
|
this.retryAfter = retryAfter;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
export class
|
|
34
|
+
export class AuthenticatedClient {
|
|
35
35
|
constructor(options = {}) {
|
|
36
36
|
const base = safeUrl(options.apiBase || 'https://api.siteqwality.com');
|
|
37
37
|
if (base.search) throw new Error('API base must not contain query parameters');
|
|
38
|
-
if (!isUuid(options.app)) throw new Error('A valid application UUID is required');
|
|
39
38
|
if (
|
|
40
39
|
typeof options.apiKey !== 'string' ||
|
|
41
40
|
!options.apiKey.trim() ||
|
|
@@ -43,7 +42,6 @@ export class ApiClient {
|
|
|
43
42
|
)
|
|
44
43
|
throw new Error('An API key is required');
|
|
45
44
|
this.base = base.href.replace(/\/+$/, '');
|
|
46
|
-
this.app = options.app;
|
|
47
45
|
this.key = options.apiKey;
|
|
48
46
|
this.retries = options.retries ?? 2;
|
|
49
47
|
this.retryDelayMs = options.retryDelayMs ?? 500;
|
|
@@ -55,7 +53,7 @@ export class ApiClient {
|
|
|
55
53
|
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > 300_000)
|
|
56
54
|
throw new Error('Invalid request timeout');
|
|
57
55
|
}
|
|
58
|
-
async request(url, init, json = false) {
|
|
56
|
+
async request(url, init, json = false, retries = this.retries) {
|
|
59
57
|
for (let attempt = 0; ; attempt++) {
|
|
60
58
|
try {
|
|
61
59
|
const response = await fetch(url, {
|
|
@@ -75,13 +73,13 @@ export class ApiClient {
|
|
|
75
73
|
}
|
|
76
74
|
const body = await response.json();
|
|
77
75
|
if (!body || !Object.hasOwn(body, 'data')) throw new Error('Invalid API response envelope');
|
|
78
|
-
return body.data;
|
|
76
|
+
return json === 'envelope' ? body : body.data;
|
|
79
77
|
} catch (err) {
|
|
80
78
|
const transient =
|
|
81
79
|
err instanceof HttpError
|
|
82
80
|
? [408, 429].includes(err.status) || err.status >= 500
|
|
83
81
|
: err instanceof TypeError || err.name === 'TimeoutError';
|
|
84
|
-
if (!transient || attempt >=
|
|
82
|
+
if (!transient || attempt >= retries) {
|
|
85
83
|
if (err instanceof HttpError) throw err;
|
|
86
84
|
throw new Error('Request failed or returned an invalid response');
|
|
87
85
|
}
|
|
@@ -98,17 +96,28 @@ export class ApiClient {
|
|
|
98
96
|
}
|
|
99
97
|
}
|
|
100
98
|
}
|
|
101
|
-
|
|
99
|
+
accountApi(path, method = 'GET', body, envelope = false, retries = this.retries) {
|
|
102
100
|
return this.request(
|
|
103
|
-
`${this.base}
|
|
101
|
+
`${this.base}${path}`,
|
|
104
102
|
{
|
|
105
103
|
method,
|
|
106
104
|
headers: { Authorization: `Bearer ${this.key}`, 'Content-Type': 'application/json' },
|
|
107
105
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
108
106
|
},
|
|
109
|
-
true,
|
|
107
|
+
envelope ? 'envelope' : true,
|
|
108
|
+
retries,
|
|
110
109
|
);
|
|
111
110
|
}
|
|
111
|
+
}
|
|
112
|
+
export class ApiClient extends AuthenticatedClient {
|
|
113
|
+
constructor(options = {}) {
|
|
114
|
+
if (!isUuid(options.app)) throw new Error('A valid application UUID is required');
|
|
115
|
+
super(options);
|
|
116
|
+
this.app = options.app;
|
|
117
|
+
}
|
|
118
|
+
api(suffix, method = 'GET', body) {
|
|
119
|
+
return this.accountApi(`/rum/${this.app}${suffix}`, method, body);
|
|
120
|
+
}
|
|
112
121
|
async createRelease(version, { commit, env } = {}) {
|
|
113
122
|
releaseName(version);
|
|
114
123
|
for (const value of [commit, env])
|
package/src/incidents.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { AuthenticatedClient, isUuid } from './api.js';
|
|
2
|
+
|
|
3
|
+
const uuid = (id, name) => {
|
|
4
|
+
if (!isUuid(id)) throw new Error(`A valid ${name} UUID is required`);
|
|
5
|
+
return id;
|
|
6
|
+
};
|
|
7
|
+
const choice = (value, choices, name) => {
|
|
8
|
+
if (!choices.includes(value)) throw new Error(`${name} must be ${choices.join(', ')}`);
|
|
9
|
+
return value;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class IncidentClient extends AuthenticatedClient {
|
|
13
|
+
async listIncidents({ status = 'active', limit = 50, offset = 0 } = {}) {
|
|
14
|
+
choice(status, ['active', 'resolved', 'all'], 'status');
|
|
15
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
16
|
+
throw new Error('limit must be 1 to 100');
|
|
17
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
18
|
+
throw new Error('offset must be a non-negative integer');
|
|
19
|
+
const query = new URLSearchParams({ status, limit: String(limit), offset: String(offset) });
|
|
20
|
+
const result = await this.accountApi(`/incident?${query}`, 'GET', undefined, true);
|
|
21
|
+
if (!Array.isArray(result.data) || !Number.isSafeInteger(result.total) || result.total < 0)
|
|
22
|
+
throw new Error('Invalid incident list response');
|
|
23
|
+
return { data: result.data, total: result.total };
|
|
24
|
+
}
|
|
25
|
+
acknowledgeIncident(id, acknowledgedBy) {
|
|
26
|
+
uuid(id, 'incident');
|
|
27
|
+
uuid(acknowledgedBy, 'acknowledging member');
|
|
28
|
+
return this.accountApi(
|
|
29
|
+
`/incident/${id}/acknowledge`,
|
|
30
|
+
'POST',
|
|
31
|
+
{ acknowledged_by: acknowledgedBy },
|
|
32
|
+
false,
|
|
33
|
+
0,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
resolveIncident(id, { message, notifySubscribers = true } = {}) {
|
|
37
|
+
uuid(id, 'incident');
|
|
38
|
+
if (message !== undefined && (typeof message !== 'string' || !message.trim()))
|
|
39
|
+
throw new Error('Resolution message must not be blank');
|
|
40
|
+
if (typeof notifySubscribers !== 'boolean')
|
|
41
|
+
throw new Error('notifySubscribers must be boolean');
|
|
42
|
+
return this.accountApi(
|
|
43
|
+
`/incident/${id}/resolve`,
|
|
44
|
+
'POST',
|
|
45
|
+
{
|
|
46
|
+
...(message === undefined ? {} : { message }),
|
|
47
|
+
notify_subscribers: notifySubscribers,
|
|
48
|
+
},
|
|
49
|
+
false,
|
|
50
|
+
0,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
updateIncident(id, { title, severity, serviceId } = {}) {
|
|
54
|
+
uuid(id, 'incident');
|
|
55
|
+
const body = {};
|
|
56
|
+
if (title !== undefined) {
|
|
57
|
+
if (typeof title !== 'string' || !title.trim()) throw new Error('Title must not be blank');
|
|
58
|
+
body.title = title;
|
|
59
|
+
}
|
|
60
|
+
if (severity !== undefined)
|
|
61
|
+
body.severity = choice(severity, ['minor', 'major', 'critical'], 'severity');
|
|
62
|
+
if (serviceId !== undefined)
|
|
63
|
+
body.service_id = serviceId === null ? null : uuid(serviceId, 'service');
|
|
64
|
+
if (!Object.keys(body).length) throw new Error('Choose a title, severity or service change');
|
|
65
|
+
return this.accountApi(`/incident/${id}`, 'PUT', body, false, 0);
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -9,14 +9,16 @@ export interface SourceMap {
|
|
|
9
9
|
debugId?: string;
|
|
10
10
|
[key: string]: unknown;
|
|
11
11
|
}
|
|
12
|
-
export interface
|
|
13
|
-
app: string;
|
|
12
|
+
export interface AccountApiOptions {
|
|
14
13
|
apiKey: string;
|
|
15
14
|
apiBase?: string;
|
|
16
15
|
retries?: number;
|
|
17
16
|
retryDelayMs?: number;
|
|
18
17
|
timeoutMs?: number;
|
|
19
18
|
}
|
|
19
|
+
export interface ApiOptions extends AccountApiOptions {
|
|
20
|
+
app: string;
|
|
21
|
+
}
|
|
20
22
|
export interface ReleaseMetadata {
|
|
21
23
|
commit?: string;
|
|
22
24
|
env?: string;
|
|
@@ -93,4 +95,35 @@ export class ApiClient {
|
|
|
93
95
|
put(url: string, headers: Record<string, string>, body: Uint8Array): Promise<void>;
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
export interface Incident {
|
|
99
|
+
id: string;
|
|
100
|
+
title: string;
|
|
101
|
+
severity: 'minor' | 'major' | 'critical';
|
|
102
|
+
status: 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
|
103
|
+
responder_status: 'triggered' | 'acknowledged' | 'resolved';
|
|
104
|
+
service_id: string | null;
|
|
105
|
+
[key: string]: unknown;
|
|
106
|
+
}
|
|
107
|
+
export class IncidentClient {
|
|
108
|
+
constructor(options: AccountApiOptions);
|
|
109
|
+
listIncidents(options?: {
|
|
110
|
+
status?: 'active' | 'resolved' | 'all';
|
|
111
|
+
limit?: number;
|
|
112
|
+
offset?: number;
|
|
113
|
+
}): Promise<{ data: Incident[]; total: number }>;
|
|
114
|
+
acknowledgeIncident(id: string, acknowledgedBy: string): Promise<Incident>;
|
|
115
|
+
resolveIncident(
|
|
116
|
+
id: string,
|
|
117
|
+
options?: { message?: string; notifySubscribers?: boolean },
|
|
118
|
+
): Promise<Incident>;
|
|
119
|
+
updateIncident(
|
|
120
|
+
id: string,
|
|
121
|
+
changes: {
|
|
122
|
+
title?: string;
|
|
123
|
+
severity?: 'minor' | 'major' | 'critical';
|
|
124
|
+
serviceId?: string | null;
|
|
125
|
+
},
|
|
126
|
+
): Promise<Incident>;
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
export function sourceMapReference(code: string): string | undefined;
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { inject, validateMap } from './inject.js';
|
|
2
2
|
export { injectDirectory, collectPairs } from './files.js';
|
|
3
3
|
export { ApiClient } from './api.js';
|
|
4
|
+
export { IncidentClient } from './incidents.js';
|
|
4
5
|
export { uploadMaps, uploadDirectory } from './upload.js';
|
|
5
6
|
|
|
6
7
|
export { sourceMapReference } from './comments.js';
|