@supercheck/cli 0.1.1-beta.2 → 0.1.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,6 +44,12 @@ var TimeoutError = class extends CLIError {
44
44
  this.name = "TimeoutError";
45
45
  }
46
46
  };
47
+ var ConfigNotFoundError = class extends CLIError {
48
+ constructor(message = "No supercheck.config.ts found. Run `supercheck init` to create one.") {
49
+ super(message, 3 /* ConfigError */);
50
+ this.name = "ConfigNotFoundError";
51
+ }
52
+ };
47
53
 
48
54
  // src/utils/logger.ts
49
55
  import pc from "picocolors";
@@ -216,7 +222,7 @@ function requireTriggerKey() {
216
222
  }
217
223
 
218
224
  // src/version.ts
219
- var CLI_VERSION = true ? "0.1.1-beta.2" : "0.0.0-dev";
225
+ var CLI_VERSION = true ? "0.1.1-beta.3" : "0.0.0-dev";
220
226
 
221
227
  // src/utils/proxy.ts
222
228
  import { ProxyAgent } from "undici";
@@ -314,12 +320,20 @@ var ApiClient = class {
314
320
  }
315
321
  /**
316
322
  * Execute an HTTP request with retries, timeout, and rate limit handling.
323
+ *
324
+ * Retry policy:
325
+ * - 429 (rate limit): retried for all methods (with Retry-After header)
326
+ * - 5xx (server error): retried only for idempotent methods (GET, PUT, DELETE, HEAD, OPTIONS)
327
+ * - Network errors: retried only for idempotent methods
328
+ * - POST and PATCH are NOT retried on 5xx/network errors to prevent duplicate mutations
317
329
  */
