@siteqwality/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @siteqwality/cli
2
2
 
3
- Version 0.1.1, Node.js 22+. Install:
3
+ Version 0.1.3, Node.js 22+. Install:
4
4
 
5
5
  ```sh
6
6
  npm install --save-dev @siteqwality/cli
@@ -26,6 +26,40 @@ 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. Acknowledgment 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
+
49
+ ## On-call
50
+
51
+ On-call commands read schedules with an account API key that has `read:incidents`. They never change a schedule.
52
+
53
+ ```sh
54
+ export SITEQWALITY_API_KEY='your-incident-api-key'
55
+ npx siteqwality oncall now
56
+ npx siteqwality oncall now --schedule "$SCHEDULE_ID" --at 2026-10-21T09:00:00Z
57
+ npx siteqwality oncall shifts "$SCHEDULE_ID" --since 2026-10-21T00:00:00Z --until 2026-11-04T00:00:00Z
58
+ npx siteqwality oncall mine --user "$MEMBER_ID"
59
+ ```
60
+
61
+ `now` lists who is on call on every schedule, or, with `--schedule`, one schedule's current shift with who is next. `shifts` returns a schedule's timeline with overrides applied and gaps included, 14 days by default and at most 90. `mine` lists one member's shifts across every schedule for the next 14 days; `--user` is required because an API key does not identify a member. Times are RFC 3339.
62
+
29
63
  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
64
 
31
65
  ## Injection
@@ -1,22 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command, InvalidArgumentError } from 'commander';
3
- import { ApiClient, injectDirectory, uploadDirectory } from '../src/index.js';
3
+ import {
4
+ ApiClient,
5
+ IncidentClient,
6
+ OnCallClient,
7
+ injectDirectory,
8
+ uploadDirectory,
9
+ } from '../src/index.js';
4
10
 
5
11
  const program = new Command()
6
12
  .name('siteqwality')
7
- .version('0.1.1')
8
- .description('Site Qwality Debug ID source maps and releases');
13
+ .version('0.1.3')
14
+ .description('Site Qwality source maps, releases, incidents and on-call');
9
15
  const integer = (value) => {
10
16
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError('Expected an integer');
11
17
  return Number(value);
12
18
  };
13
- function authenticated(command) {
19
+ function accountAuthenticated(command) {
14
20
  return command
15
- .option('--app <uuid>', 'Application UUID', process.env.SITEQWALITY_APP_ID)
16
21
  .option('--api-url <url>', 'Site Qwality API base URL', process.env.SITEQWALITY_API_URL)
17
22
  .option('--retries <count>', 'Transient retries per request, 0 to 5', integer, 2)
18
23
  .option('--timeout <ms>', 'Request timeout, 1 to 300000 milliseconds', integer, 30000);
19
24
  }
25
+ function authenticated(command) {
26
+ return accountAuthenticated(command).option(
27
+ '--app <uuid>',
28
+ 'Application UUID',
29
+ process.env.SITEQWALITY_APP_ID,
30
+ );
31
+ }
20
32
  function options(command) {
21
33
  return {
22
34
  ...command,
@@ -67,6 +79,81 @@ authenticated(
67
79
  authenticated(releases.command('list').description('List releases, newest created first')).action(
68
80
  async (opts) => print(await new ApiClient(options(opts)).listReleases()),
69
81
  );
82
+ const incidents = program.command('incidents').description('List and manage account incidents');
83
+ accountAuthenticated(
84
+ incidents.command('list').description('List one page, with the total matching count'),
85
+ )
86
+ .option('--status <status>', 'active, resolved or all', 'active')
87
+ .option('--limit <count>', 'Page size, 1 to 100', integer, 50)
88
+ .option('--offset <count>', 'Row offset', integer, 0)
89
+ .action(async (opts) => print(await new IncidentClient(options(opts)).listIncidents(opts)));
90
+ accountAuthenticated(
91
+ incidents
92
+ .command('ack <incident>')
93
+ .description('Acknowledge as an account member and stop escalation'),
94
+ )
95
+ .requiredOption('--by <uuid>', 'Acknowledging member UUID, required for API keys')
96
+ .action(async (id, opts) =>
97
+ print(await new IncidentClient(options(opts)).acknowledgeIncident(id, opts.by)),
98
+ );
99
+ accountAuthenticated(
100
+ incidents
101
+ .command('resolve <incident>')
102
+ .description('Resolve; published incidents get a public resolution'),
103
+ )
104
+ .option('--message <text>', 'Public resolution text')
105
+ .option(
106
+ '--no-notify-subscribers',
107
+ 'Suppress subscriber email; the public resolution is still posted',
108
+ )
109
+ .action(async (id, opts) =>
110
+ print(await new IncidentClient(options(opts)).resolveIncident(id, opts)),
111
+ );
112
+ accountAuthenticated(
113
+ incidents.command('update <incident>').description('Change title, severity or service'),
114
+ )
115
+ .option('--title <text>', 'Incident title')
116
+ .option('--severity <severity>', 'minor, major or critical')
117
+ .option('--service <uuid>', 'Move to a service, which may start paging')
118
+ .option('--clear-service', 'Detach the incident from its service')
119
+ .action(async (id, opts) => {
120
+ if (opts.service && opts.clearService) throw new Error('Choose --service or --clear-service');
121
+ print(
122
+ await new IncidentClient(options(opts)).updateIncident(id, {
123
+ title: opts.title,
124
+ severity: opts.severity,
125
+ serviceId: opts.clearService ? null : opts.service,
126
+ }),
127
+ );
128
+ });
129
+ const oncall = program.command('oncall').description('See who is on call and when');
130
+ accountAuthenticated(
131
+ oncall
132
+ .command('now')
133
+ .description('Who is on call now: every schedule, or one with its next shift'),
134
+ )
135
+ .option('--schedule <uuid>', 'Only this schedule, with who is next')
136
+ .option('--at <time>', 'RFC 3339 instant instead of now')
137
+ .action(async (opts) =>
138
+ print(
139
+ await new OnCallClient(options(opts)).onCallNow({ scheduleId: opts.schedule, at: opts.at }),
140
+ ),
141
+ );
142
+ accountAuthenticated(
143
+ oncall.command('shifts <schedule>').description("A schedule's shifts, gaps included"),
144
+ )
145
+ .option('--since <time>', 'RFC 3339 start; defaults to now')
146
+ .option('--until <time>', 'RFC 3339 end; defaults to 14 days later, at most 90')
147
+ .action(async (schedule, opts) =>
148
+ print(await new OnCallClient(options(opts)).listShifts(schedule, opts)),
149
+ );
150
+ accountAuthenticated(oncall.command('mine').description("A member's shifts across every schedule"))
151
+ .requiredOption('--user <uuid>', 'Member UUID, required for API keys')
152
+ .option('--since <time>', 'RFC 3339 start; defaults to now')
153
+ .option('--until <time>', 'RFC 3339 end; defaults to 14 days later, at most 90')
154
+ .action(async (opts) =>
155
+ print(await new OnCallClient(options(opts)).listMemberShifts(opts.user, opts)),
156
+ );
70
157
  try {
71
158
  await program.parseAsync();
72
159
  } catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@siteqwality/cli",
3
- "version": "0.1.1",
4
- "description": "Site Qwality release and Debug ID source-map tooling",
3
+ "version": "0.1.3",
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 ApiClient {
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 >= this.retries) {
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
- api(suffix, method = 'GET', body) {
99
+ accountApi(path, method = 'GET', body, envelope = false, retries = this.retries) {
102
100
  return this.request(
103
- `${this.base}/rum/${this.app}${suffix}`,
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])
@@ -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 ApiOptions {
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,73 @@ 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
+
129
+ export interface OnCallShift {
130
+ schedule_id?: string;
131
+ schedule_name?: string;
132
+ user_id?: string;
133
+ source: 'override' | 'layer' | 'gap';
134
+ layer_id?: string;
135
+ layer_number?: number;
136
+ override_id?: string;
137
+ starts_at?: string;
138
+ ends_at?: string;
139
+ }
140
+ export interface OnCallNow {
141
+ schedule_id: string;
142
+ at: string;
143
+ timezone: string;
144
+ on_call: OnCallShift;
145
+ next?: OnCallShift;
146
+ }
147
+ export class OnCallClient {
148
+ constructor(options: AccountApiOptions);
149
+ onCallNow(options: { scheduleId: string; at?: string }): Promise<OnCallNow>;
150
+ onCallNow(options?: { at?: string }): Promise<OnCallShift[]>;
151
+ listShifts(
152
+ scheduleId: string,
153
+ range?: { since?: string; until?: string },
154
+ ): Promise<{
155
+ schedule_id: string;
156
+ since: string;
157
+ until: string;
158
+ timezone: string;
159
+ shifts: OnCallShift[];
160
+ }>;
161
+ listMemberShifts(
162
+ userId: string,
163
+ range?: { since?: string; until?: string },
164
+ ): Promise<OnCallShift[]>;
165
+ }
166
+
96
167
  export function sourceMapReference(code: string): string | undefined;
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
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';
5
+ export { OnCallClient } from './oncall.js';
4
6
  export { uploadMaps, uploadDirectory } from './upload.js';
5
7
 
6
8
  export { sourceMapReference } from './comments.js';
package/src/oncall.js ADDED
@@ -0,0 +1,41 @@
1
+ import { AuthenticatedClient, isUuid } from './api.js';
2
+
3
+ const DAY_MS = 86_400_000;
4
+ const uuid = (id, name) => {
5
+ if (!isUuid(id)) throw new Error(`A valid ${name} UUID is required`);
6
+ return id;
7
+ };
8
+ const instant = (value, name) => {
9
+ if (value === undefined) return undefined;
10
+ const ms = typeof value === 'string' ? Date.parse(value) : Number.NaN;
11
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(value) || Number.isNaN(ms))
12
+ throw new Error(`${name} must be an RFC 3339 time, for example 2026-10-21T09:00:00Z`);
13
+ return new Date(ms).toISOString();
14
+ };
15
+ const query = (params) => {
16
+ const entries = Object.entries(params).filter(([, v]) => v !== undefined);
17
+ return entries.length ? `?${new URLSearchParams(entries)}` : '';
18
+ };
19
+
20
+ export class OnCallClient extends AuthenticatedClient {
21
+ /** Who is on call now (or at `at`): one schedule with its next shift, or every schedule. */
22
+ onCallNow({ scheduleId, at } = {}) {
23
+ const when = instant(at, 'at');
24
+ if (scheduleId === undefined) return this.accountApi(`/on_call${query({ since: when })}`);
25
+ uuid(scheduleId, 'schedule');
26
+ return this.accountApi(`/on_call_schedule/${scheduleId}/on_call${query({ at: when })}`);
27
+ }
28
+ /** A schedule's shifts, gaps included; the API allows at most 90 days. */
29
+ listShifts(scheduleId, { since, until } = {}) {
30
+ uuid(scheduleId, 'schedule');
31
+ const range = { since: instant(since, 'since'), until: instant(until, 'until') };
32
+ return this.accountApi(`/on_call_schedule/${scheduleId}/shifts${query(range)}`);
33
+ }
34
+ /** One member's shifts across every schedule, from now for 14 days unless given. */
35
+ listMemberShifts(userId, { since, until } = {}) {
36
+ uuid(userId, 'member');
37
+ const start = instant(since, 'since') ?? new Date().toISOString();
38
+ const end = instant(until, 'until') ?? new Date(Date.parse(start) + 14 * DAY_MS).toISOString();
39
+ return this.accountApi(`/on_call${query({ user_id: userId, since: start, until: end })}`);
40
+ }
41
+ }