@supercheck/cli 0.1.0-beta.1
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 +79 -0
- package/dist/bin/supercheck.js +3750 -0
- package/dist/bin/supercheck.js.map +1 -0
- package/dist/index.d.ts +1140 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,3750 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin/supercheck.ts
|
|
4
|
+
import { Command as Command18 } from "commander";
|
|
5
|
+
import pc7 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/commands/login.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/auth/store.ts
|
|
11
|
+
import Conf from "conf";
|
|
12
|
+
|
|
13
|
+
// src/utils/errors.ts
|
|
14
|
+
var CLIError = class extends Error {
|
|
15
|
+
exitCode;
|
|
16
|
+
constructor(message, exitCode = 1 /* GeneralError */) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CLIError";
|
|
19
|
+
this.exitCode = exitCode;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
var AuthenticationError = class extends CLIError {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message, 2 /* AuthError */);
|
|
25
|
+
this.name = "AuthenticationError";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var ApiRequestError = class extends CLIError {
|
|
29
|
+
statusCode;
|
|
30
|
+
responseBody;
|
|
31
|
+
constructor(message, statusCode, responseBody) {
|
|
32
|
+
super(message, 4 /* ApiError */);
|
|
33
|
+
this.name = "ApiRequestError";
|
|
34
|
+
this.statusCode = statusCode;
|
|
35
|
+
this.responseBody = responseBody;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var TimeoutError = class extends CLIError {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message, 5 /* Timeout */);
|
|
41
|
+
this.name = "TimeoutError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// src/utils/logger.ts
|
|
46
|
+
import pc from "picocolors";
|
|
47
|
+
var LOG_LEVELS = {
|
|
48
|
+
debug: 0,
|
|
49
|
+
info: 1,
|
|
50
|
+
warn: 2,
|
|
51
|
+
error: 3,
|
|
52
|
+
silent: 4
|
|
53
|
+
};
|
|
54
|
+
var currentLevel = "info";
|
|
55
|
+
var quietMode = false;
|
|
56
|
+
function setLogLevel(level) {
|
|
57
|
+
currentLevel = level;
|
|
58
|
+
}
|
|
59
|
+
function setQuietMode(quiet) {
|
|
60
|
+
quietMode = quiet;
|
|
61
|
+
if (quiet) currentLevel = "error";
|
|
62
|
+
}
|
|
63
|
+
function shouldLog(level) {
|
|
64
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
65
|
+
}
|
|
66
|
+
var logger = {
|
|
67
|
+
debug(message, ...args) {
|
|
68
|
+
if (shouldLog("debug")) {
|
|
69
|
+
console.error(pc.gray(`[debug] ${message}`), ...args);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
info(message, ...args) {
|
|
73
|
+
if (shouldLog("info") && !quietMode) {
|
|
74
|
+
console.error(message, ...args);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
success(message, ...args) {
|
|
78
|
+
if (shouldLog("info") && !quietMode) {
|
|
79
|
+
console.error(pc.green(`\u2713 ${message}`), ...args);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
warn(message, ...args) {
|
|
83
|
+
if (shouldLog("warn")) {
|
|
84
|
+
console.error(pc.yellow(`\u26A0 ${message}`), ...args);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
error(message, ...args) {
|
|
88
|
+
if (shouldLog("error")) {
|
|
89
|
+
console.error(pc.red(`\u2717 ${message}`), ...args);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* Output data to stdout (not stderr).
|
|
94
|
+
* This is for machine-readable output (JSON, tables).
|
|
95
|
+
*/
|
|
96
|
+
output(data) {
|
|
97
|
+
console.log(data);
|
|
98
|
+
},
|
|
99
|
+
newline() {
|
|
100
|
+
if (!quietMode) console.error("");
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* Print a styled header.
|
|
104
|
+
*/
|
|
105
|
+
header(text) {
|
|
106
|
+
if (!quietMode) {
|
|
107
|
+
console.error(pc.bold(pc.cyan(text)));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/auth/store.ts
|
|
113
|
+
var TOKEN_PREFIX_LIVE = "sck_live_";
|
|
114
|
+
var TOKEN_PREFIX_TEST = "sck_test_";
|
|
115
|
+
var TOKEN_PREFIX_TRIGGER = "sck_trigger_";
|
|
116
|
+
var TOKEN_PREFIX_TRIGGER_LEGACY = "job_";
|
|
117
|
+
var VALID_CLI_PREFIXES = [TOKEN_PREFIX_LIVE, TOKEN_PREFIX_TEST];
|
|
118
|
+
var VALID_TRIGGER_PREFIXES = [TOKEN_PREFIX_TRIGGER, TOKEN_PREFIX_TRIGGER_LEGACY];
|
|
119
|
+
var store = new Conf({
|
|
120
|
+
projectName: "supercheck-cli",
|
|
121
|
+
schema: {
|
|
122
|
+
token: { type: "string" },
|
|
123
|
+
baseUrl: { type: "string" },
|
|
124
|
+
organization: { type: "string" },
|
|
125
|
+
project: { type: "string" }
|
|
126
|
+
},
|
|
127
|
+
// Static obfuscation key — see SECURITY NOTE above.
|
|
128
|
+
encryptionKey: "supercheck-cli-v1"
|
|
129
|
+
});
|
|
130
|
+
function validateTokenFormat(token) {
|
|
131
|
+
return VALID_CLI_PREFIXES.some((prefix) => token.startsWith(prefix));
|
|
132
|
+
}
|
|
133
|
+
function validateTriggerKeyFormat(token) {
|
|
134
|
+
return VALID_TRIGGER_PREFIXES.some((prefix) => token.startsWith(prefix));
|
|
135
|
+
}
|
|
136
|
+
function getToken() {
|
|
137
|
+
const envToken = process.env.SUPERCHECK_TOKEN;
|
|
138
|
+
if (envToken) {
|
|
139
|
+
const trimmed = envToken.trim();
|
|
140
|
+
if (!validateTokenFormat(trimmed)) {
|
|
141
|
+
logger.warn("SUPERCHECK_TOKEN must be a CLI token (sck_live_* or sck_test_*).");
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return trimmed;
|
|
145
|
+
}
|
|
146
|
+
const stored = store.get("token");
|
|
147
|
+
if (!stored) return null;
|
|
148
|
+
return validateTokenFormat(stored) ? stored : null;
|
|
149
|
+
}
|
|
150
|
+
function getTriggerKey() {
|
|
151
|
+
const envKey = process.env.SUPERCHECK_TRIGGER_KEY;
|
|
152
|
+
if (envKey) {
|
|
153
|
+
const trimmed = envKey.trim();
|
|
154
|
+
if (!validateTriggerKeyFormat(trimmed)) {
|
|
155
|
+
logger.warn("SUPERCHECK_TRIGGER_KEY must start with sck_trigger_ or job_.");
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return trimmed;
|
|
159
|
+
}
|
|
160
|
+
const token = process.env.SUPERCHECK_TOKEN;
|
|
161
|
+
if (token && validateTriggerKeyFormat(token.trim())) {
|
|
162
|
+
logger.warn("Using SUPERCHECK_TOKEN as a trigger key. Prefer setting SUPERCHECK_TRIGGER_KEY instead.");
|
|
163
|
+
return token.trim();
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
function setToken(token) {
|
|
168
|
+
if (!validateTokenFormat(token)) {
|
|
169
|
+
throw new AuthenticationError(
|
|
170
|
+
`Invalid token format. Token must start with one of: ${VALID_CLI_PREFIXES.join(", ")}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
store.set("token", token);
|
|
174
|
+
}
|
|
175
|
+
function clearAuth() {
|
|
176
|
+
store.delete("token");
|
|
177
|
+
store.delete("baseUrl");
|
|
178
|
+
store.delete("organization");
|
|
179
|
+
store.delete("project");
|
|
180
|
+
}
|
|
181
|
+
function getStoredBaseUrl() {
|
|
182
|
+
return process.env.SUPERCHECK_URL ?? store.get("baseUrl") ?? null;
|
|
183
|
+
}
|
|
184
|
+
function setBaseUrl(url) {
|
|
185
|
+
store.set("baseUrl", url);
|
|
186
|
+
}
|
|
187
|
+
function getStoredOrganization() {
|
|
188
|
+
return process.env.SUPERCHECK_ORG ?? store.get("organization") ?? null;
|
|
189
|
+
}
|
|
190
|
+
function getStoredProject() {
|
|
191
|
+
return process.env.SUPERCHECK_PROJECT ?? store.get("project") ?? null;
|
|
192
|
+
}
|
|
193
|
+
function requireAuth() {
|
|
194
|
+
const token = getToken();
|
|
195
|
+
if (!token) {
|
|
196
|
+
throw new AuthenticationError(
|
|
197
|
+
"Not authenticated. Run `supercheck login` or set SUPERCHECK_TOKEN environment variable."
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return token;
|
|
201
|
+
}
|
|
202
|
+
function requireTriggerKey() {
|
|
203
|
+
const key = getTriggerKey();
|
|
204
|
+
if (!key) {
|
|
205
|
+
throw new AuthenticationError(
|
|
206
|
+
"Missing trigger key. Set SUPERCHECK_TRIGGER_KEY (sck_trigger_* or job_*) to use `supercheck job trigger`."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return key;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/version.ts
|
|
213
|
+
var CLI_VERSION = true ? "0.1.0-beta.1" : "0.0.0-dev";
|
|
214
|
+
|
|
215
|
+
// src/api/client.ts
|
|
216
|
+
import { ProxyAgent } from "undici";
|
|
217
|
+
var DEFAULT_BASE_URL = "https://app.supercheck.io";
|
|
218
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
219
|
+
var MAX_RETRIES = 3;
|
|
220
|
+
var RETRY_BACKOFF_MS = 1e3;
|
|
221
|
+
var proxyAgents = /* @__PURE__ */ new Map();
|
|
222
|
+
function getProxyAgent(proxyUrl) {
|
|
223
|
+
const existing = proxyAgents.get(proxyUrl);
|
|
224
|
+
if (existing) return existing;
|
|
225
|
+
const created = new ProxyAgent(proxyUrl);
|
|
226
|
+
proxyAgents.set(proxyUrl, created);
|
|
227
|
+
return created;
|
|
228
|
+
}
|
|
229
|
+
var ApiClient = class {
|
|
230
|
+
baseUrl;
|
|
231
|
+
token;
|
|
232
|
+
timeout;
|
|
233
|
+
proxy;
|
|
234
|
+
constructor(options = {}) {
|
|
235
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
236
|
+
this.token = options.token ?? null;
|
|
237
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
238
|
+
this.proxy = options.proxy ?? null;
|
|
239
|
+
}
|
|
240
|
+
setToken(token) {
|
|
241
|
+
this.token = token;
|
|
242
|
+
}
|
|
243
|
+
setBaseUrl(url) {
|
|
244
|
+
this.baseUrl = url.replace(/\/$/, "");
|
|
245
|
+
}
|
|
246
|
+
setTimeout(timeout) {
|
|
247
|
+
this.timeout = timeout;
|
|
248
|
+
}
|
|
249
|
+
setProxy(proxy) {
|
|
250
|
+
this.proxy = proxy;
|
|
251
|
+
}
|
|
252
|
+
buildHeaders(extraHeaders) {
|
|
253
|
+
const headers = new Headers({
|
|
254
|
+
"Content-Type": "application/json",
|
|
255
|
+
"User-Agent": `supercheck-cli/${CLI_VERSION}`,
|
|
256
|
+
...extraHeaders
|
|
257
|
+
});
|
|
258
|
+
if (this.token) {
|
|
259
|
+
headers.set("Authorization", `Bearer ${this.token}`);
|
|
260
|
+
}
|
|
261
|
+
return headers;
|
|
262
|
+
}
|
|
263
|
+
buildUrl(path, params) {
|
|
264
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
265
|
+
if (params) {
|
|
266
|
+
for (const [key, value] of Object.entries(params)) {
|
|
267
|
+
if (value !== void 0) {
|
|
268
|
+
url.searchParams.set(key, String(value));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return url.toString();
|
|
273
|
+
}
|
|
274
|
+
getProxyEnv(url) {
|
|
275
|
+
if (this.proxy) return this.proxy;
|
|
276
|
+
const noProxyRaw = process.env.NO_PROXY ?? process.env.no_proxy;
|
|
277
|
+
if (noProxyRaw && this.isNoProxyMatch(url, noProxyRaw)) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (url.protocol === "https:") {
|
|
281
|
+
return process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
|
|
282
|
+
}
|
|
283
|
+
if (url.protocol === "http:") {
|
|
284
|
+
return process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
isNoProxyMatch(url, noProxyRaw) {
|
|
289
|
+
const hostname = url.hostname;
|
|
290
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
291
|
+
const entries = noProxyRaw.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
292
|
+
if (entries.includes("*")) return true;
|
|
293
|
+
for (const entry of entries) {
|
|
294
|
+
const [hostPart, portPart] = entry.split(":");
|
|
295
|
+
const host = hostPart.trim();
|
|
296
|
+
const entryPort = portPart?.trim();
|
|
297
|
+
if (!host) continue;
|
|
298
|
+
if (entryPort && entryPort !== port) continue;
|
|
299
|
+
if (host.startsWith(".")) {
|
|
300
|
+
if (hostname.endsWith(host)) return true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (hostname === host) return true;
|
|
304
|
+
if (hostname.endsWith(`.${host}`)) return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Execute an HTTP request with retries, timeout, and rate limit handling.
|
|
310
|
+
*/
|
|
311
|
+
async request(method, path, options) {
|
|
312
|
+
const url = this.buildUrl(path, options?.params);
|
|
313
|
+
const parsedUrl = new URL(url);
|
|
314
|
+
const headers = this.buildHeaders(options?.headers);
|
|
315
|
+
const maxRetries = options?.retries ?? MAX_RETRIES;
|
|
316
|
+
let lastError = null;
|
|
317
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
318
|
+
let timeoutId = null;
|
|
319
|
+
try {
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
322
|
+
const proxy = this.getProxyEnv(parsedUrl);
|
|
323
|
+
const fetchOptions = {
|
|
324
|
+
method,
|
|
325
|
+
headers,
|
|
326
|
+
body: options?.body ? JSON.stringify(options.body) : void 0,
|
|
327
|
+
signal: controller.signal
|
|
328
|
+
};
|
|
329
|
+
if (proxy) {
|
|
330
|
+
fetchOptions.dispatcher = getProxyAgent(proxy);
|
|
331
|
+
}
|
|
332
|
+
const response = await fetch(url, fetchOptions);
|
|
333
|
+
clearTimeout(timeoutId);
|
|
334
|
+
timeoutId = null;
|
|
335
|
+
if (response.status === 429) {
|
|
336
|
+
if (attempt >= maxRetries) {
|
|
337
|
+
throw new ApiRequestError(
|
|
338
|
+
`Rate limited after ${maxRetries + 1} attempts: ${method} ${path}`,
|
|
339
|
+
429
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
343
|
+
const parsedSeconds = retryAfter ? Number(retryAfter) : NaN;
|
|
344
|
+
const waitMs = Number.isFinite(parsedSeconds) && parsedSeconds > 0 ? parsedSeconds * 1e3 : RETRY_BACKOFF_MS * Math.pow(2, attempt);
|
|
345
|
+
logger.warn(`Rate limited. Retrying in ${Math.round(waitMs / 1e3)}s...`);
|
|
346
|
+
await this.sleep(waitMs);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (response.status >= 500 && attempt < maxRetries) {
|
|
350
|
+
const waitMs = RETRY_BACKOFF_MS * Math.pow(2, attempt);
|
|
351
|
+
logger.debug(`Server error ${response.status}. Retrying in ${waitMs}ms...`);
|
|
352
|
+
await this.sleep(waitMs);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const text = await response.text();
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
let responseBody;
|
|
358
|
+
try {
|
|
359
|
+
responseBody = JSON.parse(text);
|
|
360
|
+
} catch {
|
|
361
|
+
responseBody = text;
|
|
362
|
+
}
|
|
363
|
+
throw new ApiRequestError(
|
|
364
|
+
`API request failed: ${method} ${path} \u2192 ${response.status}`,
|
|
365
|
+
response.status,
|
|
366
|
+
responseBody
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const data = text ? JSON.parse(text) : {};
|
|
370
|
+
return {
|
|
371
|
+
data,
|
|
372
|
+
status: response.status,
|
|
373
|
+
headers: response.headers
|
|
374
|
+
};
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof ApiRequestError) throw err;
|
|
377
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
378
|
+
throw new TimeoutError(`Request timed out after ${this.timeout}ms: ${method} ${path}`);
|
|
379
|
+
}
|
|
380
|
+
lastError = err;
|
|
381
|
+
if (attempt < maxRetries) {
|
|
382
|
+
const waitMs = RETRY_BACKOFF_MS * Math.pow(2, attempt);
|
|
383
|
+
logger.debug(`Network error: ${err.message}. Retrying in ${waitMs}ms...`);
|
|
384
|
+
await this.sleep(waitMs);
|
|
385
|
+
}
|
|
386
|
+
} finally {
|
|
387
|
+
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
throw new ApiRequestError(
|
|
391
|
+
`Request failed after ${maxRetries + 1} attempts: ${lastError?.message ?? "Unknown error"}`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
async get(path, params) {
|
|
395
|
+
return this.request("GET", path, { params });
|
|
396
|
+
}
|
|
397
|
+
async post(path, body) {
|
|
398
|
+
return this.request("POST", path, { body });
|
|
399
|
+
}
|
|
400
|
+
async put(path, body) {
|
|
401
|
+
return this.request("PUT", path, { body });
|
|
402
|
+
}
|
|
403
|
+
async patch(path, body) {
|
|
404
|
+
return this.request("PATCH", path, { body });
|
|
405
|
+
}
|
|
406
|
+
async delete(path) {
|
|
407
|
+
return this.request("DELETE", path);
|
|
408
|
+
}
|
|
409
|
+
sleep(ms) {
|
|
410
|
+
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
var defaultClient = null;
|
|
414
|
+
function getApiClient(options) {
|
|
415
|
+
if (!defaultClient) {
|
|
416
|
+
defaultClient = new ApiClient(options);
|
|
417
|
+
} else if (options) {
|
|
418
|
+
if (options.token !== void 0) defaultClient.setToken(options.token);
|
|
419
|
+
if (options.baseUrl !== void 0) defaultClient.setBaseUrl(options.baseUrl);
|
|
420
|
+
if (options.timeout !== void 0) defaultClient.setTimeout(options.timeout);
|
|
421
|
+
if (options.proxy !== void 0) defaultClient.setProxy(options.proxy);
|
|
422
|
+
}
|
|
423
|
+
return defaultClient;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/utils/discovery.ts
|
|
427
|
+
import { readdirSync, statSync, readFileSync } from "fs";
|
|
428
|
+
import { resolve, relative, basename } from "path";
|
|
429
|
+
function matchGlob(filePath, pattern) {
|
|
430
|
+
const withPlaceholders = pattern.replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "{{STAR}}");
|
|
431
|
+
const escaped = withPlaceholders.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
432
|
+
const regexStr = escaped.replace(/\{\{GLOBSTAR\}\}/g, ".*").replace(/\{\{STAR\}\}/g, "[^/]*");
|
|
433
|
+
return new RegExp(`^${regexStr}$`).test(filePath);
|
|
434
|
+
}
|
|
435
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
436
|
+
"node_modules",
|
|
437
|
+
".git",
|
|
438
|
+
".next",
|
|
439
|
+
".nuxt",
|
|
440
|
+
"dist",
|
|
441
|
+
"build",
|
|
442
|
+
"out",
|
|
443
|
+
"coverage",
|
|
444
|
+
".turbo",
|
|
445
|
+
".cache",
|
|
446
|
+
".vercel",
|
|
447
|
+
"vendor"
|
|
448
|
+
]);
|
|
449
|
+
function walkDir(dir) {
|
|
450
|
+
const results = [];
|
|
451
|
+
try {
|
|
452
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
453
|
+
for (const entry of entries) {
|
|
454
|
+
const fullPath = resolve(dir, entry.name);
|
|
455
|
+
if (entry.isDirectory()) {
|
|
456
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
457
|
+
results.push(...walkDir(fullPath));
|
|
458
|
+
} else if (entry.isFile()) {
|
|
459
|
+
results.push(fullPath);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
return results;
|
|
465
|
+
}
|
|
466
|
+
function getGlobStaticPrefix(pattern) {
|
|
467
|
+
const parts = pattern.split("/");
|
|
468
|
+
const staticParts = [];
|
|
469
|
+
for (const part of parts) {
|
|
470
|
+
if (part.includes("*") || part.includes("?") || part.includes("{") || part.includes("[")) break;
|
|
471
|
+
staticParts.push(part);
|
|
472
|
+
}
|
|
473
|
+
return staticParts.length > 0 ? staticParts.join("/") : null;
|
|
474
|
+
}
|
|
475
|
+
function discoverFiles(cwd, patterns) {
|
|
476
|
+
const walkRoots = /* @__PURE__ */ new Set();
|
|
477
|
+
for (const pattern of [patterns.playwright, patterns.k6]) {
|
|
478
|
+
if (!pattern) continue;
|
|
479
|
+
const prefix = getGlobStaticPrefix(pattern);
|
|
480
|
+
if (prefix) {
|
|
481
|
+
walkRoots.add(resolve(cwd, prefix));
|
|
482
|
+
} else {
|
|
483
|
+
walkRoots.add(cwd);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (walkRoots.has(cwd) && walkRoots.size > 1) {
|
|
487
|
+
walkRoots.clear();
|
|
488
|
+
walkRoots.add(cwd);
|
|
489
|
+
}
|
|
490
|
+
const allFiles = [];
|
|
491
|
+
for (const root of walkRoots) {
|
|
492
|
+
allFiles.push(...walkDir(root));
|
|
493
|
+
}
|
|
494
|
+
const discovered = [];
|
|
495
|
+
for (const absPath of allFiles) {
|
|
496
|
+
const relPath = relative(cwd, absPath);
|
|
497
|
+
const name = basename(absPath);
|
|
498
|
+
if (patterns.playwright && matchGlob(relPath, patterns.playwright)) {
|
|
499
|
+
discovered.push({
|
|
500
|
+
absolutePath: absPath,
|
|
501
|
+
relativePath: relPath,
|
|
502
|
+
filename: name,
|
|
503
|
+
type: "playwright"
|
|
504
|
+
});
|
|
505
|
+
} else if (patterns.k6 && matchGlob(relPath, patterns.k6)) {
|
|
506
|
+
discovered.push({
|
|
507
|
+
absolutePath: absPath,
|
|
508
|
+
relativePath: relPath,
|
|
509
|
+
filename: name,
|
|
510
|
+
type: "k6"
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
logger.debug(`Discovered ${discovered.length} test files`);
|
|
515
|
+
return discovered;
|
|
516
|
+
}
|
|
517
|
+
function readFileContent(filePath) {
|
|
518
|
+
try {
|
|
519
|
+
return readFileSync(filePath, "utf-8");
|
|
520
|
+
} catch {
|
|
521
|
+
logger.warn(`Cannot read file: ${filePath}`);
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/utils/resources.ts
|
|
527
|
+
var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
528
|
+
function buildLocalResources(config, cwd) {
|
|
529
|
+
const resources = [];
|
|
530
|
+
if (config.jobs) {
|
|
531
|
+
for (const job of config.jobs) {
|
|
532
|
+
resources.push({
|
|
533
|
+
id: job.id,
|
|
534
|
+
type: "job",
|
|
535
|
+
name: job.name,
|
|
536
|
+
definition: { ...job }
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (config.variables) {
|
|
541
|
+
for (const v of config.variables) {
|
|
542
|
+
resources.push({
|
|
543
|
+
id: v.id,
|
|
544
|
+
type: "variable",
|
|
545
|
+
name: v.key,
|
|
546
|
+
definition: { key: v.key, value: v.value, isSecret: v.isSecret, description: v.description }
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (config.tags) {
|
|
551
|
+
for (const t of config.tags) {
|
|
552
|
+
resources.push({
|
|
553
|
+
id: t.id,
|
|
554
|
+
type: "tag",
|
|
555
|
+
name: t.name,
|
|
556
|
+
definition: { name: t.name, color: t.color }
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (Array.isArray(config.monitors)) {
|
|
561
|
+
for (const m of config.monitors) {
|
|
562
|
+
resources.push({
|
|
563
|
+
id: m.id,
|
|
564
|
+
type: "monitor",
|
|
565
|
+
name: m.name,
|
|
566
|
+
definition: { ...m }
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
} else if (typeof config.monitors === "string") {
|
|
570
|
+
logger.warn(
|
|
571
|
+
`monitors is set to a glob pattern ("${config.monitors}") but glob-based monitor discovery is not yet implemented. Define monitors as an inline array in your config, or remove the monitors field.`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (Array.isArray(config.statusPages)) {
|
|
575
|
+
for (const sp of config.statusPages) {
|
|
576
|
+
resources.push({
|
|
577
|
+
id: sp.id,
|
|
578
|
+
type: "statusPage",
|
|
579
|
+
name: sp.name,
|
|
580
|
+
definition: { ...sp }
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
} else if (typeof config.statusPages === "string") {
|
|
584
|
+
logger.warn(
|
|
585
|
+
`statusPages is set to a glob pattern ("${config.statusPages}") but glob-based status page discovery is not yet implemented. Define status pages as an inline array in your config, or remove the statusPages field.`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
const patterns = {
|
|
589
|
+
playwright: config.tests?.playwright?.testMatch,
|
|
590
|
+
k6: config.tests?.k6?.testMatch
|
|
591
|
+
};
|
|
592
|
+
const files = discoverFiles(cwd, patterns);
|
|
593
|
+
for (const file of files) {
|
|
594
|
+
const script = readFileContent(file.absolutePath);
|
|
595
|
+
if (script) {
|
|
596
|
+
const stem = file.filename.replace(/\.(pw|k6)\.ts$/, "");
|
|
597
|
+
const testId = UUID_REGEX.test(stem) ? stem : void 0;
|
|
598
|
+
if (!testId) {
|
|
599
|
+
logger.debug(`Test file "${file.filename}" does not have a UUID-based filename \u2014 will be treated as a new resource`);
|
|
600
|
+
}
|
|
601
|
+
resources.push({
|
|
602
|
+
id: testId,
|
|
603
|
+
type: "test",
|
|
604
|
+
name: file.filename,
|
|
605
|
+
definition: {
|
|
606
|
+
id: testId,
|
|
607
|
+
title: stem,
|
|
608
|
+
testType: file.type === "playwright" ? "playwright" : "k6",
|
|
609
|
+
script
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return resources;
|
|
615
|
+
}
|
|
616
|
+
var PAGE_LIMIT = 200;
|
|
617
|
+
async function fetchAllPages(client, endpoint, label) {
|
|
618
|
+
const items = [];
|
|
619
|
+
let page = 1;
|
|
620
|
+
let totalPages = 1;
|
|
621
|
+
do {
|
|
622
|
+
const { data } = await client.get(endpoint, {
|
|
623
|
+
limit: String(PAGE_LIMIT),
|
|
624
|
+
page: String(page)
|
|
625
|
+
});
|
|
626
|
+
items.push(...data.data);
|
|
627
|
+
if (data.pagination) {
|
|
628
|
+
totalPages = data.pagination.totalPages;
|
|
629
|
+
}
|
|
630
|
+
page++;
|
|
631
|
+
} while (page <= totalPages);
|
|
632
|
+
if (totalPages > 1) {
|
|
633
|
+
logger.debug(`Fetched ${totalPages} pages of ${label} (${items.length} total)`);
|
|
634
|
+
}
|
|
635
|
+
return items;
|
|
636
|
+
}
|
|
637
|
+
function safeId(value) {
|
|
638
|
+
if (value === void 0 || value === null) return void 0;
|
|
639
|
+
const str = String(value);
|
|
640
|
+
if (str === "undefined" || str === "null" || str === "") return void 0;
|
|
641
|
+
return str;
|
|
642
|
+
}
|
|
643
|
+
async function fetchRemoteResources(client) {
|
|
644
|
+
const resources = [];
|
|
645
|
+
try {
|
|
646
|
+
const jobs = await fetchAllPages(client, "/api/jobs", "jobs");
|
|
647
|
+
for (const job of jobs) {
|
|
648
|
+
const id = safeId(job.id);
|
|
649
|
+
if (!id) {
|
|
650
|
+
logger.debug("Skipping job with missing id");
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
resources.push({ id, type: "job", name: String(job.name ?? ""), raw: job });
|
|
654
|
+
}
|
|
655
|
+
} catch (err) {
|
|
656
|
+
logFetchError("jobs", err);
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const tests = await fetchAllPages(client, "/api/tests", "tests");
|
|
660
|
+
for (const test of tests) {
|
|
661
|
+
const id = safeId(test.id);
|
|
662
|
+
if (!id) {
|
|
663
|
+
logger.debug("Skipping test with missing id");
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
resources.push({ id, type: "test", name: String(test.title ?? ""), raw: test });
|
|
667
|
+
}
|
|
668
|
+
} catch (err) {
|
|
669
|
+
logFetchError("tests", err);
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
const monitors = await fetchAllPages(client, "/api/monitors", "monitors");
|
|
673
|
+
for (const monitor of monitors) {
|
|
674
|
+
const id = safeId(monitor.id);
|
|
675
|
+
if (!id) {
|
|
676
|
+
logger.debug("Skipping monitor with missing id");
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
resources.push({ id, type: "monitor", name: String(monitor.name ?? ""), raw: monitor });
|
|
680
|
+
}
|
|
681
|
+
} catch (err) {
|
|
682
|
+
logFetchError("monitors", err);
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
const { data } = await client.get("/api/variables");
|
|
686
|
+
for (const v of data) {
|
|
687
|
+
const id = safeId(v.id);
|
|
688
|
+
if (!id) {
|
|
689
|
+
logger.debug("Skipping variable with missing id");
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
resources.push({ id, type: "variable", name: String(v.key ?? ""), raw: v });
|
|
693
|
+
}
|
|
694
|
+
} catch (err) {
|
|
695
|
+
logFetchError("variables", err);
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
const { data } = await client.get("/api/tags");
|
|
699
|
+
for (const t of data) {
|
|
700
|
+
const id = safeId(t.id);
|
|
701
|
+
if (!id) {
|
|
702
|
+
logger.debug("Skipping tag with missing id");
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
resources.push({ id, type: "tag", name: String(t.name ?? ""), raw: t });
|
|
706
|
+
}
|
|
707
|
+
} catch (err) {
|
|
708
|
+
logFetchError("tags", err);
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const statusPages = await fetchAllPages(client, "/api/status-pages", "status-pages");
|
|
712
|
+
for (const sp of statusPages) {
|
|
713
|
+
const id = safeId(sp.id);
|
|
714
|
+
if (!id) {
|
|
715
|
+
logger.debug("Skipping status page with missing id");
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
resources.push({ id, type: "statusPage", name: String(sp.name ?? ""), raw: sp });
|
|
719
|
+
}
|
|
720
|
+
} catch (err) {
|
|
721
|
+
logFetchError("status-pages", err);
|
|
722
|
+
}
|
|
723
|
+
return resources;
|
|
724
|
+
}
|
|
725
|
+
function logFetchError(resourceType, err) {
|
|
726
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) {
|
|
727
|
+
logger.debug(`Could not fetch ${resourceType} (not found)`);
|
|
728
|
+
} else {
|
|
729
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
730
|
+
logger.warn(`Could not fetch ${resourceType}: ${msg}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function getApiEndpoint(type) {
|
|
734
|
+
switch (type) {
|
|
735
|
+
case "job":
|
|
736
|
+
return "/api/jobs";
|
|
737
|
+
case "test":
|
|
738
|
+
return "/api/tests";
|
|
739
|
+
case "monitor":
|
|
740
|
+
return "/api/monitors";
|
|
741
|
+
case "variable":
|
|
742
|
+
return "/api/variables";
|
|
743
|
+
case "tag":
|
|
744
|
+
return "/api/tags";
|
|
745
|
+
case "statusPage":
|
|
746
|
+
return "/api/status-pages";
|
|
747
|
+
default:
|
|
748
|
+
throw new Error(`Unknown resource type: ${type}`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function safeTokenPreview(token) {
|
|
752
|
+
if (!token || token.length === 0) return "(empty)";
|
|
753
|
+
if (token.length <= 4) return `${token.substring(0, 1)}***`;
|
|
754
|
+
if (token.length <= 8) {
|
|
755
|
+
return `${token.substring(0, 4)}...`;
|
|
756
|
+
}
|
|
757
|
+
if (token.length <= 20) {
|
|
758
|
+
return `${token.substring(0, Math.min(token.length - 4, 12))}...`;
|
|
759
|
+
}
|
|
760
|
+
return `${token.substring(0, 12)}...${token.substring(token.length - 4)}`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/commands/login.ts
|
|
764
|
+
var loginCommand = new Command("login").description("Authenticate with Supercheck").option("--token <token>", "Provide a CLI token directly (for CI/CD)").option("--url <url>", "Supercheck API URL (for self-hosted instances)").action(async (options) => {
|
|
765
|
+
if (options.token) {
|
|
766
|
+
const token = options.token.trim();
|
|
767
|
+
if (!validateTokenFormat(token)) {
|
|
768
|
+
throw new CLIError(
|
|
769
|
+
"Invalid token. `supercheck login` requires a CLI token (sck_live_* or sck_test_*). Trigger keys (sck_trigger_* / job_*) are only for `supercheck job trigger` via SUPERCHECK_TRIGGER_KEY.",
|
|
770
|
+
2 /* AuthError */
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
const client = getApiClient({
|
|
774
|
+
baseUrl: options.url,
|
|
775
|
+
token
|
|
776
|
+
});
|
|
777
|
+
try {
|
|
778
|
+
await client.get("/api/cli-tokens");
|
|
779
|
+
} catch {
|
|
780
|
+
throw new CLIError(
|
|
781
|
+
"Token verification failed. Please check your token and try again.",
|
|
782
|
+
2 /* AuthError */
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
setToken(token);
|
|
786
|
+
if (options.url) {
|
|
787
|
+
setBaseUrl(options.url);
|
|
788
|
+
}
|
|
789
|
+
const tokenPreview = safeTokenPreview(token);
|
|
790
|
+
logger.success("Authentication successful");
|
|
791
|
+
logger.info(` Token: ${tokenPreview}`);
|
|
792
|
+
if (options.url) {
|
|
793
|
+
logger.info(` API URL: ${options.url}`);
|
|
794
|
+
}
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
logger.newline();
|
|
798
|
+
logger.info("To authenticate, create a CLI token in the Dashboard:");
|
|
799
|
+
logger.info(" Dashboard \u2192 Project Settings \u2192 CLI Tokens \u2192 Create Token");
|
|
800
|
+
logger.newline();
|
|
801
|
+
if (options.url) {
|
|
802
|
+
logger.info(`Then run: supercheck login --token <your-token> --url ${options.url}`);
|
|
803
|
+
} else {
|
|
804
|
+
logger.info("Then run: supercheck login --token <your-token>");
|
|
805
|
+
}
|
|
806
|
+
logger.newline();
|
|
807
|
+
logger.warn("Browser-based OAuth login is not yet implemented.");
|
|
808
|
+
});
|
|
809
|
+
var logoutCommand = new Command("logout").description("Remove stored authentication credentials").action(() => {
|
|
810
|
+
clearAuth();
|
|
811
|
+
logger.success("Logged out successfully. Stored credentials removed.");
|
|
812
|
+
});
|
|
813
|
+
var whoamiCommand = new Command("whoami").description("Show current authentication context").action(async () => {
|
|
814
|
+
const token = getToken();
|
|
815
|
+
if (!token) {
|
|
816
|
+
throw new CLIError(
|
|
817
|
+
"Not authenticated. Run `supercheck login` to authenticate.",
|
|
818
|
+
2 /* AuthError */
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
const baseUrl = getStoredBaseUrl();
|
|
822
|
+
const client = getApiClient({ token, baseUrl: baseUrl ?? void 0 });
|
|
823
|
+
try {
|
|
824
|
+
const { data } = await client.get("/api/cli-tokens");
|
|
825
|
+
const tokenPreview = safeTokenPreview(token);
|
|
826
|
+
const activeToken = data.tokens?.find((t) => t.start && token.startsWith(t.start.replace(/\.+$/, "")));
|
|
827
|
+
logger.newline();
|
|
828
|
+
logger.header("Current Context");
|
|
829
|
+
logger.newline();
|
|
830
|
+
if (activeToken?.createdByName && activeToken.createdByName !== "-") {
|
|
831
|
+
logger.info(` User: ${activeToken.createdByName}`);
|
|
832
|
+
}
|
|
833
|
+
if (activeToken?.name) {
|
|
834
|
+
logger.info(` Token: ${activeToken.name} (${tokenPreview})`);
|
|
835
|
+
} else {
|
|
836
|
+
logger.info(` Token: ${tokenPreview}`);
|
|
837
|
+
}
|
|
838
|
+
logger.info(` API URL: ${baseUrl ?? "https://app.supercheck.io"}`);
|
|
839
|
+
if (activeToken?.expiresAt) {
|
|
840
|
+
logger.info(` Expires: ${activeToken.expiresAt}`);
|
|
841
|
+
}
|
|
842
|
+
if (activeToken?.lastRequest) {
|
|
843
|
+
logger.info(` Last used: ${activeToken.lastRequest}`);
|
|
844
|
+
}
|
|
845
|
+
logger.newline();
|
|
846
|
+
} catch {
|
|
847
|
+
throw new CLIError(
|
|
848
|
+
"Failed to verify authentication. Your token may be expired or invalid.",
|
|
849
|
+
2 /* AuthError */
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// src/commands/health.ts
|
|
855
|
+
import { Command as Command2 } from "commander";
|
|
856
|
+
|
|
857
|
+
// src/config/loader.ts
|
|
858
|
+
import { createJiti } from "jiti";
|
|
859
|
+
import { deepmerge } from "deepmerge-ts";
|
|
860
|
+
import { existsSync } from "fs";
|
|
861
|
+
import { resolve as resolve2, dirname } from "path";
|
|
862
|
+
|
|
863
|
+
// src/config/schema.ts
|
|
864
|
+
import { z } from "zod";
|
|
865
|
+
var tagDefinitionSchema = z.object({
|
|
866
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
867
|
+
id: z.string().uuid().optional(),
|
|
868
|
+
name: z.string().min(1),
|
|
869
|
+
color: z.string().optional()
|
|
870
|
+
});
|
|
871
|
+
var variableDefinitionSchema = z.object({
|
|
872
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
873
|
+
id: z.string().uuid().optional(),
|
|
874
|
+
key: z.string().min(1),
|
|
875
|
+
value: z.string(),
|
|
876
|
+
isSecret: z.boolean().default(false),
|
|
877
|
+
description: z.string().optional()
|
|
878
|
+
});
|
|
879
|
+
var notificationProviderDefinitionSchema = z.object({
|
|
880
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
881
|
+
id: z.string().uuid().optional(),
|
|
882
|
+
name: z.string().min(1),
|
|
883
|
+
type: z.enum(["email", "slack", "webhook", "telegram", "discord", "teams"]),
|
|
884
|
+
config: z.record(z.unknown())
|
|
885
|
+
});
|
|
886
|
+
var alertConfigSchema = z.object({
|
|
887
|
+
enabled: z.boolean().optional(),
|
|
888
|
+
notificationProviders: z.array(z.string()).optional(),
|
|
889
|
+
alertOnFailure: z.boolean().optional(),
|
|
890
|
+
alertOnSuccess: z.boolean().optional(),
|
|
891
|
+
alertOnTimeout: z.boolean().optional(),
|
|
892
|
+
alertOnRecovery: z.boolean().optional(),
|
|
893
|
+
alertOnSslExpiration: z.boolean().optional(),
|
|
894
|
+
failureThreshold: z.number().int().positive().optional(),
|
|
895
|
+
recoveryThreshold: z.number().int().positive().optional(),
|
|
896
|
+
customMessage: z.string().optional()
|
|
897
|
+
}).passthrough();
|
|
898
|
+
var playwrightTestConfigSchema = z.object({
|
|
899
|
+
testMatch: z.string().default("_supercheck_/tests/**/*.pw.ts"),
|
|
900
|
+
browser: z.enum(["chromium", "firefox", "webkit"]).default("chromium")
|
|
901
|
+
});
|
|
902
|
+
var k6TestConfigSchema = z.object({
|
|
903
|
+
testMatch: z.string().default("_supercheck_/tests/**/*.k6.ts")
|
|
904
|
+
});
|
|
905
|
+
var testsConfigSchema = z.object({
|
|
906
|
+
playwright: playwrightTestConfigSchema.optional(),
|
|
907
|
+
k6: k6TestConfigSchema.optional()
|
|
908
|
+
});
|
|
909
|
+
var monitorDefinitionSchema = z.object({
|
|
910
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
911
|
+
id: z.string().uuid().optional(),
|
|
912
|
+
name: z.string().min(1),
|
|
913
|
+
description: z.string().optional(),
|
|
914
|
+
type: z.enum(["http_request", "website", "ping_host", "port_check", "synthetic_test"]),
|
|
915
|
+
target: z.string().optional(),
|
|
916
|
+
frequencyMinutes: z.number().int().positive().optional(),
|
|
917
|
+
enabled: z.boolean().optional(),
|
|
918
|
+
config: z.record(z.unknown()).optional(),
|
|
919
|
+
alertConfig: alertConfigSchema.optional(),
|
|
920
|
+
tags: z.array(z.string()).optional()
|
|
921
|
+
});
|
|
922
|
+
var jobDefinitionSchema = z.object({
|
|
923
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
924
|
+
id: z.string().uuid().optional(),
|
|
925
|
+
name: z.string().min(1),
|
|
926
|
+
description: z.string().optional(),
|
|
927
|
+
jobType: z.enum(["playwright", "k6"]).optional(),
|
|
928
|
+
tests: z.array(z.string()).min(1),
|
|
929
|
+
cronSchedule: z.string().optional(),
|
|
930
|
+
status: z.string().optional(),
|
|
931
|
+
alertConfig: alertConfigSchema.optional(),
|
|
932
|
+
tags: z.array(z.string()).optional()
|
|
933
|
+
});
|
|
934
|
+
var statusPageComponentDefinitionSchema = z.object({
|
|
935
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
936
|
+
id: z.string().uuid().optional(),
|
|
937
|
+
name: z.string().min(1),
|
|
938
|
+
description: z.string().optional(),
|
|
939
|
+
monitors: z.array(z.string()).optional(),
|
|
940
|
+
position: z.number().int().min(0).optional()
|
|
941
|
+
});
|
|
942
|
+
var statusPageDefinitionSchema = z.object({
|
|
943
|
+
/** Database UUID. Present for existing resources, omitted for new ones. */
|
|
944
|
+
id: z.string().uuid().optional(),
|
|
945
|
+
name: z.string().min(1),
|
|
946
|
+
subdomain: z.string().optional(),
|
|
947
|
+
status: z.enum(["draft", "published", "archived"]).optional(),
|
|
948
|
+
description: z.string().optional(),
|
|
949
|
+
headline: z.string().optional(),
|
|
950
|
+
supportUrl: z.string().optional(),
|
|
951
|
+
components: z.array(statusPageComponentDefinitionSchema).optional(),
|
|
952
|
+
branding: z.object({
|
|
953
|
+
bodyBackgroundColor: z.string().optional(),
|
|
954
|
+
fontColor: z.string().optional(),
|
|
955
|
+
greens: z.string().optional(),
|
|
956
|
+
reds: z.string().optional()
|
|
957
|
+
}).optional(),
|
|
958
|
+
notifications: z.object({
|
|
959
|
+
allowEmailSubscribers: z.boolean().default(true),
|
|
960
|
+
allowWebhookSubscribers: z.boolean().default(false),
|
|
961
|
+
allowRssFeed: z.boolean().default(true)
|
|
962
|
+
}).optional()
|
|
963
|
+
});
|
|
964
|
+
var projectConfigSchema = z.object({
|
|
965
|
+
organization: z.string().min(1),
|
|
966
|
+
project: z.string().min(1)
|
|
967
|
+
});
|
|
968
|
+
var apiConfigSchema = z.object({
|
|
969
|
+
baseUrl: z.string().url().default("https://app.supercheck.io")
|
|
970
|
+
});
|
|
971
|
+
var defaultsConfigSchema = z.object({
|
|
972
|
+
runLocation: z.string().optional(),
|
|
973
|
+
timeout: z.number().int().positive().optional()
|
|
974
|
+
});
|
|
975
|
+
var supercheckConfigSchema = z.object({
|
|
976
|
+
schemaVersion: z.literal("1.0"),
|
|
977
|
+
project: projectConfigSchema,
|
|
978
|
+
api: apiConfigSchema.optional(),
|
|
979
|
+
defaults: defaultsConfigSchema.optional(),
|
|
980
|
+
tests: testsConfigSchema.optional(),
|
|
981
|
+
monitors: z.union([
|
|
982
|
+
z.string(),
|
|
983
|
+
z.array(monitorDefinitionSchema)
|
|
984
|
+
]).optional(),
|
|
985
|
+
statusPages: z.union([
|
|
986
|
+
z.string(),
|
|
987
|
+
z.array(statusPageDefinitionSchema)
|
|
988
|
+
]).optional(),
|
|
989
|
+
jobs: z.array(jobDefinitionSchema).optional(),
|
|
990
|
+
notificationProviders: z.array(notificationProviderDefinitionSchema).optional(),
|
|
991
|
+
variables: z.array(variableDefinitionSchema).optional(),
|
|
992
|
+
tags: z.array(tagDefinitionSchema).optional()
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// src/config/loader.ts
|
|
996
|
+
var CONFIG_FILES = [
|
|
997
|
+
"supercheck.config.ts",
|
|
998
|
+
"supercheck.config.js",
|
|
999
|
+
"supercheck.config.mjs"
|
|
1000
|
+
];
|
|
1001
|
+
var LOCAL_CONFIG_FILES = [
|
|
1002
|
+
"supercheck.config.local.ts",
|
|
1003
|
+
"supercheck.config.local.js",
|
|
1004
|
+
"supercheck.config.local.mjs"
|
|
1005
|
+
];
|
|
1006
|
+
function resolveConfigPath(cwd, explicitPath) {
|
|
1007
|
+
if (explicitPath) {
|
|
1008
|
+
const abs = resolve2(cwd, explicitPath);
|
|
1009
|
+
if (!existsSync(abs)) {
|
|
1010
|
+
throw new CLIError(
|
|
1011
|
+
`Config file not found: ${abs}`,
|
|
1012
|
+
3 /* ConfigError */
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
return abs;
|
|
1016
|
+
}
|
|
1017
|
+
for (const file of CONFIG_FILES) {
|
|
1018
|
+
const abs = resolve2(cwd, file);
|
|
1019
|
+
if (existsSync(abs)) return abs;
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
function resolveLocalConfigPath(cwd) {
|
|
1024
|
+
for (const file of LOCAL_CONFIG_FILES) {
|
|
1025
|
+
const abs = resolve2(cwd, file);
|
|
1026
|
+
if (existsSync(abs)) return abs;
|
|
1027
|
+
}
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
async function loadConfigFile(filePath) {
|
|
1031
|
+
const jiti = createJiti(dirname(filePath), {
|
|
1032
|
+
interopDefault: true
|
|
1033
|
+
});
|
|
1034
|
+
const mod = await jiti.import(filePath);
|
|
1035
|
+
return mod.default ?? mod;
|
|
1036
|
+
}
|
|
1037
|
+
function applyEnvOverrides(config) {
|
|
1038
|
+
const result = { ...config };
|
|
1039
|
+
if (process.env.SUPERCHECK_URL) {
|
|
1040
|
+
result.api = {
|
|
1041
|
+
...result.api,
|
|
1042
|
+
baseUrl: process.env.SUPERCHECK_URL
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
if (process.env.SUPERCHECK_ORG) {
|
|
1046
|
+
result.project = {
|
|
1047
|
+
...result.project,
|
|
1048
|
+
organization: process.env.SUPERCHECK_ORG
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
if (process.env.SUPERCHECK_PROJECT) {
|
|
1052
|
+
result.project = {
|
|
1053
|
+
...result.project,
|
|
1054
|
+
project: process.env.SUPERCHECK_PROJECT
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
1058
|
+
}
|
|
1059
|
+
function validateNoSecrets(config) {
|
|
1060
|
+
const configStr = JSON.stringify(config);
|
|
1061
|
+
const secretPatterns = [
|
|
1062
|
+
/sck_live_[a-zA-Z0-9]+/,
|
|
1063
|
+
/sck_trigger_[a-zA-Z0-9]+/,
|
|
1064
|
+
/sck_test_[a-zA-Z0-9]+/
|
|
1065
|
+
];
|
|
1066
|
+
for (const pattern of secretPatterns) {
|
|
1067
|
+
if (pattern.test(configStr)) {
|
|
1068
|
+
throw new CLIError(
|
|
1069
|
+
"Config file contains what appears to be an API token. Tokens must be stored in environment variables (SUPERCHECK_TOKEN) or the OS keychain, never in config files.",
|
|
1070
|
+
3 /* ConfigError */
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
async function loadConfig(options = {}) {
|
|
1076
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1077
|
+
const configPath = resolveConfigPath(cwd, options.configPath);
|
|
1078
|
+
if (!configPath) {
|
|
1079
|
+
throw new CLIError(
|
|
1080
|
+
"No supercheck.config.ts found. Run `supercheck init` to create one.",
|
|
1081
|
+
3 /* ConfigError */
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
let config = await loadConfigFile(configPath);
|
|
1085
|
+
const localConfigPath = resolveLocalConfigPath(cwd);
|
|
1086
|
+
if (localConfigPath) {
|
|
1087
|
+
const localConfig = await loadConfigFile(localConfigPath);
|
|
1088
|
+
config = deepmerge(config, localConfig);
|
|
1089
|
+
}
|
|
1090
|
+
config = applyEnvOverrides(config);
|
|
1091
|
+
validateNoSecrets(config);
|
|
1092
|
+
const parsed = supercheckConfigSchema.safeParse(config);
|
|
1093
|
+
if (!parsed.success) {
|
|
1094
|
+
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
1095
|
+
throw new CLIError(
|
|
1096
|
+
`Invalid configuration:
|
|
1097
|
+
${issues}`,
|
|
1098
|
+
3 /* ConfigError */
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
return {
|
|
1102
|
+
config: parsed.data,
|
|
1103
|
+
configPath
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
async function tryLoadConfig(options = {}) {
|
|
1107
|
+
try {
|
|
1108
|
+
return await loadConfig(options);
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
if (err instanceof CLIError && err.message.includes("No supercheck.config.ts found")) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
throw err;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// src/output/formatter.ts
|
|
1118
|
+
import Table from "cli-table3";
|
|
1119
|
+
import pc2 from "picocolors";
|
|
1120
|
+
var currentFormat = "table";
|
|
1121
|
+
function setOutputFormat(format) {
|
|
1122
|
+
currentFormat = format;
|
|
1123
|
+
}
|
|
1124
|
+
function getOutputFormat() {
|
|
1125
|
+
return currentFormat;
|
|
1126
|
+
}
|
|
1127
|
+
function output(data, options) {
|
|
1128
|
+
switch (currentFormat) {
|
|
1129
|
+
case "json":
|
|
1130
|
+
logger.output(JSON.stringify(data, null, 2));
|
|
1131
|
+
break;
|
|
1132
|
+
case "quiet":
|
|
1133
|
+
if (Array.isArray(data)) {
|
|
1134
|
+
for (const item of data) {
|
|
1135
|
+
if ("id" in item) logger.output(String(item.id));
|
|
1136
|
+
}
|
|
1137
|
+
} else if ("id" in data) {
|
|
1138
|
+
logger.output(String(data.id));
|
|
1139
|
+
}
|
|
1140
|
+
break;
|
|
1141
|
+
case "table":
|
|
1142
|
+
default:
|
|
1143
|
+
outputTable(data, options?.columns);
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function outputTable(data, columns) {
|
|
1148
|
+
if (data === void 0 || data === null) {
|
|
1149
|
+
logger.info("No results found.");
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
const rawItems = Array.isArray(data) ? data : [data];
|
|
1153
|
+
const items = rawItems.filter((item) => item !== void 0 && item !== null);
|
|
1154
|
+
if (items.length === 0) {
|
|
1155
|
+
logger.info("No results found.");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
const cols = columns ?? Object.keys(items[0]).map((key) => ({
|
|
1159
|
+
key,
|
|
1160
|
+
header: key.charAt(0).toUpperCase() + key.slice(1)
|
|
1161
|
+
}));
|
|
1162
|
+
const table = new Table({
|
|
1163
|
+
head: cols.map((c) => c.header),
|
|
1164
|
+
style: {
|
|
1165
|
+
head: ["cyan"],
|
|
1166
|
+
border: ["gray"]
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
for (const item of items) {
|
|
1170
|
+
table.push(cols.map((c) => formatValue(item?.[c.key], c, item)));
|
|
1171
|
+
}
|
|
1172
|
+
logger.output(table.toString());
|
|
1173
|
+
}
|
|
1174
|
+
var STATUS_COLORS = {
|
|
1175
|
+
// Success states (green)
|
|
1176
|
+
up: "success",
|
|
1177
|
+
ok: "success",
|
|
1178
|
+
success: "success",
|
|
1179
|
+
passed: "success",
|
|
1180
|
+
active: "success",
|
|
1181
|
+
sent: "success",
|
|
1182
|
+
enabled: "success",
|
|
1183
|
+
healthy: "success",
|
|
1184
|
+
running: "success",
|
|
1185
|
+
completed: "success",
|
|
1186
|
+
// Error states (red)
|
|
1187
|
+
down: "error",
|
|
1188
|
+
failed: "error",
|
|
1189
|
+
error: "error",
|
|
1190
|
+
blocked: "error",
|
|
1191
|
+
unhealthy: "error",
|
|
1192
|
+
cancelled: "error",
|
|
1193
|
+
canceled: "error",
|
|
1194
|
+
// Warning states (yellow)
|
|
1195
|
+
paused: "warning",
|
|
1196
|
+
pending: "warning",
|
|
1197
|
+
disabled: "warning",
|
|
1198
|
+
degraded: "warning",
|
|
1199
|
+
unknown: "warning",
|
|
1200
|
+
queued: "warning",
|
|
1201
|
+
warning: "warning",
|
|
1202
|
+
skipped: "warning",
|
|
1203
|
+
inactive: "warning"
|
|
1204
|
+
};
|
|
1205
|
+
function formatStatus(status) {
|
|
1206
|
+
const normalizedStatus = status.toLowerCase();
|
|
1207
|
+
const colorType = STATUS_COLORS[normalizedStatus];
|
|
1208
|
+
if (!colorType) {
|
|
1209
|
+
return status;
|
|
1210
|
+
}
|
|
1211
|
+
switch (colorType) {
|
|
1212
|
+
case "success":
|
|
1213
|
+
return pc2.green(`\u25CF ${status}`);
|
|
1214
|
+
case "error":
|
|
1215
|
+
return pc2.red(`\u25CF ${status}`);
|
|
1216
|
+
case "warning":
|
|
1217
|
+
return pc2.yellow(`\u25CF ${status}`);
|
|
1218
|
+
default:
|
|
1219
|
+
return status;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
function formatValue(value, column, row) {
|
|
1223
|
+
if (column?.format && row) {
|
|
1224
|
+
return column.format(value, row);
|
|
1225
|
+
}
|
|
1226
|
+
if (value === null || value === void 0) return pc2.dim("-");
|
|
1227
|
+
if (typeof value === "boolean") {
|
|
1228
|
+
return value ? pc2.green("\u2713") : pc2.red("\u2717");
|
|
1229
|
+
}
|
|
1230
|
+
if (value instanceof Date) return formatDate(value);
|
|
1231
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
1232
|
+
if (typeof value === "number") {
|
|
1233
|
+
const dateForNumber = maybeFormatDate(value, column?.key);
|
|
1234
|
+
if (dateForNumber) return dateForNumber;
|
|
1235
|
+
return String(value);
|
|
1236
|
+
}
|
|
1237
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
1238
|
+
const strValue = String(value);
|
|
1239
|
+
const dateForString = maybeFormatDate(strValue, column?.key);
|
|
1240
|
+
if (dateForString) return dateForString;
|
|
1241
|
+
if (STATUS_COLORS[strValue.toLowerCase()]) {
|
|
1242
|
+
return formatStatus(strValue);
|
|
1243
|
+
}
|
|
1244
|
+
return strValue;
|
|
1245
|
+
}
|
|
1246
|
+
var DATE_KEY_PATTERN = /(At|Date|Time|Timestamp)$/i;
|
|
1247
|
+
function maybeFormatDate(value, key) {
|
|
1248
|
+
if (!key && typeof value !== "string") return null;
|
|
1249
|
+
if (key && !DATE_KEY_PATTERN.test(key) && typeof value !== "string") return null;
|
|
1250
|
+
const date = new Date(value);
|
|
1251
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
1252
|
+
if (typeof value === "string") {
|
|
1253
|
+
const isIsoLike = value.includes("T") || value.endsWith("Z") || /[+-]\d{2}:?\d{2}$/.test(value);
|
|
1254
|
+
if (!isIsoLike) return null;
|
|
1255
|
+
return formatDate(date);
|
|
1256
|
+
}
|
|
1257
|
+
return formatDate(date);
|
|
1258
|
+
}
|
|
1259
|
+
function formatDate(date) {
|
|
1260
|
+
return date.toISOString().replace("T", " ").replace("Z", "");
|
|
1261
|
+
}
|
|
1262
|
+
function outputDetail(data) {
|
|
1263
|
+
if (currentFormat === "json") {
|
|
1264
|
+
logger.output(JSON.stringify(data, null, 2));
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (currentFormat === "quiet") {
|
|
1268
|
+
if ("id" in data) logger.output(String(data.id));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const keys = Object.keys(data);
|
|
1272
|
+
if (keys.length === 0) {
|
|
1273
|
+
logger.info("No details available.");
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const maxKeyLen = Math.max(...keys.map((k) => k.length));
|
|
1277
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1278
|
+
const label = key.padEnd(maxKeyLen + 2);
|
|
1279
|
+
logger.output(` ${label}${formatValue(value, { key, header: key }, data)}`);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/commands/health.ts
|
|
1284
|
+
var healthCommand = new Command2("health").description("Check Supercheck API health").option("--url <url>", "Supercheck API URL").action(async (options) => {
|
|
1285
|
+
const configResult = await tryLoadConfig();
|
|
1286
|
+
const baseUrl = options.url ?? getStoredBaseUrl() ?? configResult?.config.api?.baseUrl ?? "https://app.supercheck.io";
|
|
1287
|
+
const client = getApiClient({ baseUrl });
|
|
1288
|
+
try {
|
|
1289
|
+
const { data } = await client.get("/api/health");
|
|
1290
|
+
const checks = data.checks ?? {};
|
|
1291
|
+
const displayData = {
|
|
1292
|
+
status: data.status,
|
|
1293
|
+
database: checks.database?.status ?? "n/a",
|
|
1294
|
+
redis: checks.redis?.status ?? "n/a",
|
|
1295
|
+
s3: checks.s3?.status ?? "n/a",
|
|
1296
|
+
latencyMs: data.latencyMs
|
|
1297
|
+
};
|
|
1298
|
+
output(displayData, {
|
|
1299
|
+
columns: [
|
|
1300
|
+
{ key: "status", header: "Status" },
|
|
1301
|
+
{ key: "database", header: "Database" },
|
|
1302
|
+
{ key: "redis", header: "Redis" },
|
|
1303
|
+
{ key: "s3", header: "S3" },
|
|
1304
|
+
{ key: "latencyMs", header: "Latency (ms)" }
|
|
1305
|
+
]
|
|
1306
|
+
});
|
|
1307
|
+
if (data.status === "ok") {
|
|
1308
|
+
logger.success(`API is healthy at ${baseUrl}`);
|
|
1309
|
+
} else {
|
|
1310
|
+
throw new CLIError(`API reports degraded status at ${baseUrl}`, 4 /* ApiError */);
|
|
1311
|
+
}
|
|
1312
|
+
} catch (err) {
|
|
1313
|
+
if (err instanceof CLIError) throw err;
|
|
1314
|
+
logger.error(`Cannot reach API at ${baseUrl}`);
|
|
1315
|
+
if (err instanceof Error) {
|
|
1316
|
+
logger.debug(err.message);
|
|
1317
|
+
}
|
|
1318
|
+
throw new CLIError(`Cannot reach API at ${baseUrl}`, 4 /* ApiError */);
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
var locationsCommand = new Command2("locations").description("List available execution locations").action(async () => {
|
|
1322
|
+
const configResult = await tryLoadConfig();
|
|
1323
|
+
const baseUrl = getStoredBaseUrl() ?? configResult?.config.api?.baseUrl ?? "https://app.supercheck.io";
|
|
1324
|
+
const client = getApiClient({ baseUrl });
|
|
1325
|
+
const { data } = await client.get("/api/locations");
|
|
1326
|
+
const locations = "locations" in data ? data.locations : data.data ?? [];
|
|
1327
|
+
output(locations, {
|
|
1328
|
+
columns: [
|
|
1329
|
+
{ key: "code", header: "Code" },
|
|
1330
|
+
{ key: "name", header: "Name" },
|
|
1331
|
+
{ key: "region", header: "Region" }
|
|
1332
|
+
]
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
// src/commands/config.ts
|
|
1337
|
+
import { Command as Command3 } from "commander";
|
|
1338
|
+
var configCommand = new Command3("config").description("Configuration management");
|
|
1339
|
+
configCommand.command("validate").description("Validate supercheck.config.ts").action(async () => {
|
|
1340
|
+
const { config, configPath } = await loadConfig();
|
|
1341
|
+
logger.success(`Valid configuration loaded from ${configPath}`);
|
|
1342
|
+
logger.info(` Organization: ${config.project.organization}`);
|
|
1343
|
+
logger.info(` Project: ${config.project.project}`);
|
|
1344
|
+
});
|
|
1345
|
+
configCommand.command("print").description("Print the resolved configuration").action(async () => {
|
|
1346
|
+
const { config } = await loadConfig();
|
|
1347
|
+
output(config);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// src/commands/init.ts
|
|
1351
|
+
import { Command as Command4 } from "commander";
|
|
1352
|
+
import { existsSync as existsSync2, mkdirSync, writeFileSync, readFileSync as readFileSync2, appendFileSync } from "fs";
|
|
1353
|
+
import { resolve as resolve3, basename as basename2 } from "path";
|
|
1354
|
+
|
|
1355
|
+
// src/utils/spinner.ts
|
|
1356
|
+
import ora from "ora";
|
|
1357
|
+
function createSpinner(text) {
|
|
1358
|
+
const format = getOutputFormat();
|
|
1359
|
+
const isInteractive = format === "table";
|
|
1360
|
+
const spinner = ora({
|
|
1361
|
+
text,
|
|
1362
|
+
// Disable spinner in non-interactive modes
|
|
1363
|
+
isSilent: !isInteractive,
|
|
1364
|
+
// Use dots style for a clean look
|
|
1365
|
+
spinner: "dots"
|
|
1366
|
+
});
|
|
1367
|
+
return spinner;
|
|
1368
|
+
}
|
|
1369
|
+
async function withSpinner(text, fn, options) {
|
|
1370
|
+
const spinner = createSpinner(text);
|
|
1371
|
+
spinner.start();
|
|
1372
|
+
try {
|
|
1373
|
+
const result = await fn();
|
|
1374
|
+
const successMessage = typeof options?.successText === "function" ? options.successText(result) : options?.successText ?? text.replace(/\.\.\.?$/, "");
|
|
1375
|
+
spinner.succeed(successMessage);
|
|
1376
|
+
return result;
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
const failMessage = options?.failText ?? text.replace(/\.\.\.?$/, " failed");
|
|
1379
|
+
spinner.fail(failMessage);
|
|
1380
|
+
throw error;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/commands/init.ts
|
|
1385
|
+
var CONFIG_TEMPLATE = `import { defineConfig } from '@supercheck/cli'
|
|
1386
|
+
|
|
1387
|
+
export default defineConfig({
|
|
1388
|
+
schemaVersion: '1.0',
|
|
1389
|
+
project: {
|
|
1390
|
+
organization: 'my-org',
|
|
1391
|
+
project: 'my-project',
|
|
1392
|
+
},
|
|
1393
|
+
api: {
|
|
1394
|
+
baseUrl: process.env.SUPERCHECK_URL ?? 'https://app.supercheck.io',
|
|
1395
|
+
},
|
|
1396
|
+
tests: {
|
|
1397
|
+
playwright: {
|
|
1398
|
+
testMatch: '_supercheck_/tests/**/*.pw.ts',
|
|
1399
|
+
browser: 'chromium',
|
|
1400
|
+
},
|
|
1401
|
+
k6: {
|
|
1402
|
+
testMatch: '_supercheck_/tests/**/*.k6.ts',
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
})
|
|
1406
|
+
`;
|
|
1407
|
+
var EXAMPLE_PW_TEST = `import { test, expect } from '@playwright/test'
|
|
1408
|
+
|
|
1409
|
+
test('homepage loads successfully', async ({ page }) => {
|
|
1410
|
+
await page.goto('https://example.com')
|
|
1411
|
+
await expect(page).toHaveTitle(/Example/)
|
|
1412
|
+
})
|
|
1413
|
+
`;
|
|
1414
|
+
var EXAMPLE_K6_TEST = `import http from 'k6/http'
|
|
1415
|
+
import { check, sleep } from 'k6'
|
|
1416
|
+
|
|
1417
|
+
export const options = {
|
|
1418
|
+
vus: 10,
|
|
1419
|
+
duration: '30s',
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
export default function () {
|
|
1423
|
+
const res = http.get('https://example.com')
|
|
1424
|
+
check(res, {
|
|
1425
|
+
'status is 200': (r) => r.status === 200,
|
|
1426
|
+
'response time < 500ms': (r) => r.timings.duration < 500,
|
|
1427
|
+
})
|
|
1428
|
+
sleep(1)
|
|
1429
|
+
}
|
|
1430
|
+
`;
|
|
1431
|
+
var SUPERCHECK_TSCONFIG = `{
|
|
1432
|
+
"compilerOptions": {
|
|
1433
|
+
"target": "ES2022",
|
|
1434
|
+
"module": "ESNext",
|
|
1435
|
+
"moduleResolution": "bundler",
|
|
1436
|
+
"esModuleInterop": true,
|
|
1437
|
+
"strict": true,
|
|
1438
|
+
"skipLibCheck": true,
|
|
1439
|
+
"resolveJsonModule": true,
|
|
1440
|
+
"isolatedModules": true,
|
|
1441
|
+
"noEmit": true,
|
|
1442
|
+
"types": ["node"]
|
|
1443
|
+
},
|
|
1444
|
+
"include": [
|
|
1445
|
+
"supercheck.config.ts",
|
|
1446
|
+
"supercheck.config.local.ts",
|
|
1447
|
+
"_supercheck_/**/*.ts"
|
|
1448
|
+
]
|
|
1449
|
+
}
|
|
1450
|
+
`;
|
|
1451
|
+
var GITIGNORE_ADDITIONS = `
|
|
1452
|
+
# Supercheck CLI
|
|
1453
|
+
supercheck.config.local.ts
|
|
1454
|
+
supercheck.config.local.js
|
|
1455
|
+
supercheck.config.local.mjs
|
|
1456
|
+
`;
|
|
1457
|
+
function detectPackageManager(cwd) {
|
|
1458
|
+
if (existsSync2(resolve3(cwd, "bun.lockb")) || existsSync2(resolve3(cwd, "bun.lock"))) return "bun";
|
|
1459
|
+
if (existsSync2(resolve3(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1460
|
+
if (existsSync2(resolve3(cwd, "yarn.lock"))) return "yarn";
|
|
1461
|
+
return "npm";
|
|
1462
|
+
}
|
|
1463
|
+
function getInstallCommand(pm, packages) {
|
|
1464
|
+
const pkgs = packages.join(" ");
|
|
1465
|
+
switch (pm) {
|
|
1466
|
+
case "bun":
|
|
1467
|
+
return `bun add -d ${pkgs}`;
|
|
1468
|
+
case "pnpm":
|
|
1469
|
+
return `pnpm add -D ${pkgs}`;
|
|
1470
|
+
case "yarn":
|
|
1471
|
+
return `yarn add -D ${pkgs}`;
|
|
1472
|
+
case "npm":
|
|
1473
|
+
return `npm install --save-dev ${pkgs}`;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function ensurePackageJson(cwd) {
|
|
1477
|
+
const pkgPath = resolve3(cwd, "package.json");
|
|
1478
|
+
if (existsSync2(pkgPath)) return false;
|
|
1479
|
+
const projectName = basename2(cwd).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1480
|
+
const minimalPkg = {
|
|
1481
|
+
name: projectName,
|
|
1482
|
+
private: true,
|
|
1483
|
+
type: "module",
|
|
1484
|
+
scripts: {
|
|
1485
|
+
"supercheck:deploy": "supercheck deploy",
|
|
1486
|
+
"supercheck:diff": "supercheck diff",
|
|
1487
|
+
"supercheck:pull": "supercheck pull"
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
writeFileSync(pkgPath, JSON.stringify(minimalPkg, null, 2) + "\n", "utf-8");
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
async function installDependencies(cwd, pm, opts) {
|
|
1494
|
+
if (opts.skipInstall) {
|
|
1495
|
+
logger.info("Skipping dependency installation (--skip-install)");
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const devDeps = ["@supercheck/cli", "typescript", "@types/node"];
|
|
1499
|
+
if (opts.playwright) {
|
|
1500
|
+
devDeps.push("@playwright/test");
|
|
1501
|
+
}
|
|
1502
|
+
const cmd = getInstallCommand(pm, devDeps);
|
|
1503
|
+
logger.info(`Installing dependencies with ${pm}...`);
|
|
1504
|
+
logger.debug(`Running: ${cmd}`);
|
|
1505
|
+
await withSpinner(
|
|
1506
|
+
`Installing ${devDeps.length} packages...`,
|
|
1507
|
+
async () => {
|
|
1508
|
+
const { execSync } = await import("child_process");
|
|
1509
|
+
try {
|
|
1510
|
+
execSync(cmd, {
|
|
1511
|
+
cwd,
|
|
1512
|
+
stdio: "pipe",
|
|
1513
|
+
timeout: 12e4,
|
|
1514
|
+
// 2 minute timeout
|
|
1515
|
+
env: { ...process.env, NODE_ENV: "development" }
|
|
1516
|
+
});
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1519
|
+
throw new CLIError(
|
|
1520
|
+
`Failed to install dependencies. Run manually:
|
|
1521
|
+
${cmd}
|
|
1522
|
+
|
|
1523
|
+
Error: ${message}`,
|
|
1524
|
+
1 /* GeneralError */
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
},
|
|
1528
|
+
{ successText: "Dependencies installed" }
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
var initCommand = new Command4("init").description("Initialize a new Supercheck project with config and example tests").option("--force", "Overwrite existing config file").option("--skip-install", "Skip automatic dependency installation").option("--skip-examples", "Skip creating example test files").option("--pm <manager>", "Package manager to use (npm, yarn, pnpm, bun)").action(async (options) => {
|
|
1532
|
+
const cwd = process.cwd();
|
|
1533
|
+
const configPath = resolve3(cwd, "supercheck.config.ts");
|
|
1534
|
+
if (existsSync2(configPath) && !options.force) {
|
|
1535
|
+
throw new CLIError(
|
|
1536
|
+
"supercheck.config.ts already exists. Use --force to overwrite.",
|
|
1537
|
+
3 /* ConfigError */
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
logger.newline();
|
|
1541
|
+
logger.header("Initializing Supercheck project...");
|
|
1542
|
+
logger.newline();
|
|
1543
|
+
writeFileSync(configPath, CONFIG_TEMPLATE, "utf-8");
|
|
1544
|
+
logger.success("Created supercheck.config.ts");
|
|
1545
|
+
const tsconfigPath = resolve3(cwd, "tsconfig.supercheck.json");
|
|
1546
|
+
if (!existsSync2(tsconfigPath) || options.force) {
|
|
1547
|
+
writeFileSync(tsconfigPath, SUPERCHECK_TSCONFIG, "utf-8");
|
|
1548
|
+
logger.success("Created tsconfig.supercheck.json (IDE IntelliSense)");
|
|
1549
|
+
}
|
|
1550
|
+
const dirs = [
|
|
1551
|
+
"_supercheck_/tests",
|
|
1552
|
+
"_supercheck_/monitors",
|
|
1553
|
+
"_supercheck_/status-pages"
|
|
1554
|
+
];
|
|
1555
|
+
for (const dir of dirs) {
|
|
1556
|
+
const dirPath = resolve3(cwd, dir);
|
|
1557
|
+
if (!existsSync2(dirPath)) {
|
|
1558
|
+
mkdirSync(dirPath, { recursive: true });
|
|
1559
|
+
}
|
|
1560
|
+
if (dir !== "_supercheck_/tests") {
|
|
1561
|
+
const gitkeepPath = resolve3(dirPath, ".gitkeep");
|
|
1562
|
+
if (!existsSync2(gitkeepPath)) {
|
|
1563
|
+
writeFileSync(gitkeepPath, "", "utf-8");
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
logger.success("Created _supercheck_/ directory structure");
|
|
1568
|
+
if (!options.skipExamples) {
|
|
1569
|
+
const pwTestPath = resolve3(cwd, "_supercheck_/tests/homepage.pw.ts");
|
|
1570
|
+
if (!existsSync2(pwTestPath)) {
|
|
1571
|
+
writeFileSync(pwTestPath, EXAMPLE_PW_TEST, "utf-8");
|
|
1572
|
+
logger.success("Created _supercheck_/tests/homepage.pw.ts (Playwright example)");
|
|
1573
|
+
}
|
|
1574
|
+
const k6TestPath = resolve3(cwd, "_supercheck_/tests/load-test.k6.ts");
|
|
1575
|
+
if (!existsSync2(k6TestPath)) {
|
|
1576
|
+
writeFileSync(k6TestPath, EXAMPLE_K6_TEST, "utf-8");
|
|
1577
|
+
logger.success("Created _supercheck_/tests/load-test.k6.ts (k6 example)");
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
const gitignorePath = resolve3(cwd, ".gitignore");
|
|
1581
|
+
if (existsSync2(gitignorePath)) {
|
|
1582
|
+
const content = readFileSync2(gitignorePath, "utf-8");
|
|
1583
|
+
if (!content.includes("supercheck.config.local")) {
|
|
1584
|
+
appendFileSync(gitignorePath, GITIGNORE_ADDITIONS, "utf-8");
|
|
1585
|
+
logger.success("Updated .gitignore with Supercheck entries");
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const pm = options.pm ?? detectPackageManager(cwd);
|
|
1589
|
+
logger.debug(`Detected package manager: ${pm}`);
|
|
1590
|
+
const createdPkg = ensurePackageJson(cwd);
|
|
1591
|
+
if (createdPkg) {
|
|
1592
|
+
logger.success("Created package.json");
|
|
1593
|
+
}
|
|
1594
|
+
await installDependencies(cwd, pm, {
|
|
1595
|
+
playwright: !options.skipExamples,
|
|
1596
|
+
skipInstall: options.skipInstall ?? false
|
|
1597
|
+
});
|
|
1598
|
+
logger.newline();
|
|
1599
|
+
logger.header("Supercheck project initialized!");
|
|
1600
|
+
logger.newline();
|
|
1601
|
+
logger.info("Next steps:");
|
|
1602
|
+
logger.info(" 1. Edit supercheck.config.ts with your org/project details");
|
|
1603
|
+
logger.info(" 2. Run `supercheck login --token <your-token>` to authenticate");
|
|
1604
|
+
logger.info(" 3. Write tests in _supercheck_/tests/ (*.pw.ts for Playwright, *.k6.ts for k6)");
|
|
1605
|
+
logger.info(" 4. Run `supercheck diff` to preview changes against the cloud");
|
|
1606
|
+
logger.info(" 5. Run `supercheck deploy` to push to Supercheck");
|
|
1607
|
+
logger.info(" 6. Run `supercheck pull` to sync cloud resources locally");
|
|
1608
|
+
logger.newline();
|
|
1609
|
+
logger.info("Useful commands:");
|
|
1610
|
+
logger.info(" supercheck pull Pull tests & config from the cloud");
|
|
1611
|
+
logger.info(" supercheck diff Preview local vs remote differences");
|
|
1612
|
+
logger.info(" supercheck deploy Push local config to Supercheck");
|
|
1613
|
+
logger.info(" supercheck whoami Check authentication status");
|
|
1614
|
+
logger.newline();
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
// src/commands/jobs.ts
|
|
1618
|
+
import { Command as Command5 } from "commander";
|
|
1619
|
+
|
|
1620
|
+
// src/api/authenticated-client.ts
|
|
1621
|
+
function createAuthenticatedClient() {
|
|
1622
|
+
const token = requireAuth();
|
|
1623
|
+
const baseUrl = getStoredBaseUrl();
|
|
1624
|
+
return getApiClient({ token, baseUrl: baseUrl ?? void 0 });
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// src/utils/validation.ts
|
|
1628
|
+
function parseIntStrict(value, name, opts) {
|
|
1629
|
+
const parsed = parseInt(value, 10);
|
|
1630
|
+
if (!Number.isFinite(parsed)) {
|
|
1631
|
+
throw new CLIError(
|
|
1632
|
+
`Invalid value for ${name}: "${value}" is not a valid integer.`,
|
|
1633
|
+
3 /* ConfigError */
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
if (opts?.min !== void 0 && parsed < opts.min) {
|
|
1637
|
+
throw new CLIError(
|
|
1638
|
+
`Invalid value for ${name}: ${parsed} is below the minimum of ${opts.min}.`,
|
|
1639
|
+
3 /* ConfigError */
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
if (opts?.max !== void 0 && parsed > opts.max) {
|
|
1643
|
+
throw new CLIError(
|
|
1644
|
+
`Invalid value for ${name}: ${parsed} exceeds the maximum of ${opts.max}.`,
|
|
1645
|
+
3 /* ConfigError */
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
return parsed;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// src/commands/jobs.ts
|
|
1652
|
+
var jobCommand = new Command5("job").description("Manage jobs");
|
|
1653
|
+
jobCommand.command("list").description("List all jobs").option("--page <page>", "Page number", "1").option("--limit <limit>", "Items per page", "50").action(async (options) => {
|
|
1654
|
+
const client = createAuthenticatedClient();
|
|
1655
|
+
const { data } = await client.get(
|
|
1656
|
+
"/api/jobs",
|
|
1657
|
+
{ page: options.page, limit: options.limit }
|
|
1658
|
+
);
|
|
1659
|
+
output(data.data, {
|
|
1660
|
+
columns: [
|
|
1661
|
+
{ key: "id", header: "ID" },
|
|
1662
|
+
{ key: "name", header: "Name" },
|
|
1663
|
+
{ key: "status", header: "Status" },
|
|
1664
|
+
{ key: "cronSchedule", header: "Schedule" },
|
|
1665
|
+
{ key: "createdAt", header: "Created" }
|
|
1666
|
+
]
|
|
1667
|
+
});
|
|
1668
|
+
if (data.pagination) {
|
|
1669
|
+
logger.info(
|
|
1670
|
+
`
|
|
1671
|
+
Page ${data.pagination.page}/${data.pagination.totalPages} (${data.pagination.total} total)`
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
var keysCommand = jobCommand.command("keys").description("Manage job trigger keys");
|
|
1676
|
+
keysCommand.argument("<jobId>", "Job ID").action(async (jobId) => {
|
|
1677
|
+
const client = createAuthenticatedClient();
|
|
1678
|
+
const { data } = await client.get(
|
|
1679
|
+
`/api/jobs/${jobId}/api-keys`
|
|
1680
|
+
);
|
|
1681
|
+
output(data.apiKeys ?? [], {
|
|
1682
|
+
columns: [
|
|
1683
|
+
{ key: "id", header: "ID" },
|
|
1684
|
+
{ key: "name", header: "Name" },
|
|
1685
|
+
{ key: "start", header: "Prefix" },
|
|
1686
|
+
{ key: "enabled", header: "Enabled" },
|
|
1687
|
+
{ key: "expiresAt", header: "Expires" },
|
|
1688
|
+
{ key: "lastRequest", header: "Last used" }
|
|
1689
|
+
]
|
|
1690
|
+
});
|
|
1691
|
+
});
|
|
1692
|
+
keysCommand.command("create").description("Create a new trigger key for a job").argument("<jobId>", "Job ID").requiredOption("--name <name>", "Key name").option("--expires-in <seconds>", "Expiry in seconds (min 60)").action(async (jobId, options) => {
|
|
1693
|
+
const client = createAuthenticatedClient();
|
|
1694
|
+
const body = { name: options.name };
|
|
1695
|
+
if (options.expiresIn !== void 0) {
|
|
1696
|
+
body.expiresIn = parseIntStrict(options.expiresIn, "--expires-in", { min: 60 });
|
|
1697
|
+
}
|
|
1698
|
+
const { data } = await client.post(
|
|
1699
|
+
`/api/jobs/${jobId}/api-keys`,
|
|
1700
|
+
body
|
|
1701
|
+
);
|
|
1702
|
+
logger.success("Trigger key created");
|
|
1703
|
+
logger.warn("Save the `key` value now. It will only be shown once.");
|
|
1704
|
+
outputDetail(data.apiKey);
|
|
1705
|
+
});
|
|
1706
|
+
keysCommand.command("delete").description("Revoke a trigger key").argument("<jobId>", "Job ID").argument("<keyId>", "Key ID").action(async (jobId, keyId) => {
|
|
1707
|
+
const client = createAuthenticatedClient();
|
|
1708
|
+
await client.delete(`/api/jobs/${jobId}/api-keys/${keyId}`);
|
|
1709
|
+
logger.success(`Trigger key ${keyId} revoked`);
|
|
1710
|
+
});
|
|
1711
|
+
jobCommand.command("get <id>").description("Get job details").action(async (id) => {
|
|
1712
|
+
const client = createAuthenticatedClient();
|
|
1713
|
+
const { data } = await client.get(`/api/jobs/${id}`);
|
|
1714
|
+
outputDetail(data);
|
|
1715
|
+
});
|
|
1716
|
+
jobCommand.command("create").description("Create a new job").requiredOption("--name <name>", "Job name").option("--description <description>", "Job description", "").option("--schedule <cron>", "Cron schedule expression").option("--timeout <seconds>", "Timeout in seconds", "300").option("--retries <count>", "Retry count on failure", "0").action(async (options) => {
|
|
1717
|
+
const client = createAuthenticatedClient();
|
|
1718
|
+
const body = {
|
|
1719
|
+
name: options.name,
|
|
1720
|
+
description: options.description,
|
|
1721
|
+
timeoutSeconds: parseIntStrict(options.timeout, "--timeout", { min: 1 }),
|
|
1722
|
+
retryCount: parseIntStrict(options.retries, "--retries", { min: 0 }),
|
|
1723
|
+
config: {},
|
|
1724
|
+
tests: []
|
|
1725
|
+
};
|
|
1726
|
+
if (options.schedule) body.cronSchedule = options.schedule;
|
|
1727
|
+
const { data } = await client.post("/api/jobs", body);
|
|
1728
|
+
logger.success(`Job "${options.name}" created (${data.id})`);
|
|
1729
|
+
outputDetail(data);
|
|
1730
|
+
});
|
|
1731
|
+
jobCommand.command("update <id>").description("Update job configuration").option("--name <name>", "Job name").option("--description <description>", "Job description").option("--schedule <cron>", "Cron schedule expression").option("--timeout <seconds>", "Timeout in seconds").option("--retries <count>", "Retry count on failure").option("--status <status>", "Job status (active, paused)").action(async (id, options) => {
|
|
1732
|
+
const client = createAuthenticatedClient();
|
|
1733
|
+
const body = {};
|
|
1734
|
+
if (options.name !== void 0) body.name = options.name;
|
|
1735
|
+
if (options.description !== void 0) body.description = options.description;
|
|
1736
|
+
if (options.schedule !== void 0) body.cronSchedule = options.schedule;
|
|
1737
|
+
if (options.timeout !== void 0) body.timeoutSeconds = parseIntStrict(options.timeout, "--timeout", { min: 1 });
|
|
1738
|
+
if (options.retries !== void 0) body.retryCount = parseIntStrict(options.retries, "--retries", { min: 0 });
|
|
1739
|
+
if (options.status !== void 0) body.status = options.status;
|
|
1740
|
+
if (Object.keys(body).length === 0) {
|
|
1741
|
+
logger.warn("No fields to update. Use --name, --description, --schedule, --timeout, --retries, or --status.");
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
const { data } = await client.patch(`/api/jobs/${id}`, body);
|
|
1745
|
+
logger.success(`Job ${id} updated`);
|
|
1746
|
+
outputDetail(data);
|
|
1747
|
+
});
|
|
1748
|
+
jobCommand.command("delete <id>").description("Delete a job").option("--force", "Skip confirmation").action(async (id, options) => {
|
|
1749
|
+
if (!options.force) {
|
|
1750
|
+
const { createInterface } = await import("readline");
|
|
1751
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1752
|
+
const answer = await new Promise((resolve5) => {
|
|
1753
|
+
rl.question(`Are you sure you want to delete job ${id}? (y/N) `, resolve5);
|
|
1754
|
+
});
|
|
1755
|
+
rl.close();
|
|
1756
|
+
if (answer.toLowerCase() !== "y") {
|
|
1757
|
+
logger.info("Aborted");
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
const client = createAuthenticatedClient();
|
|
1762
|
+
await client.delete(`/api/jobs/${id}`);
|
|
1763
|
+
logger.success(`Job ${id} deleted`);
|
|
1764
|
+
});
|
|
1765
|
+
jobCommand.command("run").description("Run a job immediately").requiredOption("--id <id>", "Job ID to run").action(async (options) => {
|
|
1766
|
+
const client = createAuthenticatedClient();
|
|
1767
|
+
const { data: jobData } = await client.get(
|
|
1768
|
+
`/api/jobs/${options.id}`
|
|
1769
|
+
);
|
|
1770
|
+
const tests = Array.isArray(jobData.tests) ? jobData.tests : [];
|
|
1771
|
+
if (tests.length === 0) {
|
|
1772
|
+
throw new CLIError(
|
|
1773
|
+
`Job ${options.id} has no tests. Add tests to the job before running it.`,
|
|
1774
|
+
1 /* GeneralError */
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
const payloadTests = tests.map((test) => ({
|
|
1778
|
+
id: test.id,
|
|
1779
|
+
name: test.name ?? test.title ?? ""
|
|
1780
|
+
}));
|
|
1781
|
+
logger.info(`Running job ${options.id}...`);
|
|
1782
|
+
const { data } = await client.post(
|
|
1783
|
+
"/api/jobs/run",
|
|
1784
|
+
{ jobId: options.id, tests: payloadTests, trigger: "manual" }
|
|
1785
|
+
);
|
|
1786
|
+
logger.success(`Job started. Run ID: ${data.runId}`);
|
|
1787
|
+
outputDetail(data);
|
|
1788
|
+
});
|
|
1789
|
+
jobCommand.command("trigger <id>").description("Trigger a job run").option("--wait", "Wait for the run to complete").option("--timeout <seconds>", "Maximum wait time in seconds", "300").action(async (id, options) => {
|
|
1790
|
+
const triggerClient = new ApiClient({
|
|
1791
|
+
token: requireTriggerKey(),
|
|
1792
|
+
baseUrl: getStoredBaseUrl() ?? void 0
|
|
1793
|
+
});
|
|
1794
|
+
logger.info(`Triggering job ${id}...`);
|
|
1795
|
+
const { data } = await triggerClient.post(
|
|
1796
|
+
`/api/jobs/${id}/trigger`
|
|
1797
|
+
);
|
|
1798
|
+
logger.success(`Job triggered. Run ID: ${data.runId}`);
|
|
1799
|
+
if (options.wait) {
|
|
1800
|
+
let statusClient;
|
|
1801
|
+
try {
|
|
1802
|
+
statusClient = createAuthenticatedClient();
|
|
1803
|
+
} catch {
|
|
1804
|
+
throw new CLIError(
|
|
1805
|
+
"Waiting for completion requires a CLI token. Set SUPERCHECK_TOKEN (sck_live_* or sck_test_).",
|
|
1806
|
+
2 /* AuthError */
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
logger.info("Waiting for run to complete...");
|
|
1810
|
+
const timeoutMs = parseIntStrict(options.timeout, "--timeout", { min: 1 }) * 1e3;
|
|
1811
|
+
const startTime = Date.now();
|
|
1812
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1813
|
+
await new Promise((resolve5) => setTimeout(resolve5, 3e3));
|
|
1814
|
+
const { data: runData } = await statusClient.get(
|
|
1815
|
+
`/api/runs/${data.runId}`
|
|
1816
|
+
);
|
|
1817
|
+
const status = typeof runData.status === "string" ? runData.status.toLowerCase() : "";
|
|
1818
|
+
if (["passed", "failed", "error", "blocked"].includes(status)) {
|
|
1819
|
+
if (status === "passed") {
|
|
1820
|
+
logger.success(`Run ${data.runId} passed`);
|
|
1821
|
+
} else if (status === "blocked") {
|
|
1822
|
+
logger.error(`Run ${data.runId} blocked`);
|
|
1823
|
+
throw new CLIError(`Run ${data.runId} blocked`, 1 /* GeneralError */);
|
|
1824
|
+
} else {
|
|
1825
|
+
logger.error(`Run ${data.runId} ${status}`);
|
|
1826
|
+
throw new CLIError(`Run ${data.runId} ${status}`, 1 /* GeneralError */);
|
|
1827
|
+
}
|
|
1828
|
+
outputDetail(runData);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
logger.debug(`Run status: ${status || "(unknown)"}`);
|
|
1832
|
+
}
|
|
1833
|
+
throw new CLIError(
|
|
1834
|
+
`Timed out waiting for run to complete after ${options.timeout}s`,
|
|
1835
|
+
5 /* Timeout */
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
// src/commands/runs.ts
|
|
1841
|
+
import { Command as Command6 } from "commander";
|
|
1842
|
+
import { ProxyAgent as ProxyAgent2 } from "undici";
|
|
1843
|
+
var proxyAgents2 = /* @__PURE__ */ new Map();
|
|
1844
|
+
function getProxyAgent2(proxyUrl) {
|
|
1845
|
+
const existing = proxyAgents2.get(proxyUrl);
|
|
1846
|
+
if (existing) return existing;
|
|
1847
|
+
const created = new ProxyAgent2(proxyUrl);
|
|
1848
|
+
proxyAgents2.set(proxyUrl, created);
|
|
1849
|
+
return created;
|
|
1850
|
+
}
|
|
1851
|
+
function isNoProxyMatch(url, noProxyRaw) {
|
|
1852
|
+
const hostname = url.hostname;
|
|
1853
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
1854
|
+
const entries = noProxyRaw.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
1855
|
+
if (entries.includes("*")) return true;
|
|
1856
|
+
for (const entry of entries) {
|
|
1857
|
+
const [hostPart, portPart] = entry.split(":");
|
|
1858
|
+
const host = hostPart.trim();
|
|
1859
|
+
const entryPort = portPart?.trim();
|
|
1860
|
+
if (!host) continue;
|
|
1861
|
+
if (entryPort && entryPort !== port) continue;
|
|
1862
|
+
if (host.startsWith(".")) {
|
|
1863
|
+
if (hostname.endsWith(host)) return true;
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (hostname === host) return true;
|
|
1867
|
+
if (hostname.endsWith(`.${host}`)) return true;
|
|
1868
|
+
}
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
function getProxyEnv(url) {
|
|
1872
|
+
const noProxyRaw = process.env.NO_PROXY ?? process.env.no_proxy;
|
|
1873
|
+
if (noProxyRaw && isNoProxyMatch(url, noProxyRaw)) {
|
|
1874
|
+
return null;
|
|
1875
|
+
}
|
|
1876
|
+
if (url.protocol === "https:") {
|
|
1877
|
+
return process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
|
|
1878
|
+
}
|
|
1879
|
+
if (url.protocol === "http:") {
|
|
1880
|
+
return process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
|
|
1881
|
+
}
|
|
1882
|
+
return null;
|
|
1883
|
+
}
|
|
1884
|
+
var runCommand = new Command6("run").description("Manage runs");
|
|
1885
|
+
runCommand.command("list").description("List runs").option("--page <page>", "Page number", "1").option("--limit <limit>", "Items per page", "50").option("--job <jobId>", "Filter by job ID").option("--status <status>", "Filter by status (queued, running, passed, failed, error, blocked)").action(async (options) => {
|
|
1886
|
+
const client = createAuthenticatedClient();
|
|
1887
|
+
const params = {
|
|
1888
|
+
page: options.page,
|
|
1889
|
+
limit: options.limit
|
|
1890
|
+
};
|
|
1891
|
+
if (options.job) params.jobId = options.job;
|
|
1892
|
+
if (options.status) params.status = options.status;
|
|
1893
|
+
const { data } = await client.get(
|
|
1894
|
+
"/api/runs",
|
|
1895
|
+
params
|
|
1896
|
+
);
|
|
1897
|
+
output(data.data, {
|
|
1898
|
+
columns: [
|
|
1899
|
+
{ key: "id", header: "ID" },
|
|
1900
|
+
{ key: "jobName", header: "Job" },
|
|
1901
|
+
{ key: "status", header: "Status" },
|
|
1902
|
+
{ key: "trigger", header: "Trigger" },
|
|
1903
|
+
{ key: "duration", header: "Duration" },
|
|
1904
|
+
{ key: "startedAt", header: "Started" }
|
|
1905
|
+
]
|
|
1906
|
+
});
|
|
1907
|
+
if (data.pagination) {
|
|
1908
|
+
logger.info(
|
|
1909
|
+
`
|
|
1910
|
+
Page ${data.pagination.page}/${data.pagination.totalPages} (${data.pagination.total} total)`
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
});
|
|
1914
|
+
runCommand.command("get <id>").description("Get run details").action(async (id) => {
|
|
1915
|
+
const client = createAuthenticatedClient();
|
|
1916
|
+
const { data } = await client.get(`/api/runs/${id}`);
|
|
1917
|
+
outputDetail(data);
|
|
1918
|
+
});
|
|
1919
|
+
runCommand.command("permissions <id>").description("Get run access permissions").action(async (id) => {
|
|
1920
|
+
const client = createAuthenticatedClient();
|
|
1921
|
+
const { data } = await client.get(`/api/runs/${id}/permissions`);
|
|
1922
|
+
outputDetail(data);
|
|
1923
|
+
});
|
|
1924
|
+
runCommand.command("cancel <id>").description("Cancel a running execution").action(async (id) => {
|
|
1925
|
+
const client = createAuthenticatedClient();
|
|
1926
|
+
await client.post(`/api/runs/${id}/cancel`);
|
|
1927
|
+
logger.success(`Run ${id} cancelled`);
|
|
1928
|
+
});
|
|
1929
|
+
runCommand.command("status <id>").description("Get run status").action(async (id) => {
|
|
1930
|
+
const client = createAuthenticatedClient();
|
|
1931
|
+
const { data } = await client.get(`/api/runs/${id}/status`);
|
|
1932
|
+
outputDetail(data);
|
|
1933
|
+
});
|
|
1934
|
+
runCommand.command("stream <id>").description("Stream live console output from a run").option("--idle-timeout <seconds>", "Abort if no data received within this period", "60").action(async (id, options) => {
|
|
1935
|
+
const token = requireAuth();
|
|
1936
|
+
const baseUrl = getStoredBaseUrl() ?? "https://app.supercheck.io";
|
|
1937
|
+
const idleTimeoutMs = Math.max(Number(options.idleTimeout) || 60, 10) * 1e3;
|
|
1938
|
+
const url = `${baseUrl}/api/runs/${id}/stream`;
|
|
1939
|
+
const parsedUrl = new URL(url);
|
|
1940
|
+
logger.info(`Streaming output for run ${id}...`);
|
|
1941
|
+
logger.newline();
|
|
1942
|
+
const controller = new AbortController();
|
|
1943
|
+
const connectTimeout = setTimeout(() => controller.abort(), 3e4);
|
|
1944
|
+
try {
|
|
1945
|
+
const proxy = getProxyEnv(parsedUrl);
|
|
1946
|
+
const response = await fetch(url, {
|
|
1947
|
+
headers: {
|
|
1948
|
+
"Authorization": `Bearer ${token}`,
|
|
1949
|
+
"Accept": "text/event-stream",
|
|
1950
|
+
"User-Agent": `supercheck-cli/${CLI_VERSION}`
|
|
1951
|
+
},
|
|
1952
|
+
...proxy ? { dispatcher: getProxyAgent2(proxy) } : {},
|
|
1953
|
+
signal: controller.signal
|
|
1954
|
+
});
|
|
1955
|
+
clearTimeout(connectTimeout);
|
|
1956
|
+
if (!response.ok) {
|
|
1957
|
+
throw new CLIError(`Failed to connect to stream: ${response.status}`, 4 /* ApiError */);
|
|
1958
|
+
}
|
|
1959
|
+
const reader = response.body?.getReader();
|
|
1960
|
+
if (!reader) {
|
|
1961
|
+
throw new CLIError("No response body for SSE stream", 4 /* ApiError */);
|
|
1962
|
+
}
|
|
1963
|
+
const decoder = new TextDecoder();
|
|
1964
|
+
let buffer = "";
|
|
1965
|
+
let currentEvent = "";
|
|
1966
|
+
let idleTimer;
|
|
1967
|
+
const resetIdleTimer = () => {
|
|
1968
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
1969
|
+
idleTimer = setTimeout(() => controller.abort(), idleTimeoutMs);
|
|
1970
|
+
};
|
|
1971
|
+
resetIdleTimer();
|
|
1972
|
+
while (true) {
|
|
1973
|
+
const { done, value } = await reader.read();
|
|
1974
|
+
if (done) break;
|
|
1975
|
+
resetIdleTimer();
|
|
1976
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1977
|
+
const lines = buffer.split("\n");
|
|
1978
|
+
buffer = lines.pop() ?? "";
|
|
1979
|
+
for (const line of lines) {
|
|
1980
|
+
if (line.startsWith(":")) continue;
|
|
1981
|
+
if (line.startsWith("event: ")) {
|
|
1982
|
+
currentEvent = line.slice(7).trim();
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
if (line.startsWith("retry:")) continue;
|
|
1986
|
+
if (line.startsWith("data: ")) {
|
|
1987
|
+
const data = line.slice(6);
|
|
1988
|
+
try {
|
|
1989
|
+
const parsed = JSON.parse(data);
|
|
1990
|
+
switch (currentEvent) {
|
|
1991
|
+
case "console":
|
|
1992
|
+
if (parsed.line !== void 0) {
|
|
1993
|
+
process.stdout.write(parsed.line + "\n");
|
|
1994
|
+
}
|
|
1995
|
+
break;
|
|
1996
|
+
case "complete":
|
|
1997
|
+
logger.newline();
|
|
1998
|
+
if (parsed.status === "completed" || parsed.status === "success") {
|
|
1999
|
+
logger.success(`Run ${id} ${parsed.status}`);
|
|
2000
|
+
} else {
|
|
2001
|
+
logger.warn(`Run ${id} finished with status: ${parsed.status}`);
|
|
2002
|
+
}
|
|
2003
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2004
|
+
return;
|
|
2005
|
+
case "ready":
|
|
2006
|
+
logger.debug(`Stream ready for run ${parsed.runId ?? id}`);
|
|
2007
|
+
break;
|
|
2008
|
+
case "error":
|
|
2009
|
+
logger.error(`Stream error: ${parsed.message ?? JSON.stringify(parsed)}`);
|
|
2010
|
+
break;
|
|
2011
|
+
case "heartbeat":
|
|
2012
|
+
break;
|
|
2013
|
+
default:
|
|
2014
|
+
if (parsed.output) {
|
|
2015
|
+
process.stdout.write(parsed.output);
|
|
2016
|
+
} else if (parsed.status) {
|
|
2017
|
+
logger.info(`Status: ${parsed.status}`);
|
|
2018
|
+
}
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
2021
|
+
} catch {
|
|
2022
|
+
process.stdout.write(data);
|
|
2023
|
+
}
|
|
2024
|
+
currentEvent = "";
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
if (line.trim() === "") {
|
|
2028
|
+
currentEvent = "";
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2033
|
+
} catch (err) {
|
|
2034
|
+
if (err instanceof CLIError) throw err;
|
|
2035
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
2036
|
+
throw new CLIError(
|
|
2037
|
+
`Stream timed out (no data received for ${Math.round(idleTimeoutMs / 1e3)}s)`,
|
|
2038
|
+
5 /* Timeout */
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
throw new CLIError(
|
|
2042
|
+
`Stream error: ${err instanceof Error ? err.message : String(err)}`,
|
|
2043
|
+
4 /* ApiError */
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
// src/commands/tests.ts
|
|
2049
|
+
import { Command as Command7 } from "commander";
|
|
2050
|
+
import { ProxyAgent as ProxyAgent3 } from "undici";
|
|
2051
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
2052
|
+
var proxyAgents3 = /* @__PURE__ */ new Map();
|
|
2053
|
+
function getProxyAgent3(proxyUrl) {
|
|
2054
|
+
const existing = proxyAgents3.get(proxyUrl);
|
|
2055
|
+
if (existing) return existing;
|
|
2056
|
+
const created = new ProxyAgent3(proxyUrl);
|
|
2057
|
+
proxyAgents3.set(proxyUrl, created);
|
|
2058
|
+
return created;
|
|
2059
|
+
}
|
|
2060
|
+
function isNoProxyMatch2(url, noProxyRaw) {
|
|
2061
|
+
const hostname = url.hostname;
|
|
2062
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
2063
|
+
const entries = noProxyRaw.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
2064
|
+
if (entries.includes("*")) return true;
|
|
2065
|
+
for (const entry of entries) {
|
|
2066
|
+
const [hostPart, portPart] = entry.split(":");
|
|
2067
|
+
const host = hostPart.trim();
|
|
2068
|
+
const entryPort = portPart?.trim();
|
|
2069
|
+
if (!host) continue;
|
|
2070
|
+
if (entryPort && entryPort !== port) continue;
|
|
2071
|
+
if (host.startsWith(".")) {
|
|
2072
|
+
if (hostname.endsWith(host)) return true;
|
|
2073
|
+
continue;
|
|
2074
|
+
}
|
|
2075
|
+
if (hostname === host) return true;
|
|
2076
|
+
if (hostname.endsWith(`.${host}`)) return true;
|
|
2077
|
+
}
|
|
2078
|
+
return false;
|
|
2079
|
+
}
|
|
2080
|
+
function getProxyEnv2(url) {
|
|
2081
|
+
const noProxyRaw = process.env.NO_PROXY ?? process.env.no_proxy;
|
|
2082
|
+
if (noProxyRaw && isNoProxyMatch2(url, noProxyRaw)) {
|
|
2083
|
+
return null;
|
|
2084
|
+
}
|
|
2085
|
+
if (url.protocol === "https:") {
|
|
2086
|
+
return process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
|
|
2087
|
+
}
|
|
2088
|
+
if (url.protocol === "http:") {
|
|
2089
|
+
return process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
|
|
2090
|
+
}
|
|
2091
|
+
return null;
|
|
2092
|
+
}
|
|
2093
|
+
function normalizeTestType(input) {
|
|
2094
|
+
const normalized = input.trim().toLowerCase();
|
|
2095
|
+
if (normalized === "k6") return "performance";
|
|
2096
|
+
if (normalized === "performance") return "performance";
|
|
2097
|
+
if (normalized === "playwright") return "browser";
|
|
2098
|
+
if (normalized === "browser") return "browser";
|
|
2099
|
+
return normalized;
|
|
2100
|
+
}
|
|
2101
|
+
var testCommand = new Command7("test").description("Manage tests");
|
|
2102
|
+
testCommand.command("list").description("List all tests").option("--page <page>", "Page number", "1").option("--limit <limit>", "Items per page", "50").option("--search <query>", "Search by title").option("--type <type>", "Filter by type (playwright, k6)").action(async (options) => {
|
|
2103
|
+
const client = createAuthenticatedClient();
|
|
2104
|
+
const params = {
|
|
2105
|
+
page: options.page,
|
|
2106
|
+
limit: options.limit
|
|
2107
|
+
};
|
|
2108
|
+
if (options.search) params.search = options.search;
|
|
2109
|
+
if (options.type) params.type = normalizeTestType(options.type);
|
|
2110
|
+
const { data } = await client.get(
|
|
2111
|
+
"/api/tests",
|
|
2112
|
+
params
|
|
2113
|
+
);
|
|
2114
|
+
output(data.data, {
|
|
2115
|
+
columns: [
|
|
2116
|
+
{ key: "id", header: "ID" },
|
|
2117
|
+
{ key: "title", header: "Title" },
|
|
2118
|
+
{ key: "type", header: "Type" },
|
|
2119
|
+
{ key: "createdAt", header: "Created" }
|
|
2120
|
+
]
|
|
2121
|
+
});
|
|
2122
|
+
if (data.pagination) {
|
|
2123
|
+
logger.info(
|
|
2124
|
+
`
|
|
2125
|
+
Page ${data.pagination.page}/${data.pagination.totalPages} (${data.pagination.total} total)`
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
});
|
|
2129
|
+
testCommand.command("get <id>").description("Get test details").option("--include-script", "Include the test script content").action(async (id, options) => {
|
|
2130
|
+
const client = createAuthenticatedClient();
|
|
2131
|
+
const params = {};
|
|
2132
|
+
if (options.includeScript) params.includeScript = "true";
|
|
2133
|
+
const { data } = await client.get(`/api/tests/${id}`, params);
|
|
2134
|
+
outputDetail(data);
|
|
2135
|
+
});
|
|
2136
|
+
testCommand.command("create").description("Create a new test").requiredOption("--title <title>", "Test title").requiredOption("--file <path>", "Path to the test script file").option("--type <type>", "Test type (playwright, k6)", "playwright").option("--description <description>", "Test description").action(async (options) => {
|
|
2137
|
+
const { readFileSync: readFileSync4 } = await import("fs");
|
|
2138
|
+
const { resolve: resolve5 } = await import("path");
|
|
2139
|
+
const filePath = resolve5(process.cwd(), options.file);
|
|
2140
|
+
let script;
|
|
2141
|
+
try {
|
|
2142
|
+
script = readFileSync4(filePath, "utf-8");
|
|
2143
|
+
} catch {
|
|
2144
|
+
throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
|
|
2145
|
+
}
|
|
2146
|
+
const client = createAuthenticatedClient();
|
|
2147
|
+
const encodedScript = Buffer2.from(script, "utf-8").toString("base64");
|
|
2148
|
+
const body = {
|
|
2149
|
+
title: options.title,
|
|
2150
|
+
script: encodedScript,
|
|
2151
|
+
type: normalizeTestType(options.type)
|
|
2152
|
+
};
|
|
2153
|
+
if (options.description) body.description = options.description;
|
|
2154
|
+
const { data } = await client.post("/api/tests", body);
|
|
2155
|
+
const createdId = data.test?.id ?? data.id;
|
|
2156
|
+
logger.success(`Test "${options.title}" created${createdId ? ` (${createdId})` : ""}`);
|
|
2157
|
+
outputDetail(data);
|
|
2158
|
+
});
|
|
2159
|
+
testCommand.command("update <id>").description("Update a test").option("--title <title>", "Test title").option("--file <path>", "Path to updated test script").option("--description <description>", "Test description").action(async (id, options) => {
|
|
2160
|
+
const client = createAuthenticatedClient();
|
|
2161
|
+
const body = {};
|
|
2162
|
+
if (options.title !== void 0) body.title = options.title;
|
|
2163
|
+
if (options.description !== void 0) body.description = options.description;
|
|
2164
|
+
if (options.file) {
|
|
2165
|
+
const { readFileSync: readFileSync4 } = await import("fs");
|
|
2166
|
+
const { resolve: resolve5 } = await import("path");
|
|
2167
|
+
const filePath = resolve5(process.cwd(), options.file);
|
|
2168
|
+
try {
|
|
2169
|
+
const raw = readFileSync4(filePath, "utf-8");
|
|
2170
|
+
body.script = Buffer2.from(raw, "utf-8").toString("base64");
|
|
2171
|
+
} catch {
|
|
2172
|
+
throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (Object.keys(body).length === 0) {
|
|
2176
|
+
logger.warn("No fields to update. Use --title, --file, or --description.");
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const { data } = await client.patch(`/api/tests/${id}`, body);
|
|
2180
|
+
logger.success(`Test ${id} updated`);
|
|
2181
|
+
outputDetail(data);
|
|
2182
|
+
});
|
|
2183
|
+
testCommand.command("delete <id>").description("Delete a test").option("--force", "Skip confirmation").action(async (id, options) => {
|
|
2184
|
+
if (!options.force) {
|
|
2185
|
+
const { createInterface } = await import("readline");
|
|
2186
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2187
|
+
const answer = await new Promise((resolve5) => {
|
|
2188
|
+
rl.question(`Are you sure you want to delete test ${id}? (y/N) `, resolve5);
|
|
2189
|
+
});
|
|
2190
|
+
rl.close();
|
|
2191
|
+
if (answer.toLowerCase() !== "y") {
|
|
2192
|
+
logger.info("Aborted");
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
const client = createAuthenticatedClient();
|
|
2197
|
+
await client.delete(`/api/tests/${id}`);
|
|
2198
|
+
logger.success(`Test ${id} deleted`);
|
|
2199
|
+
});
|
|
2200
|
+
testCommand.command("execute <id>").description("Execute a test immediately").option("--location <location>", "Execution location (k6 only)").action(async (id, options) => {
|
|
2201
|
+
const client = createAuthenticatedClient();
|
|
2202
|
+
const body = {};
|
|
2203
|
+
if (options.location) body.location = options.location;
|
|
2204
|
+
const { data } = await client.post(`/api/tests/${id}/execute`, body);
|
|
2205
|
+
outputDetail(data);
|
|
2206
|
+
});
|
|
2207
|
+
testCommand.command("tags <id>").description("Get test tags").action(async (id) => {
|
|
2208
|
+
const client = createAuthenticatedClient();
|
|
2209
|
+
const { data } = await client.get(`/api/tests/${id}/tags`);
|
|
2210
|
+
output(data, {
|
|
2211
|
+
columns: [
|
|
2212
|
+
{ key: "id", header: "ID" },
|
|
2213
|
+
{ key: "name", header: "Name" },
|
|
2214
|
+
{ key: "color", header: "Color" }
|
|
2215
|
+
]
|
|
2216
|
+
});
|
|
2217
|
+
});
|
|
2218
|
+
testCommand.command("validate").description("Validate a test script").requiredOption("--file <path>", "Path to the test script file").option("--type <type>", "Test type (playwright, k6)", "playwright").action(async (options) => {
|
|
2219
|
+
const { readFileSync: readFileSync4 } = await import("fs");
|
|
2220
|
+
const { resolve: resolve5 } = await import("path");
|
|
2221
|
+
const filePath = resolve5(process.cwd(), options.file);
|
|
2222
|
+
let script;
|
|
2223
|
+
try {
|
|
2224
|
+
script = readFileSync4(filePath, "utf-8");
|
|
2225
|
+
} catch {
|
|
2226
|
+
throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
|
|
2227
|
+
}
|
|
2228
|
+
const client = createAuthenticatedClient();
|
|
2229
|
+
const { data } = await client.post(
|
|
2230
|
+
"/api/validate-script",
|
|
2231
|
+
{ script, testType: normalizeTestType(options.type) }
|
|
2232
|
+
);
|
|
2233
|
+
if (data.valid) {
|
|
2234
|
+
logger.success(`Script is valid (${options.type})`);
|
|
2235
|
+
} else {
|
|
2236
|
+
logger.error("Script validation failed:");
|
|
2237
|
+
if (data.errors) {
|
|
2238
|
+
for (const err of data.errors) {
|
|
2239
|
+
logger.error(` - ${err}`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
throw new CLIError("Script validation failed", 1 /* GeneralError */);
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
testCommand.command("status <id>").description("Stream live status events for a test").option("--idle-timeout <seconds>", "Abort if no data received within this period", "60").action(async (id, options) => {
|
|
2246
|
+
const token = requireAuth();
|
|
2247
|
+
const baseUrl = getStoredBaseUrl() ?? "https://app.supercheck.io";
|
|
2248
|
+
const idleTimeoutMs = Math.max(Number(options.idleTimeout) || 60, 10) * 1e3;
|
|
2249
|
+
const url = `${baseUrl}/api/test-status/events/${id}`;
|
|
2250
|
+
const parsedUrl = new URL(url);
|
|
2251
|
+
logger.info(`Streaming status for test ${id}...`);
|
|
2252
|
+
logger.newline();
|
|
2253
|
+
const controller = new AbortController();
|
|
2254
|
+
const connectTimeout = setTimeout(() => controller.abort(), 3e4);
|
|
2255
|
+
try {
|
|
2256
|
+
const proxy = getProxyEnv2(parsedUrl);
|
|
2257
|
+
const response = await fetch(url, {
|
|
2258
|
+
headers: {
|
|
2259
|
+
"Authorization": `Bearer ${token}`,
|
|
2260
|
+
"Accept": "text/event-stream",
|
|
2261
|
+
"User-Agent": `supercheck-cli/${CLI_VERSION}`
|
|
2262
|
+
},
|
|
2263
|
+
...proxy ? { dispatcher: getProxyAgent3(proxy) } : {},
|
|
2264
|
+
signal: controller.signal
|
|
2265
|
+
});
|
|
2266
|
+
clearTimeout(connectTimeout);
|
|
2267
|
+
if (!response.ok) {
|
|
2268
|
+
throw new CLIError(`Failed to connect to status stream: ${response.status}`, 4 /* ApiError */);
|
|
2269
|
+
}
|
|
2270
|
+
const reader = response.body?.getReader();
|
|
2271
|
+
if (!reader) {
|
|
2272
|
+
throw new CLIError("No response body for SSE stream", 4 /* ApiError */);
|
|
2273
|
+
}
|
|
2274
|
+
const decoder = new TextDecoder();
|
|
2275
|
+
let buffer = "";
|
|
2276
|
+
let idleTimer;
|
|
2277
|
+
const resetIdleTimer = () => {
|
|
2278
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2279
|
+
idleTimer = setTimeout(() => controller.abort(), idleTimeoutMs);
|
|
2280
|
+
};
|
|
2281
|
+
resetIdleTimer();
|
|
2282
|
+
while (true) {
|
|
2283
|
+
const { done, value } = await reader.read();
|
|
2284
|
+
if (done) break;
|
|
2285
|
+
resetIdleTimer();
|
|
2286
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2287
|
+
const lines = buffer.split("\n");
|
|
2288
|
+
buffer = lines.pop() ?? "";
|
|
2289
|
+
for (const line of lines) {
|
|
2290
|
+
if (line.startsWith(":")) continue;
|
|
2291
|
+
if (line.startsWith("data: ")) {
|
|
2292
|
+
const payload = line.slice(6);
|
|
2293
|
+
try {
|
|
2294
|
+
const parsed = JSON.parse(payload);
|
|
2295
|
+
outputDetail(parsed);
|
|
2296
|
+
} catch {
|
|
2297
|
+
process.stdout.write(payload + "\n");
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
if (err instanceof CLIError) throw err;
|
|
2305
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
2306
|
+
throw new CLIError(
|
|
2307
|
+
`Stream timed out (no data received for ${Math.round(idleTimeoutMs / 1e3)}s)`,
|
|
2308
|
+
5 /* Timeout */
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
throw new CLIError(
|
|
2312
|
+
`Stream error: ${err instanceof Error ? err.message : String(err)}`,
|
|
2313
|
+
4 /* ApiError */
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
// src/commands/monitors.ts
|
|
2319
|
+
import { Command as Command8 } from "commander";
|
|
2320
|
+
var monitorCommand = new Command8("monitor").description("Manage monitors");
|
|
2321
|
+
monitorCommand.command("list").description("List all monitors").option("--page <page>", "Page number", "1").option("--limit <limit>", "Items per page", "50").action(async (options) => {
|
|
2322
|
+
const client = createAuthenticatedClient();
|
|
2323
|
+
const { data } = await client.get(
|
|
2324
|
+
"/api/monitors",
|
|
2325
|
+
{ page: options.page, limit: options.limit }
|
|
2326
|
+
);
|
|
2327
|
+
output(data.data, {
|
|
2328
|
+
columns: [
|
|
2329
|
+
{ key: "id", header: "ID" },
|
|
2330
|
+
{ key: "name", header: "Name" },
|
|
2331
|
+
{ key: "type", header: "Type" },
|
|
2332
|
+
{ key: "status", header: "Status" },
|
|
2333
|
+
{ key: "frequencyMinutes", header: "Freq (min)" }
|
|
2334
|
+
]
|
|
2335
|
+
});
|
|
2336
|
+
if (data.pagination) {
|
|
2337
|
+
logger.info(
|
|
2338
|
+
`
|
|
2339
|
+
Page ${data.pagination.page}/${data.pagination.totalPages} (${data.pagination.total} total)`
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
});
|
|
2343
|
+
monitorCommand.command("get <id>").description("Get monitor details").action(async (id) => {
|
|
2344
|
+
const client = createAuthenticatedClient();
|
|
2345
|
+
const { data } = await client.get(`/api/monitors/${id}`);
|
|
2346
|
+
outputDetail(data);
|
|
2347
|
+
});
|
|
2348
|
+
monitorCommand.command("results <id>").description("Get monitor check results").option("--limit <limit>", "Number of results", "20").action(async (id, options) => {
|
|
2349
|
+
const client = createAuthenticatedClient();
|
|
2350
|
+
const { data } = await client.get(
|
|
2351
|
+
`/api/monitors/${id}/results`,
|
|
2352
|
+
{ limit: options.limit }
|
|
2353
|
+
);
|
|
2354
|
+
output(data.results, {
|
|
2355
|
+
columns: [
|
|
2356
|
+
{ key: "timestamp", header: "Time" },
|
|
2357
|
+
{ key: "status", header: "Status" },
|
|
2358
|
+
{ key: "responseTime", header: "Response (ms)" },
|
|
2359
|
+
{ key: "location", header: "Location" }
|
|
2360
|
+
]
|
|
2361
|
+
});
|
|
2362
|
+
});
|
|
2363
|
+
monitorCommand.command("stats <id>").description("Get monitor performance statistics").action(async (id) => {
|
|
2364
|
+
const client = createAuthenticatedClient();
|
|
2365
|
+
const { data } = await client.get(`/api/monitors/${id}/stats`);
|
|
2366
|
+
outputDetail(data);
|
|
2367
|
+
});
|
|
2368
|
+
monitorCommand.command("status <id>").description("Get current monitor status").action(async (id) => {
|
|
2369
|
+
const client = createAuthenticatedClient();
|
|
2370
|
+
const { data } = await client.get(`/api/monitors/${id}`);
|
|
2371
|
+
const statusInfo = {
|
|
2372
|
+
id: data.id,
|
|
2373
|
+
name: data.name,
|
|
2374
|
+
status: data.status,
|
|
2375
|
+
lastCheckAt: data.lastCheckAt,
|
|
2376
|
+
responseTime: data.responseTime
|
|
2377
|
+
};
|
|
2378
|
+
outputDetail(statusInfo);
|
|
2379
|
+
});
|
|
2380
|
+
monitorCommand.command("create").description("Create a new monitor").requiredOption("--name <name>", "Monitor name").requiredOption("--url <url>", "URL to monitor").option("--type <type>", "Monitor type (http_request, website, ping_host, port_check, synthetic_test)", "http_request").option("--interval <seconds>", "Check interval in seconds", "300").option("--timeout <seconds>", "Request timeout in seconds", "30").option("--method <method>", "HTTP method (GET, POST, HEAD)", "GET").action(async (options) => {
|
|
2381
|
+
const client = createAuthenticatedClient();
|
|
2382
|
+
const intervalSeconds = parseInt(options.interval, 10);
|
|
2383
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
2384
|
+
logger.error("Invalid --interval value: must be a positive number of seconds");
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
const frequencyMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
2388
|
+
const body = {
|
|
2389
|
+
name: options.name,
|
|
2390
|
+
target: options.url,
|
|
2391
|
+
type: options.type,
|
|
2392
|
+
frequencyMinutes,
|
|
2393
|
+
config: {
|
|
2394
|
+
timeout: parseInt(options.timeout, 10) * 1e3,
|
|
2395
|
+
// API expects milliseconds
|
|
2396
|
+
method: options.method
|
|
2397
|
+
}
|
|
2398
|
+
};
|
|
2399
|
+
const { data } = await client.post("/api/monitors", body);
|
|
2400
|
+
logger.success(`Monitor "${options.name}" created (${data.id})`);
|
|
2401
|
+
outputDetail(data);
|
|
2402
|
+
});
|
|
2403
|
+
monitorCommand.command("update <id>").description("Update a monitor").option("--name <name>", "Monitor name").option("--url <url>", "URL to monitor").option("--interval <seconds>", "Check interval in seconds").option("--timeout <seconds>", "Request timeout in seconds").option("--method <method>", "HTTP method").option("--active <boolean>", "Enable or disable monitor (true/false)").action(async (id, options) => {
|
|
2404
|
+
const client = createAuthenticatedClient();
|
|
2405
|
+
const body = {};
|
|
2406
|
+
if (options.name !== void 0) body.name = options.name;
|
|
2407
|
+
if (options.url !== void 0) body.target = options.url;
|
|
2408
|
+
if (options.interval !== void 0) {
|
|
2409
|
+
const intervalSeconds = parseInt(options.interval, 10);
|
|
2410
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
2411
|
+
logger.error("Invalid --interval value: must be a positive number of seconds");
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
body.frequencyMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
|
|
2415
|
+
}
|
|
2416
|
+
if (options.active !== void 0) body.enabled = options.active === "true";
|
|
2417
|
+
const config = {};
|
|
2418
|
+
if (options.timeout !== void 0) config.timeout = parseInt(options.timeout, 10) * 1e3;
|
|
2419
|
+
if (options.method !== void 0) config.method = options.method;
|
|
2420
|
+
if (Object.keys(config).length > 0) body.config = config;
|
|
2421
|
+
if (Object.keys(body).length === 0) {
|
|
2422
|
+
logger.warn("No fields to update. Use --name, --url, --interval, --timeout, --method, or --active.");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const { data } = await client.patch(`/api/monitors/${id}`, body);
|
|
2426
|
+
logger.success(`Monitor ${id} updated`);
|
|
2427
|
+
outputDetail(data);
|
|
2428
|
+
});
|
|
2429
|
+
monitorCommand.command("delete <id>").description("Delete a monitor").option("--force", "Skip confirmation").action(async (id, options) => {
|
|
2430
|
+
if (!options.force) {
|
|
2431
|
+
const { createInterface } = await import("readline");
|
|
2432
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2433
|
+
const answer = await new Promise((resolve5) => {
|
|
2434
|
+
rl.question(`Are you sure you want to delete monitor ${id}? (y/N) `, resolve5);
|
|
2435
|
+
});
|
|
2436
|
+
rl.close();
|
|
2437
|
+
if (answer.toLowerCase() !== "y") {
|
|
2438
|
+
logger.info("Aborted");
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
const client = createAuthenticatedClient();
|
|
2443
|
+
await client.delete(`/api/monitors/${id}`);
|
|
2444
|
+
logger.success(`Monitor ${id} deleted`);
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
// src/commands/variables.ts
|
|
2448
|
+
import { Command as Command9 } from "commander";
|
|
2449
|
+
var varCommand = new Command9("var").description("Manage project variables");
|
|
2450
|
+
varCommand.command("list").description("List all variables").action(async () => {
|
|
2451
|
+
const client = createAuthenticatedClient();
|
|
2452
|
+
const { data } = await client.get("/api/variables");
|
|
2453
|
+
output(data, {
|
|
2454
|
+
columns: [
|
|
2455
|
+
{ key: "id", header: "ID" },
|
|
2456
|
+
{ key: "key", header: "Key" },
|
|
2457
|
+
{ key: "isSecret", header: "Secret" },
|
|
2458
|
+
{ key: "createdAt", header: "Created" }
|
|
2459
|
+
]
|
|
2460
|
+
});
|
|
2461
|
+
});
|
|
2462
|
+
varCommand.command("get <key>").description("Get a variable by key name").action(async (key) => {
|
|
2463
|
+
const client = createAuthenticatedClient();
|
|
2464
|
+
const { data: variables } = await client.get("/api/variables");
|
|
2465
|
+
const variable = variables.find((v) => v.key === key);
|
|
2466
|
+
if (!variable) {
|
|
2467
|
+
throw new CLIError(`Variable "${key}" not found`, 1 /* GeneralError */);
|
|
2468
|
+
}
|
|
2469
|
+
outputDetail(variable);
|
|
2470
|
+
});
|
|
2471
|
+
varCommand.command("set <key> <value>").description("Create or update a variable").option("--secret", "Mark as secret (value will be encrypted)").option("--description <description>", "Variable description").action(async (key, value, options) => {
|
|
2472
|
+
const client = createAuthenticatedClient();
|
|
2473
|
+
const { data: variables } = await client.get("/api/variables");
|
|
2474
|
+
const existing = variables.find((v) => v.key === key);
|
|
2475
|
+
if (existing) {
|
|
2476
|
+
await client.put(`/api/variables/${existing.id}`, {
|
|
2477
|
+
key,
|
|
2478
|
+
value,
|
|
2479
|
+
isSecret: options.secret ?? existing.isSecret,
|
|
2480
|
+
...options.description !== void 0 && { description: options.description }
|
|
2481
|
+
});
|
|
2482
|
+
logger.success(`Variable "${key}" updated`);
|
|
2483
|
+
} else {
|
|
2484
|
+
await client.post("/api/variables", {
|
|
2485
|
+
key,
|
|
2486
|
+
value,
|
|
2487
|
+
isSecret: options.secret ?? false,
|
|
2488
|
+
...options.description !== void 0 && { description: options.description }
|
|
2489
|
+
});
|
|
2490
|
+
logger.success(`Variable "${key}" created`);
|
|
2491
|
+
}
|
|
2492
|
+
});
|
|
2493
|
+
varCommand.command("delete <key>").description("Delete a variable").action(async (key) => {
|
|
2494
|
+
const client = createAuthenticatedClient();
|
|
2495
|
+
const { data: variables } = await client.get("/api/variables");
|
|
2496
|
+
const variable = variables.find((v) => v.key === key);
|
|
2497
|
+
if (!variable) {
|
|
2498
|
+
throw new CLIError(`Variable "${key}" not found`, 1 /* GeneralError */);
|
|
2499
|
+
}
|
|
2500
|
+
await client.delete(`/api/variables/${variable.id}`);
|
|
2501
|
+
logger.success(`Variable "${key}" deleted`);
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
// src/commands/tags.ts
|
|
2505
|
+
import { Command as Command10 } from "commander";
|
|
2506
|
+
var tagCommand = new Command10("tag").description("Manage tags");
|
|
2507
|
+
tagCommand.command("list").description("List all tags").action(async () => {
|
|
2508
|
+
const client = createAuthenticatedClient();
|
|
2509
|
+
const { data } = await client.get("/api/tags");
|
|
2510
|
+
output(data, {
|
|
2511
|
+
columns: [
|
|
2512
|
+
{ key: "id", header: "ID" },
|
|
2513
|
+
{ key: "name", header: "Name" },
|
|
2514
|
+
{ key: "color", header: "Color" }
|
|
2515
|
+
]
|
|
2516
|
+
});
|
|
2517
|
+
});
|
|
2518
|
+
tagCommand.command("create <name>").description("Create a new tag").option("--color <color>", "Tag color (hex)").action(async (name, options) => {
|
|
2519
|
+
const client = createAuthenticatedClient();
|
|
2520
|
+
const { data } = await client.post("/api/tags", {
|
|
2521
|
+
name,
|
|
2522
|
+
color: options.color
|
|
2523
|
+
});
|
|
2524
|
+
logger.success(`Tag "${name}" created (${data.id})`);
|
|
2525
|
+
});
|
|
2526
|
+
tagCommand.command("delete <id>").description("Delete a tag").action(async (id) => {
|
|
2527
|
+
const client = createAuthenticatedClient();
|
|
2528
|
+
await client.delete(`/api/tags/${id}`);
|
|
2529
|
+
logger.success(`Tag ${id} deleted`);
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
// src/commands/diff.ts
|
|
2533
|
+
import { Command as Command11 } from "commander";
|
|
2534
|
+
|
|
2535
|
+
// src/utils/reconcile.ts
|
|
2536
|
+
import pc3 from "picocolors";
|
|
2537
|
+
function reconcile(local, remote) {
|
|
2538
|
+
const changes = [];
|
|
2539
|
+
const remoteByKey = /* @__PURE__ */ new Map();
|
|
2540
|
+
for (const r of remote) {
|
|
2541
|
+
remoteByKey.set(`${r.type}:${r.id}`, r);
|
|
2542
|
+
}
|
|
2543
|
+
const localByKey = /* @__PURE__ */ new Map();
|
|
2544
|
+
for (const l of local) {
|
|
2545
|
+
if (l.id) {
|
|
2546
|
+
localByKey.set(`${l.type}:${l.id}`, l);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
for (const l of local) {
|
|
2550
|
+
if (!l.id) {
|
|
2551
|
+
changes.push({
|
|
2552
|
+
action: "create",
|
|
2553
|
+
type: l.type,
|
|
2554
|
+
name: l.name,
|
|
2555
|
+
local: l
|
|
2556
|
+
});
|
|
2557
|
+
continue;
|
|
2558
|
+
}
|
|
2559
|
+
const key = `${l.type}:${l.id}`;
|
|
2560
|
+
const r = remoteByKey.get(key);
|
|
2561
|
+
if (!r) {
|
|
2562
|
+
changes.push({
|
|
2563
|
+
action: "create",
|
|
2564
|
+
type: l.type,
|
|
2565
|
+
id: l.id,
|
|
2566
|
+
name: l.name,
|
|
2567
|
+
local: l
|
|
2568
|
+
});
|
|
2569
|
+
} else {
|
|
2570
|
+
const diffs = diffFields(l.definition, r.raw);
|
|
2571
|
+
if (diffs.length > 0) {
|
|
2572
|
+
changes.push({
|
|
2573
|
+
action: "update",
|
|
2574
|
+
type: l.type,
|
|
2575
|
+
id: l.id,
|
|
2576
|
+
name: l.name,
|
|
2577
|
+
local: l,
|
|
2578
|
+
remote: r,
|
|
2579
|
+
details: diffs
|
|
2580
|
+
});
|
|
2581
|
+
} else {
|
|
2582
|
+
changes.push({
|
|
2583
|
+
action: "no-change",
|
|
2584
|
+
type: l.type,
|
|
2585
|
+
id: l.id,
|
|
2586
|
+
name: l.name,
|
|
2587
|
+
local: l,
|
|
2588
|
+
remote: r
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
for (const r of remote) {
|
|
2594
|
+
const key = `${r.type}:${r.id}`;
|
|
2595
|
+
if (!localByKey.has(key)) {
|
|
2596
|
+
changes.push({
|
|
2597
|
+
action: "delete",
|
|
2598
|
+
type: r.type,
|
|
2599
|
+
id: r.id,
|
|
2600
|
+
name: r.name,
|
|
2601
|
+
remote: r
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
return changes;
|
|
2606
|
+
}
|
|
2607
|
+
function stableStringify(value) {
|
|
2608
|
+
return JSON.stringify(value, (_, v) => {
|
|
2609
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
2610
|
+
return Object.keys(v).sort().reduce((sorted, k) => {
|
|
2611
|
+
sorted[k] = v[k];
|
|
2612
|
+
return sorted;
|
|
2613
|
+
}, {});
|
|
2614
|
+
}
|
|
2615
|
+
return v;
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
function diffFields(local, remote) {
|
|
2619
|
+
const diffs = [];
|
|
2620
|
+
for (const [key, localVal] of Object.entries(local)) {
|
|
2621
|
+
if (["id", "createdAt", "updatedAt", "projectId", "organizationId", "createdByUserId"].includes(key)) continue;
|
|
2622
|
+
const remoteVal = remote[key];
|
|
2623
|
+
if (stableStringify(localVal) !== stableStringify(remoteVal)) {
|
|
2624
|
+
diffs.push(`${key}: ${JSON.stringify(remoteVal)} -> ${JSON.stringify(localVal)}`);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
return diffs;
|
|
2628
|
+
}
|
|
2629
|
+
function formatChangePlan(changes) {
|
|
2630
|
+
const creates = changes.filter((c) => c.action === "create");
|
|
2631
|
+
const updates = changes.filter((c) => c.action === "update");
|
|
2632
|
+
const deletes = changes.filter((c) => c.action === "delete");
|
|
2633
|
+
const unchanged = changes.filter((c) => c.action === "no-change");
|
|
2634
|
+
logger.newline();
|
|
2635
|
+
logger.header("Change Plan");
|
|
2636
|
+
logger.newline();
|
|
2637
|
+
if (creates.length > 0) {
|
|
2638
|
+
logger.info(pc3.green(` + ${creates.length} to create`));
|
|
2639
|
+
for (const c of creates) {
|
|
2640
|
+
logger.info(pc3.green(` + ${c.type}/${c.name}`));
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
if (updates.length > 0) {
|
|
2644
|
+
logger.info(pc3.yellow(` ~ ${updates.length} to update`));
|
|
2645
|
+
for (const c of updates) {
|
|
2646
|
+
logger.info(pc3.yellow(` ~ ${c.type}/${c.name} (${c.id})`));
|
|
2647
|
+
if (c.details) {
|
|
2648
|
+
for (const d of c.details) {
|
|
2649
|
+
logger.info(pc3.gray(` ${d}`));
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
if (deletes.length > 0) {
|
|
2655
|
+
logger.info(pc3.red(` - ${deletes.length} to delete`));
|
|
2656
|
+
for (const c of deletes) {
|
|
2657
|
+
logger.info(pc3.red(` - ${c.type}/${c.name} (${c.id})`));
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
if (unchanged.length > 0) {
|
|
2661
|
+
logger.info(pc3.gray(` = ${unchanged.length} unchanged`));
|
|
2662
|
+
}
|
|
2663
|
+
logger.newline();
|
|
2664
|
+
const totalChanges = creates.length + updates.length + deletes.length;
|
|
2665
|
+
if (totalChanges === 0) {
|
|
2666
|
+
logger.success("No changes detected. Everything is in sync.");
|
|
2667
|
+
} else {
|
|
2668
|
+
logger.info(`${pc3.bold(String(totalChanges))} change(s) detected.`);
|
|
2669
|
+
}
|
|
2670
|
+
logger.newline();
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
// src/commands/diff.ts
|
|
2674
|
+
var diffCommand = new Command11("diff").description("Preview changes between local config and the remote Supercheck project").option("--config <path>", "Path to config file").action(async (options) => {
|
|
2675
|
+
const cwd = process.cwd();
|
|
2676
|
+
const { config } = await loadConfig({ cwd, configPath: options.config });
|
|
2677
|
+
const client = createAuthenticatedClient();
|
|
2678
|
+
logger.info("Comparing local config with remote project...");
|
|
2679
|
+
logger.newline();
|
|
2680
|
+
const localResources = buildLocalResources(config, cwd);
|
|
2681
|
+
const remoteResources = await fetchRemoteResources(client);
|
|
2682
|
+
logger.debug(`Local: ${localResources.length} resources, Remote: ${remoteResources.length} resources`);
|
|
2683
|
+
const changes = reconcile(localResources, remoteResources);
|
|
2684
|
+
formatChangePlan(changes);
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
// src/commands/deploy.ts
|
|
2688
|
+
import { Command as Command12 } from "commander";
|
|
2689
|
+
import pc4 from "picocolors";
|
|
2690
|
+
async function applyChange(client, change) {
|
|
2691
|
+
try {
|
|
2692
|
+
const endpoint = getApiEndpoint(change.type);
|
|
2693
|
+
switch (change.action) {
|
|
2694
|
+
case "create": {
|
|
2695
|
+
const body = { ...change.local.definition };
|
|
2696
|
+
await client.post(endpoint, body);
|
|
2697
|
+
return { success: true };
|
|
2698
|
+
}
|
|
2699
|
+
case "update": {
|
|
2700
|
+
const id = change.id ?? change.remote.id;
|
|
2701
|
+
const { id: _id, ...body } = change.local.definition;
|
|
2702
|
+
await client.put(`${endpoint}/${id}`, body);
|
|
2703
|
+
return { success: true };
|
|
2704
|
+
}
|
|
2705
|
+
case "delete": {
|
|
2706
|
+
const id = change.id ?? change.remote.id;
|
|
2707
|
+
await client.delete(`${endpoint}/${id}`);
|
|
2708
|
+
return { success: true };
|
|
2709
|
+
}
|
|
2710
|
+
default:
|
|
2711
|
+
return { success: true };
|
|
2712
|
+
}
|
|
2713
|
+
} catch (err) {
|
|
2714
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2715
|
+
return { success: false, error: message };
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
var deployCommand = new Command12("deploy").description("Push local config resources to the Supercheck project").option("--config <path>", "Path to config file").option("--dry-run", "Show what would change without applying").option("--force", "Skip confirmation prompt").option("--no-delete", "Do not delete remote resources missing from config").action(async (options) => {
|
|
2719
|
+
const cwd = process.cwd();
|
|
2720
|
+
const { config, configPath } = await loadConfig({ cwd, configPath: options.config });
|
|
2721
|
+
const client = createAuthenticatedClient();
|
|
2722
|
+
logger.info(`Deploying from ${configPath ?? "config"}...`);
|
|
2723
|
+
logger.newline();
|
|
2724
|
+
const localResources = buildLocalResources(config, cwd);
|
|
2725
|
+
const remoteResources = await fetchRemoteResources(client);
|
|
2726
|
+
let changes = reconcile(localResources, remoteResources);
|
|
2727
|
+
if (options.delete === false) {
|
|
2728
|
+
changes = changes.filter((c) => c.action !== "delete");
|
|
2729
|
+
}
|
|
2730
|
+
const actionable = changes.filter((c) => c.action !== "no-change");
|
|
2731
|
+
if (actionable.length === 0) {
|
|
2732
|
+
logger.success("No changes to deploy. Everything is in sync.");
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
formatChangePlan(changes);
|
|
2736
|
+
if (options.dryRun) {
|
|
2737
|
+
logger.info(pc4.yellow("Dry run \u2014 no changes applied."));
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
if (!options.force) {
|
|
2741
|
+
const { createInterface } = await import("readline");
|
|
2742
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2743
|
+
const answer = await new Promise((resolve5) => {
|
|
2744
|
+
rl.question("Do you want to apply these changes? (y/N) ", resolve5);
|
|
2745
|
+
});
|
|
2746
|
+
rl.close();
|
|
2747
|
+
if (!answer || answer.toLowerCase() !== "y") {
|
|
2748
|
+
logger.info("Deploy aborted.");
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
logger.info("Applying changes...");
|
|
2753
|
+
logger.newline();
|
|
2754
|
+
let succeeded = 0;
|
|
2755
|
+
let failed = 0;
|
|
2756
|
+
const ordered = [
|
|
2757
|
+
...actionable.filter((c) => c.action === "create"),
|
|
2758
|
+
...actionable.filter((c) => c.action === "update"),
|
|
2759
|
+
...actionable.filter((c) => c.action === "delete")
|
|
2760
|
+
];
|
|
2761
|
+
for (const change of ordered) {
|
|
2762
|
+
const actionLabel = change.action === "create" ? "+" : change.action === "update" ? "~" : "-";
|
|
2763
|
+
const color = change.action === "create" ? pc4.green : change.action === "update" ? pc4.yellow : pc4.red;
|
|
2764
|
+
const result = await applyChange(client, change);
|
|
2765
|
+
if (result.success) {
|
|
2766
|
+
logger.info(color(` ${actionLabel} ${change.type}/${change.name} \u2713`));
|
|
2767
|
+
succeeded++;
|
|
2768
|
+
} else {
|
|
2769
|
+
logger.error(` ${actionLabel} ${change.type}/${change.name} \u2717 ${result.error}`);
|
|
2770
|
+
failed++;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
logger.newline();
|
|
2774
|
+
if (failed > 0) {
|
|
2775
|
+
throw new CLIError(
|
|
2776
|
+
`Deploy completed with errors: ${succeeded} succeeded, ${failed} failed`,
|
|
2777
|
+
1 /* GeneralError */
|
|
2778
|
+
);
|
|
2779
|
+
} else {
|
|
2780
|
+
logger.success(`Deploy complete: ${succeeded} change(s) applied successfully`);
|
|
2781
|
+
}
|
|
2782
|
+
});
|
|
2783
|
+
|
|
2784
|
+
// src/commands/destroy.ts
|
|
2785
|
+
import { Command as Command13 } from "commander";
|
|
2786
|
+
import pc5 from "picocolors";
|
|
2787
|
+
async function fetchManagedResources(client, config) {
|
|
2788
|
+
const resources = [];
|
|
2789
|
+
const managedIds = /* @__PURE__ */ new Set();
|
|
2790
|
+
if (config.jobs) {
|
|
2791
|
+
for (const j of config.jobs) {
|
|
2792
|
+
if (j.id) managedIds.add(`job:${j.id}`);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
if (config.variables) {
|
|
2796
|
+
for (const v of config.variables) {
|
|
2797
|
+
if (v.id) managedIds.add(`variable:${v.id}`);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
if (config.tags) {
|
|
2801
|
+
for (const t of config.tags) {
|
|
2802
|
+
if (t.id) managedIds.add(`tag:${t.id}`);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
if (Array.isArray(config.monitors)) {
|
|
2806
|
+
for (const m of config.monitors) {
|
|
2807
|
+
if (m.id) managedIds.add(`monitor:${m.id}`);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
if (Array.isArray(config.statusPages)) {
|
|
2811
|
+
for (const sp of config.statusPages) {
|
|
2812
|
+
if (sp.id) managedIds.add(`statusPage:${sp.id}`);
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2816
|
+
const patterns = {
|
|
2817
|
+
playwright: config.tests?.playwright?.testMatch,
|
|
2818
|
+
k6: config.tests?.k6?.testMatch
|
|
2819
|
+
};
|
|
2820
|
+
const cwd = process.cwd();
|
|
2821
|
+
const files = discoverFiles(cwd, patterns);
|
|
2822
|
+
const testIds = /* @__PURE__ */ new Set();
|
|
2823
|
+
for (const f of files) {
|
|
2824
|
+
const stem = f.filename.replace(/\.(pw|k6)\.ts$/, "");
|
|
2825
|
+
if (UUID_RE.test(stem)) {
|
|
2826
|
+
testIds.add(stem);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
try {
|
|
2830
|
+
const jobs = await fetchAllPages(client, "/api/jobs", "jobs");
|
|
2831
|
+
for (const job of jobs) {
|
|
2832
|
+
if (job.id && managedIds.has(`job:${String(job.id)}`)) {
|
|
2833
|
+
resources.push({ id: String(job.id), type: "job", name: String(job.name ?? "") });
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
} catch (err) {
|
|
2837
|
+
logDestroyFetchError("jobs", err);
|
|
2838
|
+
}
|
|
2839
|
+
try {
|
|
2840
|
+
const tests = await fetchAllPages(client, "/api/tests", "tests");
|
|
2841
|
+
for (const test of tests) {
|
|
2842
|
+
if (test.id && testIds.has(String(test.id))) {
|
|
2843
|
+
resources.push({ id: String(test.id), type: "test", name: String(test.title ?? "") });
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
} catch (err) {
|
|
2847
|
+
logDestroyFetchError("tests", err);
|
|
2848
|
+
}
|
|
2849
|
+
try {
|
|
2850
|
+
const monitors = await fetchAllPages(client, "/api/monitors", "monitors");
|
|
2851
|
+
for (const monitor of monitors) {
|
|
2852
|
+
if (monitor.id && managedIds.has(`monitor:${String(monitor.id)}`)) {
|
|
2853
|
+
resources.push({ id: String(monitor.id), type: "monitor", name: String(monitor.name ?? "") });
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
} catch (err) {
|
|
2857
|
+
logDestroyFetchError("monitors", err);
|
|
2858
|
+
}
|
|
2859
|
+
try {
|
|
2860
|
+
const { data } = await client.get("/api/variables");
|
|
2861
|
+
for (const v of data) {
|
|
2862
|
+
if (v.id && managedIds.has(`variable:${String(v.id)}`)) {
|
|
2863
|
+
resources.push({ id: String(v.id), type: "variable", name: String(v.key ?? "") });
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
} catch (err) {
|
|
2867
|
+
logDestroyFetchError("variables", err);
|
|
2868
|
+
}
|
|
2869
|
+
try {
|
|
2870
|
+
const { data } = await client.get("/api/tags");
|
|
2871
|
+
for (const t of data) {
|
|
2872
|
+
if (t.id && managedIds.has(`tag:${String(t.id)}`)) {
|
|
2873
|
+
resources.push({ id: String(t.id), type: "tag", name: String(t.name ?? "") });
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
logDestroyFetchError("tags", err);
|
|
2878
|
+
}
|
|
2879
|
+
try {
|
|
2880
|
+
const statusPages = await fetchAllPages(client, "/api/status-pages", "status-pages");
|
|
2881
|
+
for (const sp of statusPages) {
|
|
2882
|
+
if (sp.id && managedIds.has(`statusPage:${String(sp.id)}`)) {
|
|
2883
|
+
resources.push({ id: String(sp.id), type: "statusPage", name: String(sp.name ?? "") });
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
} catch (err) {
|
|
2887
|
+
logDestroyFetchError("status-pages", err);
|
|
2888
|
+
}
|
|
2889
|
+
return resources;
|
|
2890
|
+
}
|
|
2891
|
+
function logDestroyFetchError(resourceType, err) {
|
|
2892
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) {
|
|
2893
|
+
logger.debug(`Could not fetch ${resourceType} (not found)`);
|
|
2894
|
+
} else {
|
|
2895
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2896
|
+
logger.warn(`Could not fetch ${resourceType}: ${msg}`);
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
var destroyCommand = new Command13("destroy").description("Tear down managed resources from the Supercheck project").option("--config <path>", "Path to config file").option("--dry-run", "Show what would be destroyed without applying").option("--force", "Skip confirmation prompt").action(async (options) => {
|
|
2900
|
+
const cwd = process.cwd();
|
|
2901
|
+
const { config } = await loadConfig({ cwd, configPath: options.config });
|
|
2902
|
+
const client = createAuthenticatedClient();
|
|
2903
|
+
logger.info("Scanning for managed resources to destroy...");
|
|
2904
|
+
logger.newline();
|
|
2905
|
+
const managed = await fetchManagedResources(client, config);
|
|
2906
|
+
if (managed.length === 0) {
|
|
2907
|
+
logger.success("No managed resources found on the remote project.");
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
logger.header("Resources to destroy:");
|
|
2911
|
+
logger.newline();
|
|
2912
|
+
for (const r of managed) {
|
|
2913
|
+
logger.info(pc5.red(` - ${r.type}/${r.name} [${r.id}]`));
|
|
2914
|
+
}
|
|
2915
|
+
logger.newline();
|
|
2916
|
+
logger.warn(`This will permanently delete ${pc5.bold(String(managed.length))} resource(s).`);
|
|
2917
|
+
logger.newline();
|
|
2918
|
+
if (options.dryRun) {
|
|
2919
|
+
logger.info(pc5.yellow("Dry run \u2014 no resources destroyed."));
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
if (!options.force) {
|
|
2923
|
+
throw new CLIError(
|
|
2924
|
+
"Use --force to confirm destruction, or --dry-run to preview.",
|
|
2925
|
+
1 /* GeneralError */
|
|
2926
|
+
);
|
|
2927
|
+
}
|
|
2928
|
+
logger.info("Destroying resources...");
|
|
2929
|
+
logger.newline();
|
|
2930
|
+
let succeeded = 0;
|
|
2931
|
+
let failed = 0;
|
|
2932
|
+
const deleteOrder = ["test", "monitor", "statusPage", "job", "variable", "tag"];
|
|
2933
|
+
const sorted = [...managed].sort((a, b) => deleteOrder.indexOf(a.type) - deleteOrder.indexOf(b.type));
|
|
2934
|
+
for (const resource of sorted) {
|
|
2935
|
+
try {
|
|
2936
|
+
const endpoint = getApiEndpoint(resource.type);
|
|
2937
|
+
await client.delete(`${endpoint}/${resource.id}`);
|
|
2938
|
+
logger.info(pc5.red(` - ${resource.type}/${resource.name} \u2713`));
|
|
2939
|
+
succeeded++;
|
|
2940
|
+
} catch (err) {
|
|
2941
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2942
|
+
logger.error(` - ${resource.type}/${resource.name} \u2717 ${msg}`);
|
|
2943
|
+
failed++;
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
logger.newline();
|
|
2947
|
+
if (failed > 0) {
|
|
2948
|
+
throw new CLIError(
|
|
2949
|
+
`Destroy completed with errors: ${succeeded} destroyed, ${failed} failed`,
|
|
2950
|
+
1 /* GeneralError */
|
|
2951
|
+
);
|
|
2952
|
+
} else {
|
|
2953
|
+
logger.success(`Destroy complete: ${succeeded} resource(s) removed`);
|
|
2954
|
+
}
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
// src/commands/pull.ts
|
|
2958
|
+
import { Command as Command14 } from "commander";
|
|
2959
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readFileSync as readFileSync3 } from "fs";
|
|
2960
|
+
import { resolve as resolve4, dirname as dirname2 } from "path";
|
|
2961
|
+
import pc6 from "picocolors";
|
|
2962
|
+
function mapTestType(apiType) {
|
|
2963
|
+
if (apiType === "performance" || apiType === "k6") return "k6";
|
|
2964
|
+
return "playwright";
|
|
2965
|
+
}
|
|
2966
|
+
function testFileExtension(testType) {
|
|
2967
|
+
return testType === "k6" ? ".k6.ts" : ".pw.ts";
|
|
2968
|
+
}
|
|
2969
|
+
function testFilename(testId, testType) {
|
|
2970
|
+
const ext = testFileExtension(testType);
|
|
2971
|
+
return `${testId}${ext}`;
|
|
2972
|
+
}
|
|
2973
|
+
function writeIfChanged(filePath, content) {
|
|
2974
|
+
if (existsSync3(filePath)) {
|
|
2975
|
+
const existing = readFileSync3(filePath, "utf-8");
|
|
2976
|
+
if (existing === content) return false;
|
|
2977
|
+
}
|
|
2978
|
+
const dir = dirname2(filePath);
|
|
2979
|
+
if (!existsSync3(dir)) {
|
|
2980
|
+
mkdirSync2(dir, { recursive: true });
|
|
2981
|
+
}
|
|
2982
|
+
writeFileSync2(filePath, content, "utf-8");
|
|
2983
|
+
return true;
|
|
2984
|
+
}
|
|
2985
|
+
function decodeScript(script) {
|
|
2986
|
+
if (!script) return null;
|
|
2987
|
+
try {
|
|
2988
|
+
const trimmed = script.trim();
|
|
2989
|
+
const decoded = Buffer.from(trimmed, "base64").toString("utf-8");
|
|
2990
|
+
const reEncoded = Buffer.from(decoded, "utf-8").toString("base64");
|
|
2991
|
+
if (reEncoded !== trimmed) {
|
|
2992
|
+
return script;
|
|
2993
|
+
}
|
|
2994
|
+
if (/^[\x09\x0a\x0d\x20-\x7e\u00a0-\uffff]*$/.test(decoded) && decoded.length > 0) {
|
|
2995
|
+
return decoded;
|
|
2996
|
+
}
|
|
2997
|
+
} catch {
|
|
2998
|
+
}
|
|
2999
|
+
return script;
|
|
3000
|
+
}
|
|
3001
|
+
async function fetchContext(client) {
|
|
3002
|
+
try {
|
|
3003
|
+
const { data } = await client.get("/api/context");
|
|
3004
|
+
if (data?.organization?.id && data?.project?.id) {
|
|
3005
|
+
return data;
|
|
3006
|
+
}
|
|
3007
|
+
logger.debug("Context response missing organization or project ID");
|
|
3008
|
+
return null;
|
|
3009
|
+
} catch (err) {
|
|
3010
|
+
logger.warn(`Could not fetch project context: ${err instanceof Error ? err.message : String(err)}`);
|
|
3011
|
+
logger.debug("Organization and project will be set to placeholder values. Re-run after fixing connectivity.");
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
async function fetchTests(client) {
|
|
3016
|
+
try {
|
|
3017
|
+
return await fetchAllPages(client, "/api/tests", "tests");
|
|
3018
|
+
} catch (err) {
|
|
3019
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) return [];
|
|
3020
|
+
throw err;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
async function fetchMonitors(client) {
|
|
3024
|
+
try {
|
|
3025
|
+
return await fetchAllPages(client, "/api/monitors", "monitors");
|
|
3026
|
+
} catch (err) {
|
|
3027
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) return [];
|
|
3028
|
+
throw err;
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
async function fetchJobs(client) {
|
|
3032
|
+
try {
|
|
3033
|
+
return await fetchAllPages(client, "/api/jobs", "jobs");
|
|
3034
|
+
} catch (err) {
|
|
3035
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) return [];
|
|
3036
|
+
throw err;
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
async function fetchVariables(client) {
|
|
3040
|
+
try {
|
|
3041
|
+
const { data } = await client.get("/api/variables");
|
|
3042
|
+
return data;
|
|
3043
|
+
} catch (err) {
|
|
3044
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) return [];
|
|
3045
|
+
throw err;
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
async function fetchTags(client) {
|
|
3049
|
+
try {
|
|
3050
|
+
const { data } = await client.get("/api/tags");
|
|
3051
|
+
return data;
|
|
3052
|
+
} catch (err) {
|
|
3053
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) return [];
|
|
3054
|
+
throw err;
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
async function fetchStatusPages(client) {
|
|
3058
|
+
try {
|
|
3059
|
+
return await fetchAllPages(client, "/api/status-pages", "status-pages");
|
|
3060
|
+
} catch (err) {
|
|
3061
|
+
if (err instanceof ApiRequestError && err.statusCode === 404) return [];
|
|
3062
|
+
throw err;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
function pullTests(tests, cwd, summary) {
|
|
3066
|
+
const testsDir = resolve4(cwd, "_supercheck_/tests");
|
|
3067
|
+
if (!existsSync3(testsDir)) {
|
|
3068
|
+
mkdirSync2(testsDir, { recursive: true });
|
|
3069
|
+
}
|
|
3070
|
+
for (const test of tests) {
|
|
3071
|
+
try {
|
|
3072
|
+
const testType = mapTestType(test.type);
|
|
3073
|
+
const filename = testFilename(test.id, testType);
|
|
3074
|
+
const relPath = `_supercheck_/tests/${filename}`;
|
|
3075
|
+
const script = decodeScript(test.script);
|
|
3076
|
+
if (!script) {
|
|
3077
|
+
logger.debug(`Skipping test "${test.title}" \u2014 no script content`);
|
|
3078
|
+
summary.skipped++;
|
|
3079
|
+
continue;
|
|
3080
|
+
}
|
|
3081
|
+
const filePath = resolve4(cwd, relPath);
|
|
3082
|
+
const written = writeIfChanged(filePath, script);
|
|
3083
|
+
if (written) {
|
|
3084
|
+
logger.info(pc6.green(` + ${relPath}`));
|
|
3085
|
+
summary.tests++;
|
|
3086
|
+
} else {
|
|
3087
|
+
logger.debug(` = ${relPath} (unchanged)`);
|
|
3088
|
+
}
|
|
3089
|
+
} catch (err) {
|
|
3090
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3091
|
+
summary.errors.push(`test "${test.title}": ${msg}`);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
function buildMonitorDefinitions(monitors) {
|
|
3096
|
+
return monitors.map((m) => {
|
|
3097
|
+
const def = {
|
|
3098
|
+
id: m.id,
|
|
3099
|
+
name: m.name,
|
|
3100
|
+
type: m.type
|
|
3101
|
+
};
|
|
3102
|
+
if (m.description) def.description = m.description;
|
|
3103
|
+
if (m.target) def.target = m.target;
|
|
3104
|
+
if (m.frequencyMinutes !== void 0) def.frequencyMinutes = m.frequencyMinutes;
|
|
3105
|
+
if (m.enabled !== void 0) def.enabled = m.enabled;
|
|
3106
|
+
if (m.config && typeof m.config === "object") {
|
|
3107
|
+
const config = { ...m.config };
|
|
3108
|
+
delete config.sslLastCheckedAt;
|
|
3109
|
+
if (config.playwrightOptions && typeof config.playwrightOptions === "object") {
|
|
3110
|
+
const opts = { ...config.playwrightOptions };
|
|
3111
|
+
if (opts.retries === 0) delete opts.retries;
|
|
3112
|
+
if (opts.timeout === 3e5) delete opts.timeout;
|
|
3113
|
+
if (opts.headless === true) delete opts.headless;
|
|
3114
|
+
config.playwrightOptions = Object.keys(opts).length > 0 ? opts : void 0;
|
|
3115
|
+
if (!config.playwrightOptions) delete config.playwrightOptions;
|
|
3116
|
+
}
|
|
3117
|
+
if (config.locationConfig && typeof config.locationConfig === "object") {
|
|
3118
|
+
const loc = { ...config.locationConfig };
|
|
3119
|
+
const hasLocations = Array.isArray(loc.locations) && loc.locations.length > 0;
|
|
3120
|
+
if (!loc.enabled || !hasLocations) {
|
|
3121
|
+
delete config.locationConfig;
|
|
3122
|
+
} else {
|
|
3123
|
+
if (loc.enabled === true) delete loc.enabled;
|
|
3124
|
+
if (loc.strategy === "majority") delete loc.strategy;
|
|
3125
|
+
if (loc.threshold === 50) delete loc.threshold;
|
|
3126
|
+
config.locationConfig = Object.keys(loc).length > 0 ? loc : void 0;
|
|
3127
|
+
if (!config.locationConfig) delete config.locationConfig;
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
if (Object.keys(config).length > 0) def.config = config;
|
|
3131
|
+
}
|
|
3132
|
+
if (m.alertConfig && typeof m.alertConfig === "object") {
|
|
3133
|
+
const alert = { ...m.alertConfig };
|
|
3134
|
+
if (alert.customMessage === "" || alert.customMessage === null) delete alert.customMessage;
|
|
3135
|
+
if (alert.failureThreshold === 1) delete alert.failureThreshold;
|
|
3136
|
+
if (alert.recoveryThreshold === 1) delete alert.recoveryThreshold;
|
|
3137
|
+
for (const key of ["alertOnFailure", "alertOnRecovery", "alertOnSslExpiration", "alertOnSuccess", "alertOnTimeout"]) {
|
|
3138
|
+
if (alert[key] === false) delete alert[key];
|
|
3139
|
+
}
|
|
3140
|
+
if (Array.isArray(alert.notificationProviders) && alert.notificationProviders.length === 0) {
|
|
3141
|
+
delete alert.notificationProviders;
|
|
3142
|
+
}
|
|
3143
|
+
if (Object.keys(alert).length > 0) def.alertConfig = alert;
|
|
3144
|
+
}
|
|
3145
|
+
return def;
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
function buildJobDefinitions(jobs, testMap) {
|
|
3149
|
+
return jobs.map((j) => {
|
|
3150
|
+
const def = {
|
|
3151
|
+
id: j.id,
|
|
3152
|
+
name: j.name,
|
|
3153
|
+
tests: (j.tests ?? []).map((t) => {
|
|
3154
|
+
return testMap.get(t.id) ?? t.id;
|
|
3155
|
+
})
|
|
3156
|
+
};
|
|
3157
|
+
if (j.description) def.description = j.description;
|
|
3158
|
+
if (j.jobType) def.jobType = j.jobType;
|
|
3159
|
+
if (j.cronSchedule) def.cronSchedule = j.cronSchedule;
|
|
3160
|
+
if (j.alertConfig && typeof j.alertConfig === "object") {
|
|
3161
|
+
const alert = { ...j.alertConfig };
|
|
3162
|
+
if (alert.customMessage === "" || alert.customMessage === null) delete alert.customMessage;
|
|
3163
|
+
if (alert.failureThreshold === 1) delete alert.failureThreshold;
|
|
3164
|
+
if (alert.recoveryThreshold === 1) delete alert.recoveryThreshold;
|
|
3165
|
+
for (const key of ["alertOnFailure", "alertOnRecovery", "alertOnSslExpiration", "alertOnSuccess", "alertOnTimeout"]) {
|
|
3166
|
+
if (alert[key] === false) delete alert[key];
|
|
3167
|
+
}
|
|
3168
|
+
if (Array.isArray(alert.notificationProviders) && alert.notificationProviders.length === 0) {
|
|
3169
|
+
delete alert.notificationProviders;
|
|
3170
|
+
}
|
|
3171
|
+
if (Object.keys(alert).length > 0) def.alertConfig = alert;
|
|
3172
|
+
}
|
|
3173
|
+
return def;
|
|
3174
|
+
});
|
|
3175
|
+
}
|
|
3176
|
+
function buildVariableDefinitions(variables) {
|
|
3177
|
+
return variables.map((v) => {
|
|
3178
|
+
const def = {
|
|
3179
|
+
id: v.id,
|
|
3180
|
+
key: v.key,
|
|
3181
|
+
isSecret: v.isSecret
|
|
3182
|
+
};
|
|
3183
|
+
if (v.description) def.description = v.description;
|
|
3184
|
+
if (v.isSecret) {
|
|
3185
|
+
const envVarName = v.key.toUpperCase();
|
|
3186
|
+
const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(envVarName);
|
|
3187
|
+
def.value = isValidIdentifier ? `\${${envVarName}}` : `\${[${envVarName}]}`;
|
|
3188
|
+
} else {
|
|
3189
|
+
def.value = v.value ?? "";
|
|
3190
|
+
}
|
|
3191
|
+
return def;
|
|
3192
|
+
});
|
|
3193
|
+
}
|
|
3194
|
+
function buildTagDefinitions(tags) {
|
|
3195
|
+
return tags.map((t) => {
|
|
3196
|
+
const def = {
|
|
3197
|
+
id: t.id,
|
|
3198
|
+
name: t.name
|
|
3199
|
+
};
|
|
3200
|
+
if (t.color) def.color = t.color;
|
|
3201
|
+
return def;
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
function buildStatusPageDefinitions(pages) {
|
|
3205
|
+
return pages.map((p) => {
|
|
3206
|
+
const def = {
|
|
3207
|
+
id: p.id,
|
|
3208
|
+
name: p.name
|
|
3209
|
+
};
|
|
3210
|
+
if (p.subdomain) def.subdomain = p.subdomain;
|
|
3211
|
+
if (p.status) def.status = p.status;
|
|
3212
|
+
if (p.pageDescription) def.description = p.pageDescription;
|
|
3213
|
+
if (p.headline) def.headline = p.headline;
|
|
3214
|
+
if (p.supportUrl) def.supportUrl = p.supportUrl;
|
|
3215
|
+
return def;
|
|
3216
|
+
});
|
|
3217
|
+
}
|
|
3218
|
+
function generateConfigContent(opts) {
|
|
3219
|
+
const parts = [];
|
|
3220
|
+
parts.push(`import { defineConfig } from '@supercheck/cli'`);
|
|
3221
|
+
parts.push("");
|
|
3222
|
+
parts.push("export default defineConfig({");
|
|
3223
|
+
parts.push(` schemaVersion: '1.0',`);
|
|
3224
|
+
parts.push(" project: {");
|
|
3225
|
+
parts.push(` organization: '${opts.orgId}',`);
|
|
3226
|
+
parts.push(` project: '${opts.projectId}',`);
|
|
3227
|
+
parts.push(" },");
|
|
3228
|
+
parts.push(" api: {");
|
|
3229
|
+
parts.push(` baseUrl: process.env.SUPERCHECK_URL ?? '${opts.baseUrl}',`);
|
|
3230
|
+
parts.push(" },");
|
|
3231
|
+
parts.push(" tests: {");
|
|
3232
|
+
parts.push(" playwright: {");
|
|
3233
|
+
parts.push(` testMatch: '_supercheck_/tests/**/*.pw.ts',`);
|
|
3234
|
+
parts.push(" },");
|
|
3235
|
+
parts.push(" k6: {");
|
|
3236
|
+
parts.push(` testMatch: '_supercheck_/tests/**/*.k6.ts',`);
|
|
3237
|
+
parts.push(" },");
|
|
3238
|
+
parts.push(" },");
|
|
3239
|
+
if (opts.monitors.length > 0) {
|
|
3240
|
+
parts.push(` monitors: ${formatArray(opts.monitors, 2)},`);
|
|
3241
|
+
}
|
|
3242
|
+
if (opts.jobs.length > 0) {
|
|
3243
|
+
parts.push(` jobs: ${formatArray(opts.jobs, 2)},`);
|
|
3244
|
+
}
|
|
3245
|
+
if (opts.variables.length > 0) {
|
|
3246
|
+
parts.push(` variables: ${formatArray(opts.variables, 2)},`);
|
|
3247
|
+
}
|
|
3248
|
+
if (opts.tags.length > 0) {
|
|
3249
|
+
parts.push(` tags: ${formatArray(opts.tags, 2)},`);
|
|
3250
|
+
}
|
|
3251
|
+
if (opts.statusPages.length > 0) {
|
|
3252
|
+
parts.push(` statusPages: ${formatArray(opts.statusPages, 2)},`);
|
|
3253
|
+
}
|
|
3254
|
+
parts.push("})");
|
|
3255
|
+
parts.push("");
|
|
3256
|
+
return parts.join("\n");
|
|
3257
|
+
}
|
|
3258
|
+
function formatArray(arr, baseIndent) {
|
|
3259
|
+
const indent = " ".repeat(baseIndent);
|
|
3260
|
+
const innerIndent = " ".repeat(baseIndent + 1);
|
|
3261
|
+
if (arr.length === 0) return "[]";
|
|
3262
|
+
const items = arr.map((obj) => {
|
|
3263
|
+
const fields = Object.entries(obj).map(([key, value]) => `${innerIndent} ${key}: ${formatValue2(value, baseIndent + 2)},`).join("\n");
|
|
3264
|
+
return `${innerIndent}{
|
|
3265
|
+
${fields}
|
|
3266
|
+
${innerIndent}}`;
|
|
3267
|
+
});
|
|
3268
|
+
return `[
|
|
3269
|
+
${items.join(",\n")}
|
|
3270
|
+
${indent}]`;
|
|
3271
|
+
}
|
|
3272
|
+
function formatValue2(value, indent = 0) {
|
|
3273
|
+
if (typeof value === "string") {
|
|
3274
|
+
if (value.startsWith("${") && value.endsWith("}")) {
|
|
3275
|
+
const inner = value.slice(2, -1);
|
|
3276
|
+
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
3277
|
+
const varName = inner.slice(1, -1);
|
|
3278
|
+
return `process.env['${varName.replace(/'/g, "\\'")}'] ?? ''`;
|
|
3279
|
+
}
|
|
3280
|
+
return `process.env.${inner} ?? ''`;
|
|
3281
|
+
}
|
|
3282
|
+
return `'${value.replace(/'/g, "\\'")}'`;
|
|
3283
|
+
}
|
|
3284
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
3285
|
+
if (value === null || value === void 0) return "undefined";
|
|
3286
|
+
if (Array.isArray(value)) {
|
|
3287
|
+
if (value.length === 0) return "[]";
|
|
3288
|
+
if (value.every((v) => typeof v === "string")) {
|
|
3289
|
+
return `[${value.map((v) => `'${String(v).replace(/'/g, "\\'")}'`).join(", ")}]`;
|
|
3290
|
+
}
|
|
3291
|
+
const innerIndent = " ".repeat(indent + 1);
|
|
3292
|
+
const items = value.map((v) => `${innerIndent}${formatValue2(v, indent + 1)}`);
|
|
3293
|
+
return `[
|
|
3294
|
+
${items.join(",\n")}
|
|
3295
|
+
${" ".repeat(indent)}]`;
|
|
3296
|
+
}
|
|
3297
|
+
if (typeof value === "object") {
|
|
3298
|
+
const entries = Object.entries(value);
|
|
3299
|
+
if (entries.length === 0) return "{}";
|
|
3300
|
+
const innerIndent = " ".repeat(indent + 1);
|
|
3301
|
+
const fields = entries.map(([key, v]) => `${innerIndent}${key}: ${formatValue2(v, indent + 1)}`).join(",\n");
|
|
3302
|
+
return `{
|
|
3303
|
+
${fields},
|
|
3304
|
+
${" ".repeat(indent)}}`;
|
|
3305
|
+
}
|
|
3306
|
+
return String(value);
|
|
3307
|
+
}
|
|
3308
|
+
var pullCommand = new Command14("pull").description("Pull tests, monitors, jobs, status pages, and config from the Supercheck cloud into the local project").option("--config <path>", "Path to config file").option("--force", "Overwrite existing local files without prompting").option("--tests-only", "Only pull test scripts").option("--config-only", "Only pull config (monitors, jobs, variables, tags, status pages)").option("--dry-run", "Show what would be pulled without writing files").action(async (options) => {
|
|
3309
|
+
if (options.testsOnly && options.configOnly) {
|
|
3310
|
+
throw new CLIError(
|
|
3311
|
+
"--tests-only and --config-only are mutually exclusive. Use one or neither.",
|
|
3312
|
+
3 /* ConfigError */
|
|
3313
|
+
);
|
|
3314
|
+
}
|
|
3315
|
+
const cwd = process.cwd();
|
|
3316
|
+
const client = createAuthenticatedClient();
|
|
3317
|
+
const existing = await tryLoadConfig({ cwd, configPath: options.config });
|
|
3318
|
+
logger.newline();
|
|
3319
|
+
logger.header("Pulling from Supercheck cloud...");
|
|
3320
|
+
logger.newline();
|
|
3321
|
+
const [context, tests, monitors, jobs, variables, tags, statusPages] = await withSpinner(
|
|
3322
|
+
"Fetching remote resources...",
|
|
3323
|
+
async () => {
|
|
3324
|
+
const results = await Promise.all([
|
|
3325
|
+
fetchContext(client),
|
|
3326
|
+
options.configOnly ? Promise.resolve([]) : fetchTests(client),
|
|
3327
|
+
options.testsOnly ? Promise.resolve([]) : fetchMonitors(client),
|
|
3328
|
+
options.testsOnly ? Promise.resolve([]) : fetchJobs(client),
|
|
3329
|
+
options.testsOnly ? Promise.resolve([]) : fetchVariables(client),
|
|
3330
|
+
options.testsOnly ? Promise.resolve([]) : fetchTags(client),
|
|
3331
|
+
options.testsOnly ? Promise.resolve([]) : fetchStatusPages(client)
|
|
3332
|
+
]);
|
|
3333
|
+
return results;
|
|
3334
|
+
},
|
|
3335
|
+
{ successText: "Fetched remote resources" }
|
|
3336
|
+
);
|
|
3337
|
+
logger.debug(`Found: ${tests.length} tests, ${monitors.length} monitors, ${jobs.length} jobs, ${variables.length} variables, ${tags.length} tags, ${statusPages.length} status pages`);
|
|
3338
|
+
const totalResources = tests.length + monitors.length + jobs.length + variables.length + tags.length + statusPages.length;
|
|
3339
|
+
if (totalResources === 0) {
|
|
3340
|
+
logger.warn("No resources found on the remote project.");
|
|
3341
|
+
logger.info("Hint: Create tests and monitors in the Supercheck dashboard, then run `supercheck pull` again.");
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
if (options.dryRun) {
|
|
3345
|
+
logger.newline();
|
|
3346
|
+
logger.header("Resources that would be pulled:");
|
|
3347
|
+
logger.newline();
|
|
3348
|
+
if (tests.length > 0) {
|
|
3349
|
+
logger.info(pc6.cyan(` Tests (${tests.length}):`));
|
|
3350
|
+
for (const t of tests) {
|
|
3351
|
+
const testType = mapTestType(t.type);
|
|
3352
|
+
logger.info(` ${t.title} (${testType})`);
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
if (monitors.length > 0) {
|
|
3356
|
+
logger.info(pc6.cyan(` Monitors (${monitors.length}):`));
|
|
3357
|
+
for (const m of monitors) logger.info(` ${m.name} (${m.type}, every ${m.frequencyMinutes ?? "?"}min)`);
|
|
3358
|
+
}
|
|
3359
|
+
if (jobs.length > 0) {
|
|
3360
|
+
logger.info(pc6.cyan(` Jobs (${jobs.length}):`));
|
|
3361
|
+
for (const j of jobs) logger.info(` ${j.name}${j.cronSchedule ? ` (${j.cronSchedule})` : ""}`);
|
|
3362
|
+
}
|
|
3363
|
+
if (variables.length > 0) {
|
|
3364
|
+
logger.info(pc6.cyan(` Variables (${variables.length}):`));
|
|
3365
|
+
for (const v of variables) logger.info(` ${v.key}${v.isSecret ? " (secret)" : ""}`);
|
|
3366
|
+
}
|
|
3367
|
+
if (tags.length > 0) {
|
|
3368
|
+
logger.info(pc6.cyan(` Tags (${tags.length}):`));
|
|
3369
|
+
for (const t of tags) logger.info(` ${t.name}`);
|
|
3370
|
+
}
|
|
3371
|
+
if (statusPages.length > 0) {
|
|
3372
|
+
logger.info(pc6.cyan(` Status Pages (${statusPages.length}):`));
|
|
3373
|
+
for (const sp of statusPages) logger.info(` ${sp.name} (${sp.status ?? "draft"})`);
|
|
3374
|
+
}
|
|
3375
|
+
logger.newline();
|
|
3376
|
+
logger.info(pc6.yellow("Dry run \u2014 no files written."));
|
|
3377
|
+
logger.newline();
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
if (!options.force) {
|
|
3381
|
+
logger.info(`Found ${pc6.bold(String(totalResources))} resources to pull.`);
|
|
3382
|
+
logger.info("This will write test scripts and update supercheck.config.ts.");
|
|
3383
|
+
logger.newline();
|
|
3384
|
+
const { createInterface } = await import("readline");
|
|
3385
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3386
|
+
const answer = await new Promise((resolvePrompt) => {
|
|
3387
|
+
rl.question("Continue? (y/N) ", resolvePrompt);
|
|
3388
|
+
});
|
|
3389
|
+
rl.close();
|
|
3390
|
+
if (!answer || answer.toLowerCase() !== "y") {
|
|
3391
|
+
logger.info("Pull aborted.");
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
logger.newline();
|
|
3395
|
+
}
|
|
3396
|
+
const summary = {
|
|
3397
|
+
tests: 0,
|
|
3398
|
+
monitors: 0,
|
|
3399
|
+
jobs: 0,
|
|
3400
|
+
variables: 0,
|
|
3401
|
+
tags: 0,
|
|
3402
|
+
statusPages: 0,
|
|
3403
|
+
skipped: 0,
|
|
3404
|
+
errors: []
|
|
3405
|
+
};
|
|
3406
|
+
if (!options.configOnly && tests.length > 0) {
|
|
3407
|
+
logger.header("Tests:");
|
|
3408
|
+
const fullTests = [];
|
|
3409
|
+
for (const t of tests) {
|
|
3410
|
+
if (t.script) {
|
|
3411
|
+
fullTests.push(t);
|
|
3412
|
+
} else {
|
|
3413
|
+
try {
|
|
3414
|
+
const { data } = await client.get(
|
|
3415
|
+
`/api/tests/${t.id}`,
|
|
3416
|
+
{ includeScript: "true" }
|
|
3417
|
+
);
|
|
3418
|
+
fullTests.push(data);
|
|
3419
|
+
} catch (err) {
|
|
3420
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3421
|
+
logger.warn(` Could not fetch script for "${t.title}": ${msg}`);
|
|
3422
|
+
summary.errors.push(`test "${t.title}": ${msg}`);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
pullTests(fullTests, cwd, summary);
|
|
3427
|
+
logger.newline();
|
|
3428
|
+
}
|
|
3429
|
+
if (!options.testsOnly) {
|
|
3430
|
+
const testMap = /* @__PURE__ */ new Map();
|
|
3431
|
+
for (const t of tests) {
|
|
3432
|
+
const testType = mapTestType(t.type);
|
|
3433
|
+
const filePath = `_supercheck_/tests/${testFilename(t.id, testType)}`;
|
|
3434
|
+
testMap.set(t.id, filePath);
|
|
3435
|
+
}
|
|
3436
|
+
const monitorDefs = buildMonitorDefinitions(monitors);
|
|
3437
|
+
const jobDefs = buildJobDefinitions(jobs, testMap);
|
|
3438
|
+
const variableDefs = buildVariableDefinitions(variables);
|
|
3439
|
+
const tagDefs = buildTagDefinitions(tags);
|
|
3440
|
+
const statusPageDefs = buildStatusPageDefinitions(statusPages);
|
|
3441
|
+
summary.monitors = monitors.length;
|
|
3442
|
+
summary.jobs = jobs.length;
|
|
3443
|
+
summary.variables = variables.length;
|
|
3444
|
+
summary.tags = tags.length;
|
|
3445
|
+
summary.statusPages = statusPages.length;
|
|
3446
|
+
const existingOrg = existing?.config?.project?.organization;
|
|
3447
|
+
const existingProject = existing?.config?.project?.project;
|
|
3448
|
+
const firstMonitor = monitors[0];
|
|
3449
|
+
const orgId = context?.organization?.id ?? firstMonitor?.organizationId ?? getStoredOrganization() ?? (existingOrg && !existingOrg.startsWith("unknown") ? existingOrg : null) ?? "unknown-org";
|
|
3450
|
+
const projectId = context?.project?.id ?? firstMonitor?.projectId ?? getStoredProject() ?? (existingProject && !existingProject.startsWith("unknown") ? existingProject : null) ?? "unknown-project";
|
|
3451
|
+
const baseUrl = existing?.config?.api?.baseUrl ?? getStoredBaseUrl() ?? "https://app.supercheck.io";
|
|
3452
|
+
const configContent = generateConfigContent({
|
|
3453
|
+
orgId,
|
|
3454
|
+
projectId,
|
|
3455
|
+
baseUrl,
|
|
3456
|
+
monitors: monitorDefs,
|
|
3457
|
+
jobs: jobDefs,
|
|
3458
|
+
variables: variableDefs,
|
|
3459
|
+
tags: tagDefs,
|
|
3460
|
+
statusPages: statusPageDefs
|
|
3461
|
+
});
|
|
3462
|
+
const configPath = resolve4(cwd, "supercheck.config.ts");
|
|
3463
|
+
const configChanged = writeIfChanged(configPath, configContent);
|
|
3464
|
+
if (configChanged) {
|
|
3465
|
+
logger.header("Config:");
|
|
3466
|
+
if (monitorDefs.length > 0) logger.info(pc6.green(` + ${monitorDefs.length} monitor(s)`));
|
|
3467
|
+
if (jobDefs.length > 0) logger.info(pc6.green(` + ${jobDefs.length} job(s)`));
|
|
3468
|
+
if (variableDefs.length > 0) logger.info(pc6.green(` + ${variableDefs.length} variable(s)`));
|
|
3469
|
+
if (tagDefs.length > 0) logger.info(pc6.green(` + ${tagDefs.length} tag(s)`));
|
|
3470
|
+
if (statusPageDefs.length > 0) logger.info(pc6.green(` + ${statusPageDefs.length} status page(s)`));
|
|
3471
|
+
logger.success("Updated supercheck.config.ts");
|
|
3472
|
+
} else {
|
|
3473
|
+
logger.info("supercheck.config.ts is already up to date");
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
logger.newline();
|
|
3477
|
+
const totalWritten = summary.tests + summary.monitors + summary.jobs + summary.variables + summary.tags + summary.statusPages;
|
|
3478
|
+
const totalErrors = summary.errors.length;
|
|
3479
|
+
if (totalErrors > 0) {
|
|
3480
|
+
logger.header("Errors:");
|
|
3481
|
+
for (const err of summary.errors) {
|
|
3482
|
+
logger.error(` ${err}`);
|
|
3483
|
+
}
|
|
3484
|
+
logger.newline();
|
|
3485
|
+
}
|
|
3486
|
+
if (totalWritten === 0 && summary.skipped === 0 && totalErrors === 0) {
|
|
3487
|
+
logger.success("Everything is already in sync.");
|
|
3488
|
+
} else {
|
|
3489
|
+
const resultParts = [];
|
|
3490
|
+
if (summary.tests > 0) resultParts.push(`${summary.tests} test(s)`);
|
|
3491
|
+
if (summary.monitors > 0) resultParts.push(`${summary.monitors} monitor(s)`);
|
|
3492
|
+
if (summary.jobs > 0) resultParts.push(`${summary.jobs} job(s)`);
|
|
3493
|
+
if (summary.variables > 0) resultParts.push(`${summary.variables} variable(s)`);
|
|
3494
|
+
if (summary.tags > 0) resultParts.push(`${summary.tags} tag(s)`);
|
|
3495
|
+
if (summary.statusPages > 0) resultParts.push(`${summary.statusPages} status page(s)`);
|
|
3496
|
+
if (summary.skipped > 0) resultParts.push(`${summary.skipped} skipped`);
|
|
3497
|
+
if (resultParts.length > 0) {
|
|
3498
|
+
logger.success(`Pull complete: ${resultParts.join(", ")}`);
|
|
3499
|
+
}
|
|
3500
|
+
if (totalErrors > 0) {
|
|
3501
|
+
throw new CLIError(
|
|
3502
|
+
`Pull completed with ${totalErrors} error(s)`,
|
|
3503
|
+
1 /* GeneralError */
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
logger.newline();
|
|
3508
|
+
logger.info("Tip: Review the changes, then commit to version control.");
|
|
3509
|
+
logger.info(" Run `supercheck diff` to compare local vs remote.");
|
|
3510
|
+
logger.newline();
|
|
3511
|
+
});
|
|
3512
|
+
|
|
3513
|
+
// src/commands/notifications.ts
|
|
3514
|
+
import { Command as Command15 } from "commander";
|
|
3515
|
+
var notificationCommand = new Command15("notification").alias("notifications").description("Manage notification providers");
|
|
3516
|
+
notificationCommand.command("list").description("List notification providers").action(async () => {
|
|
3517
|
+
const client = createAuthenticatedClient();
|
|
3518
|
+
const { data } = await client.get("/api/notification-providers");
|
|
3519
|
+
output(data, {
|
|
3520
|
+
columns: [
|
|
3521
|
+
{ key: "id", header: "ID" },
|
|
3522
|
+
{ key: "name", header: "Name" },
|
|
3523
|
+
{ key: "type", header: "Type" },
|
|
3524
|
+
{ key: "enabled", header: "Enabled" },
|
|
3525
|
+
{ key: "lastUsed", header: "Last Used" }
|
|
3526
|
+
]
|
|
3527
|
+
});
|
|
3528
|
+
});
|
|
3529
|
+
notificationCommand.command("get <id>").description("Get notification provider details").action(async (id) => {
|
|
3530
|
+
const client = createAuthenticatedClient();
|
|
3531
|
+
const { data } = await client.get(`/api/notification-providers/${id}`);
|
|
3532
|
+
outputDetail(data);
|
|
3533
|
+
});
|
|
3534
|
+
notificationCommand.command("create").description("Create a notification provider").requiredOption("--type <type>", "Provider type (email, slack, webhook, telegram, discord, teams)").requiredOption("--name <name>", "Provider name").option("--config <json>", "Provider config as JSON string").action(async (options) => {
|
|
3535
|
+
const validTypes = ["email", "slack", "webhook", "telegram", "discord", "teams"];
|
|
3536
|
+
if (!validTypes.includes(options.type)) {
|
|
3537
|
+
throw new CLIError(
|
|
3538
|
+
`Invalid provider type. Must be one of: ${validTypes.join(", ")}`,
|
|
3539
|
+
1 /* GeneralError */
|
|
3540
|
+
);
|
|
3541
|
+
}
|
|
3542
|
+
let config = {};
|
|
3543
|
+
if (options.config) {
|
|
3544
|
+
try {
|
|
3545
|
+
config = JSON.parse(options.config);
|
|
3546
|
+
} catch {
|
|
3547
|
+
throw new CLIError("Invalid JSON in --config", 1 /* GeneralError */);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
const client = createAuthenticatedClient();
|
|
3551
|
+
const { data } = await client.post("/api/notification-providers", {
|
|
3552
|
+
name: options.name,
|
|
3553
|
+
type: options.type,
|
|
3554
|
+
config: { name: options.name, ...config }
|
|
3555
|
+
});
|
|
3556
|
+
logger.success(`Notification provider "${options.name}" created (${data.id})`);
|
|
3557
|
+
outputDetail(data);
|
|
3558
|
+
});
|
|
3559
|
+
notificationCommand.command("update <id>").description("Update a notification provider").option("--name <name>", "Provider name").option("--type <type>", "Provider type").option("--config <json>", "Provider config as JSON string").action(async (id, options) => {
|
|
3560
|
+
const client = createAuthenticatedClient();
|
|
3561
|
+
if (!options.name && !options.type && !options.config) {
|
|
3562
|
+
logger.warn("No fields to update. Use --name, --type, or --config.");
|
|
3563
|
+
return;
|
|
3564
|
+
}
|
|
3565
|
+
const { data: existing } = await client.get(`/api/notification-providers/${id}`);
|
|
3566
|
+
let updatedConfig = existing.config ?? {};
|
|
3567
|
+
if (options.config) {
|
|
3568
|
+
try {
|
|
3569
|
+
updatedConfig = { ...updatedConfig, ...JSON.parse(options.config) };
|
|
3570
|
+
} catch {
|
|
3571
|
+
throw new CLIError("Invalid JSON in --config", 1 /* GeneralError */);
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
const updatedName = options.name ?? String(existing.name ?? "");
|
|
3575
|
+
const updatedType = options.type ?? String(existing.type ?? "");
|
|
3576
|
+
updatedConfig.name = updatedName;
|
|
3577
|
+
const body = {
|
|
3578
|
+
name: updatedName,
|
|
3579
|
+
type: updatedType,
|
|
3580
|
+
config: updatedConfig
|
|
3581
|
+
};
|
|
3582
|
+
const { data } = await client.put(`/api/notification-providers/${id}`, body);
|
|
3583
|
+
logger.success(`Notification provider ${id} updated`);
|
|
3584
|
+
outputDetail(data);
|
|
3585
|
+
});
|
|
3586
|
+
notificationCommand.command("delete <id>").description("Delete a notification provider").option("--force", "Skip confirmation").action(async (id, options) => {
|
|
3587
|
+
if (!options.force) {
|
|
3588
|
+
const { createInterface } = await import("readline");
|
|
3589
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3590
|
+
const answer = await new Promise((resolve5) => {
|
|
3591
|
+
rl.question(`Are you sure you want to delete notification provider ${id}? (y/N) `, resolve5);
|
|
3592
|
+
});
|
|
3593
|
+
rl.close();
|
|
3594
|
+
if (answer.toLowerCase() !== "y") {
|
|
3595
|
+
logger.info("Aborted");
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
const client = createAuthenticatedClient();
|
|
3600
|
+
await client.delete(`/api/notification-providers/${id}`);
|
|
3601
|
+
logger.success(`Notification provider ${id} deleted`);
|
|
3602
|
+
});
|
|
3603
|
+
notificationCommand.command("test").description("Send a test notification to verify provider configuration").requiredOption("--type <type>", "Provider type (email, slack, webhook, telegram, discord, teams)").requiredOption("--config <json>", "Provider config as JSON string").action(async (options) => {
|
|
3604
|
+
const validTypes = ["email", "slack", "webhook", "telegram", "discord", "teams"];
|
|
3605
|
+
if (!validTypes.includes(options.type)) {
|
|
3606
|
+
throw new CLIError(
|
|
3607
|
+
`Invalid provider type. Must be one of: ${validTypes.join(", ")}`,
|
|
3608
|
+
1 /* GeneralError */
|
|
3609
|
+
);
|
|
3610
|
+
}
|
|
3611
|
+
let config = {};
|
|
3612
|
+
try {
|
|
3613
|
+
config = JSON.parse(options.config);
|
|
3614
|
+
} catch {
|
|
3615
|
+
throw new CLIError("Invalid JSON in --config", 1 /* GeneralError */);
|
|
3616
|
+
}
|
|
3617
|
+
const client = createAuthenticatedClient();
|
|
3618
|
+
const { data } = await client.post(
|
|
3619
|
+
"/api/notification-providers/test",
|
|
3620
|
+
{ type: options.type, config }
|
|
3621
|
+
);
|
|
3622
|
+
if (data.success) {
|
|
3623
|
+
logger.success(data.message ?? "Test notification sent successfully");
|
|
3624
|
+
} else {
|
|
3625
|
+
throw new CLIError(data.error ?? "Test notification failed", 1 /* GeneralError */);
|
|
3626
|
+
}
|
|
3627
|
+
});
|
|
3628
|
+
|
|
3629
|
+
// src/commands/alerts.ts
|
|
3630
|
+
import { Command as Command16 } from "commander";
|
|
3631
|
+
var alertCommand = new Command16("alert").alias("alerts").description("View alert history");
|
|
3632
|
+
alertCommand.command("history").description("Get alert history").option("--page <page>", "Page number", "1").option("--limit <limit>", "Number of results per page", "50").action(async (options) => {
|
|
3633
|
+
const client = createAuthenticatedClient();
|
|
3634
|
+
const { data } = await client.get("/api/alerts/history", { page: options.page, limit: options.limit });
|
|
3635
|
+
const items = Array.isArray(data) ? data : data.data;
|
|
3636
|
+
const pagination = Array.isArray(data) ? null : data.pagination;
|
|
3637
|
+
output(items, {
|
|
3638
|
+
columns: [
|
|
3639
|
+
{ key: "id", header: "ID" },
|
|
3640
|
+
{ key: "targetType", header: "Target Type" },
|
|
3641
|
+
{ key: "targetName", header: "Target" },
|
|
3642
|
+
{ key: "type", header: "Alert Type" },
|
|
3643
|
+
{ key: "status", header: "Status" },
|
|
3644
|
+
{ key: "timestamp", header: "Time" }
|
|
3645
|
+
]
|
|
3646
|
+
});
|
|
3647
|
+
if (pagination) {
|
|
3648
|
+
logger.info(
|
|
3649
|
+
`
|
|
3650
|
+
Page ${pagination.page}/${pagination.totalPages} (${pagination.total} total)`
|
|
3651
|
+
);
|
|
3652
|
+
}
|
|
3653
|
+
});
|
|
3654
|
+
|
|
3655
|
+
// src/commands/audit.ts
|
|
3656
|
+
import { Command as Command17 } from "commander";
|
|
3657
|
+
var auditCommand = new Command17("audit").description("View audit logs (admin)").option("--page <page>", "Page number", "1").option("--limit <limit>", "Items per page", "20").option("--search <query>", "Search by action").option("--action <action>", "Filter by action type").action(async (options) => {
|
|
3658
|
+
const client = createAuthenticatedClient();
|
|
3659
|
+
const params = {
|
|
3660
|
+
page: options.page,
|
|
3661
|
+
limit: options.limit
|
|
3662
|
+
};
|
|
3663
|
+
if (options.search) params.search = options.search;
|
|
3664
|
+
if (options.action) params.action = options.action;
|
|
3665
|
+
const { data } = await client.get("/api/audit", params);
|
|
3666
|
+
if (!data.success) {
|
|
3667
|
+
throw new CLIError("Failed to fetch audit logs", 4 /* ApiError */);
|
|
3668
|
+
}
|
|
3669
|
+
const rows = data.data.logs.map((log) => ({
|
|
3670
|
+
id: log.id,
|
|
3671
|
+
action: log.action,
|
|
3672
|
+
user: log.user?.email ?? log.user?.name ?? "-",
|
|
3673
|
+
createdAt: log.createdAt
|
|
3674
|
+
}));
|
|
3675
|
+
output(rows, {
|
|
3676
|
+
columns: [
|
|
3677
|
+
{ key: "id", header: "ID" },
|
|
3678
|
+
{ key: "action", header: "Action" },
|
|
3679
|
+
{ key: "user", header: "User" },
|
|
3680
|
+
{ key: "createdAt", header: "Date" }
|
|
3681
|
+
]
|
|
3682
|
+
});
|
|
3683
|
+
const { pagination } = data.data;
|
|
3684
|
+
if (pagination) {
|
|
3685
|
+
logger.info(
|
|
3686
|
+
`
|
|
3687
|
+
Page ${pagination.currentPage}/${pagination.totalPages} (${pagination.totalCount} total)`
|
|
3688
|
+
);
|
|
3689
|
+
}
|
|
3690
|
+
});
|
|
3691
|
+
|
|
3692
|
+
// src/bin/supercheck.ts
|
|
3693
|
+
var program = new Command18().name("supercheck").description("Open-source testing, monitoring, and reliability \u2014 as code").version(CLI_VERSION, "-v, --version").option("--json", "Output in JSON format").option("--quiet", "Suppress non-essential output").option("--debug", "Enable debug logging").hook("preAction", (_thisCommand, actionCommand) => {
|
|
3694
|
+
const opts = program.opts();
|
|
3695
|
+
if (opts.json) {
|
|
3696
|
+
setOutputFormat("json");
|
|
3697
|
+
}
|
|
3698
|
+
if (opts.quiet) {
|
|
3699
|
+
setQuietMode(true);
|
|
3700
|
+
setOutputFormat("quiet");
|
|
3701
|
+
}
|
|
3702
|
+
if (opts.debug) {
|
|
3703
|
+
setLogLevel("debug");
|
|
3704
|
+
}
|
|
3705
|
+
void actionCommand;
|
|
3706
|
+
});
|
|
3707
|
+
program.addCommand(loginCommand);
|
|
3708
|
+
program.addCommand(logoutCommand);
|
|
3709
|
+
program.addCommand(whoamiCommand);
|
|
3710
|
+
program.addCommand(initCommand);
|
|
3711
|
+
program.addCommand(configCommand);
|
|
3712
|
+
program.addCommand(jobCommand);
|
|
3713
|
+
program.addCommand(runCommand);
|
|
3714
|
+
program.addCommand(testCommand);
|
|
3715
|
+
program.addCommand(monitorCommand);
|
|
3716
|
+
program.addCommand(varCommand);
|
|
3717
|
+
program.addCommand(tagCommand);
|
|
3718
|
+
program.addCommand(diffCommand);
|
|
3719
|
+
program.addCommand(deployCommand);
|
|
3720
|
+
program.addCommand(destroyCommand);
|
|
3721
|
+
program.addCommand(pullCommand);
|
|
3722
|
+
program.addCommand(notificationCommand);
|
|
3723
|
+
program.addCommand(alertCommand);
|
|
3724
|
+
program.addCommand(auditCommand);
|
|
3725
|
+
program.addCommand(healthCommand);
|
|
3726
|
+
program.addCommand(locationsCommand);
|
|
3727
|
+
program.exitOverride();
|
|
3728
|
+
async function main() {
|
|
3729
|
+
try {
|
|
3730
|
+
await program.parseAsync(process.argv);
|
|
3731
|
+
} catch (err) {
|
|
3732
|
+
if (err instanceof CLIError) {
|
|
3733
|
+
if (err.exitCode !== 0 /* Success */) {
|
|
3734
|
+
console.error(pc7.red(`
|
|
3735
|
+
\u2717 ${err.message}
|
|
3736
|
+
`));
|
|
3737
|
+
}
|
|
3738
|
+
process.exit(err.exitCode);
|
|
3739
|
+
}
|
|
3740
|
+
if (err && typeof err === "object" && "exitCode" in err && typeof err.exitCode === "number") {
|
|
3741
|
+
process.exit(err.exitCode);
|
|
3742
|
+
}
|
|
3743
|
+
console.error(pc7.red(`
|
|
3744
|
+
\u2717 Unexpected error: ${err instanceof Error ? err.message : String(err)}
|
|
3745
|
+
`));
|
|
3746
|
+
process.exit(1 /* GeneralError */);
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
main();
|
|
3750
|
+
//# sourceMappingURL=supercheck.js.map
|