318
330
  async request(method, path, options) {
319
331
  const url = this.buildUrl(path, options?.params);
320
332
  const parsedUrl = new URL(url);
321
333
  const headers = this.buildHeaders(options?.headers);
322
334
  const maxRetries = options?.retries ?? MAX_RETRIES;
335
+ const upperMethod = method.toUpperCase();
336
+ const isIdempotent = ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"].includes(upperMethod);
323
337
  let lastError = null;
324
338
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
325
339
  let timeoutId = null;
@@ -353,7 +367,7 @@ var ApiClient = class {
353
367
  await this.sleep(waitMs);
354
368
  continue;
355
369
  }
356
- if (response.status >= 500 && attempt < maxRetries) {
370
+ if (response.status >= 500 && attempt < maxRetries && isIdempotent) {
357
371
  const waitMs = RETRY_BACKOFF_MS * Math.pow(2, attempt);
358
372
  logger.debug(`Server error ${response.status}. Retrying in ${waitMs}ms...`);
359
373
  await this.sleep(waitMs);
@@ -385,7 +399,7 @@ var ApiClient = class {
385
399
  throw new TimeoutError(`Request timed out after ${this.timeout}ms: ${method} ${path}`);
386
400
  }
387
401
  lastError = err;
388
- if (attempt < maxRetries) {
402
+ if (attempt < maxRetries && isIdempotent) {
389
403
  const waitMs = RETRY_BACKOFF_MS * Math.pow(2, attempt);
390
404
  logger.debug(`Network error: ${err.message}. Retrying in ${waitMs}ms...`);
391
405
  await this.sleep(waitMs);
@@ -691,6 +705,7 @@ function normalizeAlertConfig(alert) {
691
705
  function normalizeMonitorConfig(config) {
692
706
  const normalized = { ...config };
693
707
  delete normalized.sslLastCheckedAt;
708
+ delete normalized.aggregatedAlertState;
694
709
  if (normalized.playwrightOptions && typeof normalized.playwrightOptions === "object") {
695
710
  const opts = { ...normalized.playwrightOptions };
696
711
  if (opts.retries === 0) delete opts.retries;
@@ -732,8 +747,8 @@ function normalizeRemoteRaw(type, raw) {
732
747
  }
733
748
  }
734
749
  if (type === "test") {
735
- if (normalized.type === "browser") normalized.testType = "playwright";
736
- else if (normalized.type === "performance") normalized.testType = "k6";
750
+ if (normalized.type === "performance") normalized.testType = "k6";
751
+ else normalized.testType = "playwright";
737
752
  delete normalized.type;
738
753
  if (typeof normalized.script === "string") {
739
754
  normalized.script = stripTitleMetadata(normalized.script);
@@ -1013,7 +1028,12 @@ function formatValue(value, column, row) {
1013
1028
  return value ? pc2.green("\u2713") : pc2.red("\u2717");
1014
1029
  }
1015
1030
  if (value instanceof Date) return formatDate(value);
1016
- if (Array.isArray(value)) return value.join(", ");
1031
+ if (Array.isArray(value)) {
1032
+ if (value.length > 0 && typeof value[0] === "object" && value[0] !== null) {
1033
+ return JSON.stringify(value);
1034
+ }
1035
+ return value.join(", ");
1036
+ }
1017
1037
  if (typeof value === "number") {
1018
1038
  const dateForNumber = maybeFormatDate(value, column?.key);
1019
1039
  if (dateForNumber) return dateForNumber;
@@ -1044,6 +1064,22 @@ function maybeFormatDate(value, key) {
1044
1064
  function formatDate(date) {
1045
1065
  return date.toISOString().replace("T", " ").replace("Z", "");
1046
1066
  }
1067
+ function summarizeArray(_key, items) {
1068
+ if (items.length === 0) return pc2.dim("(none)");
1069
+ if (typeof items[0] !== "object" || items[0] === null) {
1070
+ return items.join(", ");
1071
+ }
1072
+ const previews = [];
1073
+ for (const item of items.slice(0, 3)) {
1074
+ const obj = item;
1075
+ const name = obj.name ?? obj.title ?? obj.key ?? obj.id;
1076
+ if (name) previews.push(String(name));
1077
+ }
1078
+ const countStr = `${items.length} ${items.length === 1 ? "item" : "items"}`;
1079
+ if (previews.length === 0) return countStr;
1080
+ const previewStr = previews.join(", ");
1081
+ return items.length > 3 ? `${countStr} (${previewStr}, ...)` : `${countStr} (${previewStr})`;
1082
+ }
1047
1083
  function outputDetail(data) {
1048
1084
  if (currentFormat === "json") {
1049
1085
  logger.output(JSON.stringify(data, null, 2));
@@ -1058,10 +1094,47 @@ function outputDetail(data) {
1058
1094
  logger.info("No details available.");
1059
1095
  return;
1060
1096
  }
1061
- const maxKeyLen = Math.max(...keys.map((k) => k.length));
1097
+ const scalarEntries = [];
1098
+ const nestedEntries = [];
1062
1099
  for (const [key, value] of Object.entries(data)) {
1063
- const label = key.padEnd(maxKeyLen + 2);
1064
- logger.output(` ${label}${formatValue(value, { key, header: key }, data)}`);
1100
+ if (value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)) {
1101
+ nestedEntries.push([key, value]);
1102
+ } else {
1103
+ scalarEntries.push([key, value]);
1104
+ }
1105
+ }
1106
+ if (scalarEntries.length > 0) {
1107
+ const maxKeyLen = Math.max(...scalarEntries.map(([k]) => k.length));
1108
+ for (const [key, value] of scalarEntries) {
1109
+ const label = key.padEnd(maxKeyLen + 2);
1110
+ let formatted;
1111
+ if (Array.isArray(value)) {
1112
+ formatted = summarizeArray(key, value);
1113
+ } else {
1114
+ formatted = formatValue(value, { key, header: key }, data);
1115
+ }
1116
+ logger.output(` ${label}${formatted}`);
1117
+ }
1118
+ }
1119
+ for (const [key, obj] of nestedEntries) {
1120
+ logger.output("");
1121
+ logger.output(` ${pc2.bold(key)}:`);
1122
+ const subKeys = Object.keys(obj);
1123
+ if (subKeys.length === 0) {
1124
+ logger.output(` ${pc2.dim("(empty)")}`);
1125
+ continue;
1126
+ }
1127
+ const maxSubKeyLen = Math.max(...subKeys.map((k) => k.length));
1128
+ for (const [subKey, subVal] of Object.entries(obj)) {
1129
+ const subLabel = subKey.padEnd(maxSubKeyLen + 2);
1130
+ let formatted;
1131
+ if (Array.isArray(subVal)) {
1132
+ formatted = summarizeArray(subKey, subVal);
1133
+ } else {
1134
+ formatted = formatValue(subVal, { key: subKey, header: subKey }, obj);
1135
+ }
1136
+ logger.output(` ${subLabel}${formatted}`);
1137
+ }
1065
1138
  }
1066
1139
  }
1067
1140
 
@@ -1168,6 +1241,18 @@ var whoamiCommand = new Command("whoami").description("Show current authenticati
1168
1241
  );
1169
1242
  const tokenPreview = safeTokenPreview(token);
1170
1243
  const activeToken = data.tokens?.find((t) => t.start && token.startsWith(t.start.replace(/\.+$/, "")));
1244
+ if (getOutputFormat() === "json") {
1245
+ const jsonData = {
1246
+ user: activeToken?.createdByName ?? null,
1247
+ tokenName: activeToken?.name ?? null,
1248
+ tokenPreview,
1249
+ apiUrl: baseUrl ?? "https://app.supercheck.io",
1250
+ expiresAt: activeToken?.expiresAt ?? null,
1251
+ lastUsed: activeToken?.lastRequest ?? null
1252
+ };
1253
+ logger.output(JSON.stringify(jsonData, null, 2));
1254
+ return;
1255
+ }
1171
1256
  logger.newline();
1172
1257
  logger.header("Current Context");
1173
1258
  logger.newline();
@@ -1405,7 +1490,9 @@ function validateNoSecrets(config) {
1405
1490
  const secretPatterns = [
1406
1491
  /sck_live_[a-zA-Z0-9]+/,
1407
1492
  /sck_trigger_[a-zA-Z0-9]+/,
1408
- /sck_test_[a-zA-Z0-9]+/
1493
+ /sck_test_[a-zA-Z0-9]+/,
1494
+ // Legacy trigger key format: job_ followed by 32+ hex chars
1495
+ /job_[a-fA-F0-9]{32,}/
1409
1496
  ];
1410
1497
  for (const pattern of secretPatterns) {
1411
1498
  if (pattern.test(configStr)) {
@@ -1419,13 +1506,10 @@ function validateNoSecrets(config) {
1419
1506
  async function loadConfig(options = {}) {
1420
1507
  const cwd = options.cwd ?? process.cwd();
1421
1508
  const { config: loadEnv } = await import("dotenv");
1422
- loadEnv({ path: resolve2(cwd, ".env") });
1509
+ loadEnv({ path: resolve2(cwd, ".env"), quiet: true });
1423
1510
  const configPath = resolveConfigPath(cwd, options.configPath);
1424
1511
  if (!configPath) {
1425
- throw new CLIError(
1426
- "No supercheck.config.ts found. Run `supercheck init` to create one.",
1427
- 3 /* ConfigError */
1428
- );
1512
+ throw new ConfigNotFoundError();
1429
1513
  }
1430
1514
  let config = await loadConfigFile(configPath);
1431
1515
  const localConfigPath = resolveLocalConfigPath(cwd);
@@ -1453,7 +1537,7 @@ async function tryLoadConfig(options = {}) {
1453
1537
  try {
1454
1538
  return await loadConfig(options);
1455
1539
  } catch (err) {
1456
- if (err instanceof CLIError && err.message.includes("No supercheck.config.ts found")) {
1540
+ if (err instanceof ConfigNotFoundError) {
1457
1541
  return null;
1458
1542
  }
1459
1543
  throw err;
@@ -2005,9 +2089,10 @@ import { readFileSync as readFileSync3 } from "fs";
2005
2089
  import { resolve as resolve6 } from "path";
2006
2090
 
2007
2091
  // src/api/authenticated-client.ts
2008
- function createAuthenticatedClient() {
2092
+ function createAuthenticatedClient(configBaseUrl) {
2009
2093
  const token = requireAuth();
2010
- const baseUrl = getStoredBaseUrl();
2094
+ const storedBaseUrl = getStoredBaseUrl();
2095
+ const baseUrl = storedBaseUrl ?? configBaseUrl;
2011
2096
  return getApiClient({ token, baseUrl: baseUrl ?? void 0 });
2012
2097
  }
2013
2098
 
@@ -2036,6 +2121,21 @@ function parseIntStrict(value, name, opts) {
2036
2121
  }
2037
2122
 
2038
2123
  // src/utils/validation.ts
2124
+ var K6_IMPORT_PATTERN = /import\s+.*\s+from\s+['"]k6(?:\/[^'"]*)?['"]/;
2125
+ function isK6Script(script) {
2126
+ return K6_IMPORT_PATTERN.test(script);
2127
+ }
2128
+ function validateScriptTypeMatch(script, declaredType) {
2129
+ if (!script || !declaredType) return void 0;
2130
+ const scriptIsK6 = isK6Script(script);
2131
+ if (scriptIsK6 && declaredType !== "performance") {
2132
+ return `Script contains k6 imports but test type is "${declaredType}". k6 scripts must use type "performance".`;
2133
+ }
2134
+ if (!scriptIsK6 && declaredType === "performance") {
2135
+ return 'Test type is "performance" but script does not contain k6 imports. Performance tests require k6 scripts.';
2136
+ }
2137
+ return void 0;
2138
+ }
2039
2139
  function normalizeTestTypeForApi(localType) {
2040
2140
  if (typeof localType !== "string") return void 0;
2041
2141
  const normalized = localType.trim().toLowerCase();
@@ -2170,6 +2270,13 @@ function resolveJobLocalTests(cwd, tests, patterns) {
2170
2270
  });
2171
2271
  }
2172
2272
  async function validateJobTests(client, tests) {
2273
+ for (const test of tests) {
2274
+ const declaredType = normalizeTestTypeForApi(test.type);
2275
+ const mismatch = validateScriptTypeMatch(test.script, declaredType);
2276
+ if (mismatch) {
2277
+ throw new CLIError(`${test.name}: ${mismatch}`, 3 /* ConfigError */);
2278
+ }
2279
+ }
2173
2280
  const inputs = tests.map((test) => ({
2174
2281
  name: test.name,
2175
2282
  script: test.script,
@@ -2297,7 +2404,7 @@ jobCommand.command("get <id>").description("Get job details").action(async (id)
2297
2404
  );
2298
2405
  outputDetail(data);
2299
2406
  });
2300
- jobCommand.command("create").description("Create a new job").requiredOption("--name <name>", "Job name").requiredOption("--tests <tests...>", "Test IDs (space or comma separated)").option("--description <description>", "Job description", "").option("--type <type>", "Job runner type (playwright, k6)").option("--schedule <cron>", "Cron schedule expression").option("--timeout <seconds>", "Timeout in seconds", "300").option("--retries <count>", "Retry count on failure", "0").action(async (options) => {
2407
+ jobCommand.command("create").description("Create a new job").requiredOption("--name <name>", "Job name").requiredOption("--tests <tests...>", "Test IDs (space or comma separated)").option("--description <description>", "Job description", "").option("--type <type>", "Job runner type (playwright, k6)").option("--schedule <cron>", "Cron schedule expression").option("--dry-run", "Show what would be sent without creating").action(async (options) => {
2301
2408
  const client = createAuthenticatedClient();
2302
2409
  const tests = (options.tests ?? []).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
2303
2410
  if (tests.length === 0) {
@@ -2306,13 +2413,16 @@ jobCommand.command("create").description("Create a new job").requiredOption("--n
2306
2413
  const body = {
2307
2414
  name: options.name,
2308
2415
  description: options.description,
2309
- timeoutSeconds: parseIntStrict(options.timeout, "--timeout", { min: 1 }),
2310
- retryCount: parseIntStrict(options.retries, "--retries", { min: 0 }),
2311
2416
  config: {},
2312
2417
  tests: tests.map((id) => ({ id }))
2313
2418
  };
2314
2419
  if (options.type) body.jobType = options.type;
2315
2420
  if (options.schedule) body.cronSchedule = options.schedule;
2421
+ if (options.dryRun) {
2422
+ logger.header("Dry run \u2014 job create payload:");
2423
+ logger.info(JSON.stringify(body, null, 2));
2424
+ return;
2425
+ }
2316
2426
  const { data } = await withSpinner(
2317
2427
  "Creating job",
2318
2428
  () => client.post("/api/jobs", body)
@@ -2322,17 +2432,20 @@ jobCommand.command("create").description("Create a new job").requiredOption("--n
2322
2432
  logger.success(`Job "${options.name}" created (${jobId ?? "unknown"})`);
2323
2433
  outputDetail(job);
2324
2434
  });
2325
- 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) => {
2435
+ 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("--status <status>", "Job status (active, paused)").option("--dry-run", "Show what would be sent without updating").action(async (id, options) => {
2326
2436
  const client = createAuthenticatedClient();
2327
2437
  const body = {};
2328
2438
  if (options.name !== void 0) body.name = options.name;
2329
2439
  if (options.description !== void 0) body.description = options.description;
2330
2440
  if (options.schedule !== void 0) body.cronSchedule = options.schedule;
2331
- if (options.timeout !== void 0) body.timeoutSeconds = parseIntStrict(options.timeout, "--timeout", { min: 1 });
2332
- if (options.retries !== void 0) body.retryCount = parseIntStrict(options.retries, "--retries", { min: 0 });
2333
2441
  if (options.status !== void 0) body.status = options.status;
2334
2442
  if (Object.keys(body).length === 0) {
2335
- logger.warn("No fields to update. Use --name, --description, --schedule, --timeout, --retries, or --status.");
2443
+ logger.warn("No fields to update. Use --name, --description, --schedule, or --status.");
2444
+ return;
2445
+ }
2446
+ if (options.dryRun) {
2447
+ logger.header("Dry run \u2014 job update payload:");
2448
+ logger.info(JSON.stringify(body, null, 2));
2336
2449
  return;
2337
2450
  }
2338
2451
  const { data } = await withSpinner(
@@ -2694,6 +2807,13 @@ function collectLocalTests(cwd, patterns, options) {
2694
2807
  });
2695
2808
  }
2696
2809
  async function validateLocalTests(client, tests) {
2810
+ for (const test of tests) {
2811
+ const declaredType = test.validationType ?? normalizeTestTypeForApi(test.type);
2812
+ const mismatch = validateScriptTypeMatch(test.script, declaredType);
2813
+ if (mismatch) {
2814
+ throw new CLIError(`${test.name}: ${mismatch}`, 3 /* ConfigError */);
2815
+ }
2816
+ }
2697
2817
  const inputs = tests.map((test) => ({
2698
2818
  name: test.name,
2699
2819
  script: test.script,
@@ -2773,7 +2893,7 @@ testCommand.command("get <id>").description("Get test details").option("--includ
2773
2893
  );
2774
2894
  outputDetail(data);
2775
2895
  });
2776
- 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 (browser, performance, api, database, custom)").option("--description <description>", "Test description").action(async (options) => {
2896
+ 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 (browser, performance, api, database, custom)").option("--description <description>", "Test description").option("--dry-run", "Show what would be sent without creating").action(async (options) => {
2777
2897
  const { readFileSync: readFileSync6 } = await import("fs");
2778
2898
  const { resolve: resolve10 } = await import("path");
2779
2899
  const filePath = resolve10(process.cwd(), options.file);
@@ -2784,7 +2904,10 @@ testCommand.command("create").description("Create a new test").requiredOption("-
2784
2904
  throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
2785
2905
  }
2786
2906
  const typeArg = options.type || inferTestType(options.file) || "playwright";
2787
- const client = createAuthenticatedClient();
2907
+ const typeMismatchError = validateScriptTypeMatch(script, normalizeTestTypeForApi(typeArg));
2908
+ if (typeMismatchError) {
2909
+ throw new CLIError(typeMismatchError, 3 /* ConfigError */);
2910
+ }
2788
2911
  const encodedScript = Buffer2.from(script, "utf-8").toString("base64");
2789
2912
  const body = {
2790
2913
  title: options.title,
@@ -2792,6 +2915,13 @@ testCommand.command("create").description("Create a new test").requiredOption("-
2792
2915
  type: normalizeTestType(typeArg)
2793
2916
  };
2794
2917
  if (options.description) body.description = options.description;
2918
+ if (options.dryRun) {
2919
+ const preview = { ...body, script: `<base64 ${encodedScript.length} chars>` };
2920
+ logger.header("Dry run \u2014 test create payload:");
2921
+ logger.info(JSON.stringify(preview, null, 2));
2922
+ return;
2923
+ }
2924
+ const client = createAuthenticatedClient();
2795
2925
  const { data } = await withSpinner(
2796
2926
  "Creating test",
2797
2927
  () => client.post("/api/tests", body)
@@ -2800,7 +2930,7 @@ testCommand.command("create").description("Create a new test").requiredOption("-
2800
2930
  logger.success(`Test "${options.title}" created${createdId ? ` (${createdId})` : ""}`);
2801
2931
  outputDetail(data);
2802
2932
  });
2803
- 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) => {
2933
+ 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").option("--dry-run", "Show what would be sent without updating").action(async (id, options) => {
2804
2934
  const client = createAuthenticatedClient();
2805
2935
  const body = {};
2806
2936
  if (options.title !== void 0) body.title = options.title;
@@ -2820,6 +2950,15 @@ testCommand.command("update <id>").description("Update a test").option("--title
2820
2950
  logger.warn("No fields to update. Use --title, --file, or --description.");
2821
2951
  return;
2822
2952
  }
2953
+ if (options.dryRun) {
2954
+ const preview = { ...body };
2955
+ if (typeof preview.script === "string") {
2956
+ preview.script = `<base64 ${preview.script.length} chars>`;
2957
+ }
2958
+ logger.header("Dry run \u2014 test update payload:");
2959
+ logger.info(JSON.stringify(preview, null, 2));
2960
+ return;
2961
+ }
2823
2962
  const { data } = await withSpinner(
2824
2963
  "Updating test",
2825
2964
  () => client.patch(`/api/tests/${id}`, body)
@@ -2843,16 +2982,7 @@ testCommand.command("delete <id>").description("Delete a test").option("--force"
2843
2982
  );
2844
2983
  logger.success(`Test ${id} deleted`);
2845
2984
  });
2846
- testCommand.command("run").description("Run tests locally").option("--local", "Run locally").option("--cloud", "DEPRECATED: Cloud test execution is no longer supported from CLI").option("--file <path>", "Local test file path").option("--all", "Run all local tests").option("--type <type>", "Local test type filter (browser, performance, api, database, custom)").option("--id <id>", "DEPRECATED: Test ID (cloud execution removed)").option("--location <location>", "Execution location (k6 cloud only)").action(async (options) => {
2847
- if (options.local && options.cloud) {
2848
- throw new CLIError("--local and --cloud are mutually exclusive.", 3 /* ConfigError */);
2849
- }
2850
- if (options.cloud || options.id || options.location) {
2851
- throw new CLIError(
2852
- "Cloud execution for single tests has been removed from CLI because results are not persisted as job runs. Use `supercheck test run --local ...` for local validation or create/trigger a job (`supercheck job run` / `supercheck job trigger`) for remote executions with run history.",
2853
- 3 /* ConfigError */
2854
- );
2855
- }
2985
+ testCommand.command("run").description("Run tests locally").option("--file <path>", "Local test file path").option("--all", "Run all local tests").option("--type <type>", "Test type filter (browser, performance, api, database, custom)").action(async (options) => {
2856
2986
  if (!options.file && !options.all) {
2857
2987
  throw new CLIError("Local runs require --file <path> or --all.", 3 /* ConfigError */);
2858
2988
  }
@@ -2899,12 +3029,6 @@ testCommand.command("run").description("Run tests locally").option("--local", "R
2899
3029
  await runK6Tests(k6Tests, cwd);
2900
3030
  }
2901
3031
  });
2902
- testCommand.command("execute <id>").description("DEPRECATED: test execute has been removed").action(async () => {
2903
- throw new CLIError(
2904
- "The `test execute` command has been removed. Use `supercheck test run --local ...` for local validation or create/trigger a job (`supercheck job run` / `supercheck job trigger`) for remote executions with run history.",
2905
- 3 /* ConfigError */
2906
- );
2907
- });
2908
3032
  testCommand.command("tags <id>").description("Get test tags").action(async (id) => {
2909
3033
  const client = createAuthenticatedClient();
2910
3034
  const { data } = await withSpinner(
@@ -2929,6 +3053,11 @@ testCommand.command("validate").description("Validate a test script").requiredOp
2929
3053
  throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
2930
3054
  }
2931
3055
  const typeArg = options.type || inferTestType(options.file) || "playwright";
3056
+ const resolvedApiType = normalizeTestTypeForApi(typeArg);
3057
+ const typeMismatchError = validateScriptTypeMatch(script, resolvedApiType);
3058
+ if (typeMismatchError) {
3059
+ throw new CLIError(typeMismatchError, 3 /* ConfigError */);
3060
+ }
2932
3061
  const client = createAuthenticatedClient();
2933
3062
  const results = await withSpinner(
2934
3063
  "Validating script",
@@ -3057,23 +3186,29 @@ monitorCommand.command("get <id>").description("Get monitor details").action(asy
3057
3186
  );
3058
3187
  outputDetail(data);
3059
3188
  });
3060
- monitorCommand.command("results <id>").description("Get monitor check results").option("--limit <limit>", "Number of results", "20").action(async (id, options) => {
3189
+ monitorCommand.command("results <id>").description("Get monitor check results").option("--limit <limit>", "Number of results", "20").option("--page <page>", "Page number", "1").action(async (id, options) => {
3061
3190
  const client = createAuthenticatedClient();
3062
3191
  const { data } = await withSpinner(
3063
3192
  "Fetching monitor results",
3064
3193
  () => client.get(
3065
3194
  `/api/monitors/${id}/results`,
3066
- { limit: options.limit }
3195
+ { limit: options.limit, page: options.page }
3067
3196
  )
3068
3197
  );
3069
- output(data.results, {
3198
+ output(data.data, {
3070
3199
  columns: [
3071
- { key: "timestamp", header: "Time" },
3200
+ { key: "checkedAt", header: "Time" },
3072
3201
  { key: "status", header: "Status" },
3073
3202
  { key: "responseTime", header: "Response (ms)" },
3074
3203
  { key: "location", header: "Location" }
3075
3204
  ]
3076
3205
  });
3206
+ if (data.pagination) {
3207
+ logger.info(
3208
+ `
3209
+ Page ${data.pagination.page}/${data.pagination.totalPages} (${data.pagination.total} total)`
3210
+ );
3211
+ }
3077
3212
  });
3078
3213
  monitorCommand.command("stats <id>").description("Get monitor performance statistics").action(async (id) => {
3079
3214
  const client = createAuthenticatedClient();
@@ -3081,7 +3216,28 @@ monitorCommand.command("stats <id>").description("Get monitor performance statis
3081
3216
  "Fetching monitor statistics",
3082
3217
  () => client.get(`/api/monitors/${id}/stats`)
3083
3218
  );
3084
- outputDetail(data);
3219
+ if (getOutputFormat() === "json") {
3220
+ outputDetail(data);
3221
+ return;
3222
+ }
3223
+ const statsData = data.data ?? data;
3224
+ const period24h = statsData.period24h;
3225
+ const period30d = statsData.period30d;
3226
+ if (period24h) {
3227
+ logger.header("Last 24 Hours");
3228
+ outputDetail(period24h);
3229
+ logger.info("");
3230
+ }
3231
+ if (period30d) {
3232
+ logger.header("Last 30 Days");
3233
+ outputDetail(period30d);
3234
+ logger.info("");
3235
+ }
3236
+ const meta = data.meta;
3237
+ if (meta) {
3238
+ logger.header("Meta");
3239
+ outputDetail(meta);
3240
+ }
3085
3241
  });
3086
3242
  monitorCommand.command("status <id>").description("Get current monitor status").action(async (id) => {
3087
3243
  const client = createAuthenticatedClient();
@@ -3098,10 +3254,22 @@ monitorCommand.command("status <id>").description("Get current monitor status").
3098
3254
  };
3099
3255
  outputDetail(statusInfo);
3100
3256
  });
3101
- 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) => {
3257
+ 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-minutes <minutes>", "Check interval in minutes (1-1440)", "5").option("--interval <seconds>", "[deprecated: use --interval-minutes] Check interval in seconds").option("--timeout <seconds>", "Request timeout in seconds", "30").option("--method <method>", "HTTP method (GET, POST, HEAD)", "GET").option("--dry-run", "Show what would be sent without creating").action(async (options) => {
3102
3258
  const client = createAuthenticatedClient();
3103
- const intervalSeconds = parseIntStrict(options.interval, "--interval", { min: 1 });
3104
- const frequencyMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
3259
+ let frequencyMinutes;
3260
+ if (options.interval !== void 0) {
3261
+ logger.warn("--interval (seconds) is deprecated. Use --interval-minutes instead.");
3262
+ const intervalSeconds = parseIntStrict(options.interval, "--interval", { min: 60 });
3263
+ if (intervalSeconds % 60 !== 0) {
3264
+ throw new CLIError(
3265
+ `--interval must be a multiple of 60 seconds. Got ${intervalSeconds}s. Use --interval-minutes for direct minute values.`,
3266
+ 3 /* ConfigError */
3267
+ );
3268
+ }
3269
+ frequencyMinutes = intervalSeconds / 60;
3270
+ } else {
3271
+ frequencyMinutes = parseIntStrict(options.intervalMinutes, "--interval-minutes", { min: 1, max: 1440 });
3272
+ }
3105
3273
  const body = {
3106
3274
  name: options.name,
3107
3275
  target: options.url,
@@ -3113,6 +3281,11 @@ monitorCommand.command("create").description("Create a new monitor").requiredOpt
3113
3281
  method: options.method
3114
3282
  }
3115
3283
  };
3284
+ if (options.dryRun) {
3285
+ logger.header("Dry run \u2014 monitor create payload:");
3286
+ logger.info(JSON.stringify(body, null, 2));
3287
+ return;
3288
+ }
3116
3289
  const { data } = await withSpinner(
3117
3290
  "Creating monitor",
3118
3291
  () => client.post("/api/monitors", body)
@@ -3120,14 +3293,23 @@ monitorCommand.command("create").description("Create a new monitor").requiredOpt
3120
3293
  logger.success(`Monitor "${options.name}" created (${data.id})`);
3121
3294
  outputDetail(data);
3122
3295
  });
3123
- 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) => {
3296
+ monitorCommand.command("update <id>").description("Update a monitor").option("--name <name>", "Monitor name").option("--url <url>", "URL to monitor").option("--interval-minutes <minutes>", "Check interval in minutes (1-1440)").option("--interval <seconds>", "[deprecated: use --interval-minutes] 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)").option("--dry-run", "Show what would be sent without updating").action(async (id, options) => {
3124
3297
  const client = createAuthenticatedClient();
3125
3298
  const body = {};
3126
3299
  if (options.name !== void 0) body.name = options.name;
3127
3300
  if (options.url !== void 0) body.target = options.url;
3128
3301
  if (options.interval !== void 0) {
3129
- const intervalSeconds = parseIntStrict(options.interval, "--interval", { min: 1 });
3130
- body.frequencyMinutes = Math.max(1, Math.ceil(intervalSeconds / 60));
3302
+ logger.warn("--interval (seconds) is deprecated. Use --interval-minutes instead.");
3303
+ const intervalSeconds = parseIntStrict(options.interval, "--interval", { min: 60 });
3304
+ if (intervalSeconds % 60 !== 0) {
3305
+ throw new CLIError(
3306
+ `--interval must be a multiple of 60 seconds. Got ${intervalSeconds}s. Use --interval-minutes for direct minute values.`,
3307
+ 3 /* ConfigError */
3308
+ );
3309
+ }
3310
+ body.frequencyMinutes = intervalSeconds / 60;
3311
+ } else if (options.intervalMinutes !== void 0) {
3312
+ body.frequencyMinutes = parseIntStrict(options.intervalMinutes, "--interval-minutes", { min: 1, max: 1440 });
3131
3313
  }
3132
3314
  if (options.active !== void 0) body.enabled = options.active === "true";
3133
3315
  const config = {};
@@ -3135,7 +3317,12 @@ monitorCommand.command("update <id>").description("Update a monitor").option("--
3135
3317
  if (options.method !== void 0) config.method = options.method;
3136
3318
  if (Object.keys(config).length > 0) body.config = config;
3137
3319
  if (Object.keys(body).length === 0) {
3138
- logger.warn("No fields to update. Use --name, --url, --interval, --timeout, --method, or --active.");
3320
+ logger.warn("No fields to update. Use --name, --url, --interval-minutes, --timeout, --method, or --active.");
3321
+ return;
3322
+ }
3323
+ if (options.dryRun) {
3324
+ logger.header("Dry run \u2014 monitor update payload:");
3325
+ logger.info(JSON.stringify(body, null, 2));
3139
3326
  return;
3140
3327
  }
3141
3328
  const { data } = await withSpinner(
@@ -3438,7 +3625,7 @@ function formatChangePlan(changes) {
3438
3625
  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) => {
3439
3626
  const cwd = process.cwd();
3440
3627
  const { config } = await loadConfig({ cwd, configPath: options.config });
3441
- const client = createAuthenticatedClient();
3628
+ const client = createAuthenticatedClient(config.api?.baseUrl);
3442
3629
  logger.newline();
3443
3630
  const { localResources, remoteResources } = await withSpinner(
3444
3631
  "Comparing local and remote resources...",
@@ -3482,7 +3669,8 @@ function prepareBodyForApi(type, body) {
3482
3669
  if (Array.isArray(payload.tests)) {
3483
3670
  payload.tests = payload.tests.map((t) => {
3484
3671
  const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(t);
3485
- return match ? match[1] : t;
3672
+ const id = match ? match[1] : t;
3673
+ return { id };
3486
3674
  });
3487
3675
  }
3488
3676
  }
@@ -3545,7 +3733,7 @@ async function applyChange(client, change, opts) {
3545
3733
  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) => {
3546
3734
  const cwd = process.cwd();
3547
3735
  const { config } = await loadConfig({ cwd, configPath: options.config });
3548
- const client = createAuthenticatedClient();
3736
+ const client = createAuthenticatedClient(config.api?.baseUrl);
3549
3737
  logger.newline();
3550
3738
  logger.header("Deploying from config...");
3551
3739
  logger.newline();
@@ -3584,6 +3772,14 @@ var deployCommand = new Command12("deploy").description("Push local config resou
3584
3772
  const testsToValidate = actionable.filter((c) => c.type === "test" && (c.action === "create" || c.action === "update"));
3585
3773
  if (testsToValidate.length > 0) {
3586
3774
  await withSpinner("Validating test scripts...", async () => {
3775
+ for (const change of testsToValidate) {
3776
+ const script = String(change.local?.definition?.script ?? "");
3777
+ const declaredType = normalizeTestTypeForApi(change.local?.definition?.testType);
3778
+ const mismatch = validateScriptTypeMatch(script, declaredType);
3779
+ if (mismatch) {
3780
+ throw new CLIError(`${change.name}: ${mismatch}`, 3 /* ConfigError */);
3781
+ }
3782
+ }
3587
3783
  const inputs = testsToValidate.map((change) => ({
3588
3784
  name: change.name,
3589
3785
  script: String(change.local?.definition?.script ?? ""),
@@ -3681,7 +3877,6 @@ async function fetchManagedResources(client, config) {
3681
3877
  if (sp.id) managedIds.add(`statusPage:${sp.id}`);
3682
3878
  }
3683
3879
  }
3684
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3685
3880
  const patterns = {
3686
3881
  playwright: config.tests?.playwright?.testMatch,
3687
3882
  k6: config.tests?.k6?.testMatch
@@ -3690,9 +3885,9 @@ async function fetchManagedResources(client, config) {
3690
3885
  const files = discoverFiles(cwd, patterns);
3691
3886
  const testIds = /* @__PURE__ */ new Set();
3692
3887
  for (const f of files) {
3693
- const stem = f.filename.replace(/\.(pw|k6)\.ts$/, "");
3694
- if (UUID_RE.test(stem)) {
3695
- testIds.add(stem);
3888
+ const uuid = extractUuidFromFilename(f.filename);
3889
+ if (uuid) {
3890
+ testIds.add(uuid);
3696
3891
  }
3697
3892
  }
3698
3893
  try {
@@ -3768,7 +3963,7 @@ function logDestroyFetchError(resourceType, err) {
3768
3963
  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) => {
3769
3964
  const cwd = process.cwd();
3770
3965
  const { config } = await loadConfig({ cwd, configPath: options.config });
3771
- const client = createAuthenticatedClient();
3966
+ const client = createAuthenticatedClient(config.api?.baseUrl);
3772
3967
  logger.newline();
3773
3968
  const managed = await withSpinner(
3774
3969
  "Scanning for managed resources...",
@@ -3801,7 +3996,7 @@ var destroyCommand = new Command13("destroy").description("Tear down managed res
3801
3996
  logger.newline();
3802
3997
  let succeeded = 0;
3803
3998
  let failed = 0;
3804
- const deleteOrder = ["test", "monitor", "statusPage", "job", "variable", "tag"];
3999
+ const deleteOrder = ["statusPage", "job", "monitor", "variable", "tag", "test"];
3805
4000
  const sorted = [...managed].sort((a, b) => deleteOrder.indexOf(a.type) - deleteOrder.indexOf(b.type));
3806
4001
  for (const resource of sorted) {
3807
4002
  try {
@@ -4010,6 +4205,7 @@ function buildMonitorDefinitions(monitors) {
4010
4205
  if (m.config && typeof m.config === "object") {
4011
4206
  const config = { ...m.config };
4012
4207
  delete config.sslLastCheckedAt;
4208
+ delete config.aggregatedAlertState;
4013
4209
  if (config.playwrightOptions && typeof config.playwrightOptions === "object") {
4014
4210
  const opts = { ...config.playwrightOptions };
4015
4211
  if (opts.retries === 0) delete opts.retries;
@@ -4280,8 +4476,8 @@ var pullCommand = new Command14("pull").description("Pull tests, monitors, jobs,
4280
4476
  );
4281
4477
  }
4282
4478
  const cwd = process.cwd();
4283
- const client = createAuthenticatedClient();
4284
4479
  const existing = await tryLoadConfig({ cwd, configPath: options.config });
4480
+ const client = createAuthenticatedClient(existing?.config?.api?.baseUrl);
4285
4481
  logger.newline();
4286
4482
  logger.header("Pulling from Supercheck cloud...");
4287
4483
  logger.newline();
@@ -4528,6 +4724,19 @@ var validateCommand = new Command15("validate").description("Validate local test
4528
4724
  logger.warn("No local tests found to validate.");
4529
4725
  return;
4530
4726
  }
4727
+ let preflightFailed = false;
4728
+ for (const test of tests) {
4729
+ const script = String(test.definition?.script ?? "");
4730
+ const declaredType = normalizeTestTypeForApi(test.definition?.testType);
4731
+ const mismatch = validateScriptTypeMatch(script, declaredType);
4732
+ if (mismatch) {
4733
+ logger.error(`${pc8.red("\u2717")} ${test.name}: ${mismatch}`);
4734
+ preflightFailed = true;
4735
+ }
4736
+ }
4737
+ if (preflightFailed) {
4738
+ throw new CLIError("Script-type mismatch detected. Fix the test type or script content before validating.", 3 /* ConfigError */);
4739
+ }
4531
4740
  const inputs = tests.map((test) => ({
4532
4741
  name: test.name,
4533
4742
  script: String(test.definition?.script ?? ""),
@@ -4614,7 +4823,7 @@ notificationCommand.command("create").description("Create a notification provide
4614
4823
  logger.success(`Notification provider "${options.name}" created (${data.id})`);
4615
4824
  outputDetail(data);
4616
4825
  });
4617
- 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) => {
4826
+ 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 (replaces entire config \u2014 include all fields)").action(async (id, options) => {
4618
4827
  const client = createAuthenticatedClient();
4619
4828
  if (!options.name && !options.type && !options.config) {
4620
4829
  logger.warn("No fields to update. Use --name, --type, or --config.");
@@ -4624,22 +4833,32 @@ notificationCommand.command("update <id>").description("Update a notification pr
4624
4833
  "Fetching existing provider",
4625
4834
  () => client.get(`/api/notification-providers/${id}`)
4626
4835
  );
4627
- let updatedConfig = existing.config ?? {};
4836
+ const maskedFields = existing.maskedFields ?? [];
4837
+ const updatedName = options.name ?? String(existing.name ?? "");
4838
+ const updatedType = options.type ?? String(existing.type ?? "");
4839
+ let updatedConfig;
4628
4840
  if (options.config) {
4629
4841
  try {
4630
- updatedConfig = { ...updatedConfig, ...JSON.parse(options.config) };
4842
+ updatedConfig = JSON.parse(options.config);
4631
4843
  } catch {
4632
4844
  throw new CLIError("Invalid JSON in --config", 1 /* GeneralError */);
4633
4845
  }
4846
+ } else if (maskedFields.length > 0) {
4847
+ logger.debug("Skipping config field (contains masked secrets that cannot be round-tripped safely)");
4848
+ updatedConfig = void 0;
4849
+ } else {
4850
+ updatedConfig = existing.config ?? {};
4851
+ }
4852
+ if (updatedConfig) {
4853
+ updatedConfig.name = updatedName;
4634
4854
  }
4635
- const updatedName = options.name ?? String(existing.name ?? "");
4636
- const updatedType = options.type ?? String(existing.type ?? "");
4637
- updatedConfig.name = updatedName;
4638
4855
  const body = {
4639
4856
  name: updatedName,
4640
- type: updatedType,
4641
- config: updatedConfig
4857
+ type: updatedType
4642
4858
  };
4859
+ if (updatedConfig) {
4860
+ body.config = updatedConfig;
4861
+ }
4643
4862
  const { data } = await withSpinner(
4644
4863
  "Updating notification provider",
4645
4864
  () => client.put(`/api/notification-providers/${id}`, body)