@supercheck/cli 0.1.1-beta.1 → 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.1" : "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";
@@ -312,55 +318,29 @@ var ApiClient = class {
312
318
  }
313
319
  return url.toString();
314
320
  }
315
- getProxyEnv(url) {
316
- if (this.proxy) return this.proxy;
317
- const noProxyRaw = process.env.NO_PROXY ?? process.env.no_proxy;
318
- if (noProxyRaw && this.isNoProxyMatch(url, noProxyRaw)) {
319
- return null;
320
- }
321
- if (url.protocol === "https:") {
322
- return process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
323
- }
324
- if (url.protocol === "http:") {
325
- return process.env.HTTP_PROXY ?? process.env.http_proxy ?? null;
326
- }
327
- return null;
328
- }
329
- isNoProxyMatch(url, noProxyRaw) {
330
- const hostname = url.hostname;
331
- const port = url.port || (url.protocol === "https:" ? "443" : "80");
332
- const entries = noProxyRaw.split(",").map((entry) => entry.trim()).filter(Boolean);
333
- if (entries.includes("*")) return true;
334
- for (const entry of entries) {
335
- const [hostPart, portPart] = entry.split(":");
336
- const host = hostPart.trim();
337
- const entryPort = portPart?.trim();
338
- if (!host) continue;
339
- if (entryPort && entryPort !== port) continue;
340
- if (host.startsWith(".")) {
341
- if (hostname.endsWith(host)) return true;
342
- continue;
343
- }
344
- if (hostname === host) return true;
345
- if (hostname.endsWith(`.${host}`)) return true;
346
- }
347
- return false;
348
- }
349
321
  /**
350
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
351
329
  */
352
330
  async request(method, path, options) {
353
331
  const url = this.buildUrl(path, options?.params);
354
332
  const parsedUrl = new URL(url);
355
333
  const headers = this.buildHeaders(options?.headers);
356
334
  const maxRetries = options?.retries ?? MAX_RETRIES;
335
+ const upperMethod = method.toUpperCase();
336
+ const isIdempotent = ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"].includes(upperMethod);
357
337
  let lastError = null;
358
338
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
359
339
  let timeoutId = null;
360
340
  try {
361
341
  const controller = new AbortController();
362
342
  timeoutId = setTimeout(() => controller.abort(), this.timeout);
363
- const proxy = this.getProxyEnv(parsedUrl);
343
+ const proxy = this.proxy || getProxyEnv(parsedUrl);
364
344
  const fetchOptions = {
365
345
  method,
366
346
  headers,
@@ -387,7 +367,7 @@ var ApiClient = class {
387
367
  await this.sleep(waitMs);
388
368
  continue;
389
369
  }
390
- if (response.status >= 500 && attempt < maxRetries) {
370
+ if (response.status >= 500 && attempt < maxRetries && isIdempotent) {
391
371
  const waitMs = RETRY_BACKOFF_MS * Math.pow(2, attempt);
392
372
  logger.debug(`Server error ${response.status}. Retrying in ${waitMs}ms...`);
393
373
  await this.sleep(waitMs);
@@ -419,7 +399,7 @@ var ApiClient = class {
419
399
  throw new TimeoutError(`Request timed out after ${this.timeout}ms: ${method} ${path}`);
420
400
  }
421
401
  lastError = err;
422
- if (attempt < maxRetries) {
402
+ if (attempt < maxRetries && isIdempotent) {
423
403
  const waitMs = RETRY_BACKOFF_MS * Math.pow(2, attempt);
424
404
  logger.debug(`Network error: ${err.message}. Retrying in ${waitMs}ms...`);
425
405
  await this.sleep(waitMs);
@@ -725,6 +705,7 @@ function normalizeAlertConfig(alert) {
725
705
  function normalizeMonitorConfig(config) {
726
706
  const normalized = { ...config };
727
707
  delete normalized.sslLastCheckedAt;
708
+ delete normalized.aggregatedAlertState;
728
709
  if (normalized.playwrightOptions && typeof normalized.playwrightOptions === "object") {
729
710
  const opts = { ...normalized.playwrightOptions };
730
711
  if (opts.retries === 0) delete opts.retries;
@@ -766,8 +747,8 @@ function normalizeRemoteRaw(type, raw) {
766
747
  }
767
748
  }
768
749
  if (type === "test") {
769
- if (normalized.type === "browser") normalized.testType = "playwright";
770
- else if (normalized.type === "performance") normalized.testType = "k6";
750
+ if (normalized.type === "performance") normalized.testType = "k6";
751
+ else normalized.testType = "playwright";
771
752
  delete normalized.type;
772
753
  if (typeof normalized.script === "string") {
773
754
  normalized.script = stripTitleMetadata(normalized.script);
@@ -1047,7 +1028,12 @@ function formatValue(value, column, row) {
1047
1028
  return value ? pc2.green("\u2713") : pc2.red("\u2717");
1048
1029
  }
1049
1030
  if (value instanceof Date) return formatDate(value);
1050
- 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
+ }
1051
1037
  if (typeof value === "number") {
1052
1038
  const dateForNumber = maybeFormatDate(value, column?.key);
1053
1039
  if (dateForNumber) return dateForNumber;
@@ -1078,6 +1064,22 @@ function maybeFormatDate(value, key) {
1078
1064
  function formatDate(date) {
1079
1065
  return date.toISOString().replace("T", " ").replace("Z", "");
1080
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
+ }
1081
1083
  function outputDetail(data) {
1082
1084
  if (currentFormat === "json") {
1083
1085
  logger.output(JSON.stringify(data, null, 2));
@@ -1092,10 +1094,47 @@ function outputDetail(data) {
1092
1094
  logger.info("No details available.");
1093
1095
  return;
1094
1096
  }
1095
- const maxKeyLen = Math.max(...keys.map((k) => k.length));
1097
+ const scalarEntries = [];
1098
+ const nestedEntries = [];
1096
1099
  for (const [key, value] of Object.entries(data)) {
1097
- const label = key.padEnd(maxKeyLen + 2);
1098
- 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
+ }
1099
1138
  }
1100
1139
  }
1101
1140
 
@@ -1202,6 +1241,18 @@ var whoamiCommand = new Command("whoami").description("Show current authenticati
1202
1241
  );
1203
1242
  const tokenPreview = safeTokenPreview(token);
1204
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
+ }
1205
1256
  logger.newline();
1206
1257
  logger.header("Current Context");
1207
1258
  logger.newline();
@@ -1439,7 +1490,9 @@ function validateNoSecrets(config) {
1439
1490
  const secretPatterns = [
1440
1491
  /sck_live_[a-zA-Z0-9]+/,
1441
1492
  /sck_trigger_[a-zA-Z0-9]+/,
1442
- /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,}/
1443
1496
  ];
1444
1497
  for (const pattern of secretPatterns) {
1445
1498
  if (pattern.test(configStr)) {
@@ -1453,13 +1506,10 @@ function validateNoSecrets(config) {
1453
1506
  async function loadConfig(options = {}) {
1454
1507
  const cwd = options.cwd ?? process.cwd();
1455
1508
  const { config: loadEnv } = await import("dotenv");
1456
- loadEnv({ path: resolve2(cwd, ".env") });
1509
+ loadEnv({ path: resolve2(cwd, ".env"), quiet: true });
1457
1510
  const configPath = resolveConfigPath(cwd, options.configPath);
1458
1511
  if (!configPath) {
1459
- throw new CLIError(
1460
- "No supercheck.config.ts found. Run `supercheck init` to create one.",
1461
- 3 /* ConfigError */
1462
- );
1512
+ throw new ConfigNotFoundError();
1463
1513
  }
1464
1514
  let config = await loadConfigFile(configPath);
1465
1515
  const localConfigPath = resolveLocalConfigPath(cwd);
@@ -1487,7 +1537,7 @@ async function tryLoadConfig(options = {}) {
1487
1537
  try {
1488
1538
  return await loadConfig(options);
1489
1539
  } catch (err) {
1490
- if (err instanceof CLIError && err.message.includes("No supercheck.config.ts found")) {
1540
+ if (err instanceof ConfigNotFoundError) {
1491
1541
  return null;
1492
1542
  }
1493
1543
  throw err;
@@ -1690,6 +1740,11 @@ function isNodeInstalled() {
1690
1740
  }
1691
1741
  async function installPlaywrightBrowsers(cwd, browser = "chromium") {
1692
1742
  try {
1743
+ const allowedBrowsers = ["chromium", "firefox", "webkit", "chrome", "msedge"];
1744
+ if (!allowedBrowsers.includes(browser)) {
1745
+ logger.error(`Invalid browser: ${browser}. Allowed: ${allowedBrowsers.join(", ")}`);
1746
+ return false;
1747
+ }
1693
1748
  logger.info(`Installing Playwright ${browser} browser...`);
1694
1749
  execSync(`npx playwright install ${browser}`, {
1695
1750
  cwd,
@@ -2034,9 +2089,10 @@ import { readFileSync as readFileSync3 } from "fs";
2034
2089
  import { resolve as resolve6 } from "path";
2035
2090
 
2036
2091
  // src/api/authenticated-client.ts
2037
- function createAuthenticatedClient() {
2092
+ function createAuthenticatedClient(configBaseUrl) {
2038
2093
  const token = requireAuth();
2039
- const baseUrl = getStoredBaseUrl();
2094
+ const storedBaseUrl = getStoredBaseUrl();
2095
+ const baseUrl = storedBaseUrl ?? configBaseUrl;
2040
2096
  return getApiClient({ token, baseUrl: baseUrl ?? void 0 });
2041
2097
  }
2042
2098
 
@@ -2065,6 +2121,21 @@ function parseIntStrict(value, name, opts) {
2065
2121
  }
2066
2122
 
2067
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
+ }
2068
2139
  function normalizeTestTypeForApi(localType) {
2069
2140
  if (typeof localType !== "string") return void 0;
2070
2141
  const normalized = localType.trim().toLowerCase();
@@ -2128,9 +2199,11 @@ async function validateScripts(client, inputs) {
2128
2199
  import { spawn } from "child_process";
2129
2200
  function runCommand(command, args, cwd) {
2130
2201
  return new Promise((resolve10, reject) => {
2131
- const child = spawn(command, args, {
2202
+ const executable = process.platform === "win32" && command === "npx" ? "npx.cmd" : command;
2203
+ const child = spawn(executable, args, {
2132
2204
  stdio: "inherit",
2133
- shell: true,
2205
+ shell: false,
2206
+ // ✓ SECURE: Prevents command injection
2134
2207
  cwd
2135
2208
  });
2136
2209
  child.on("error", (err) => reject(err));
@@ -2197,6 +2270,13 @@ function resolveJobLocalTests(cwd, tests, patterns) {
2197
2270
  });
2198
2271
  }
2199
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
+ }
2200
2280
  const inputs = tests.map((test) => ({
2201
2281
  name: test.name,
2202
2282
  script: test.script,
@@ -2324,7 +2404,7 @@ jobCommand.command("get <id>").description("Get job details").action(async (id)
2324
2404
  );
2325
2405
  outputDetail(data);
2326
2406
  });
2327
- 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) => {
2328
2408
  const client = createAuthenticatedClient();
2329
2409
  const tests = (options.tests ?? []).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
2330
2410
  if (tests.length === 0) {
@@ -2333,13 +2413,16 @@ jobCommand.command("create").description("Create a new job").requiredOption("--n
2333
2413
  const body = {
2334
2414
  name: options.name,
2335
2415
  description: options.description,
2336
- timeoutSeconds: parseIntStrict(options.timeout, "--timeout", { min: 1 }),
2337
- retryCount: parseIntStrict(options.retries, "--retries", { min: 0 }),
2338
2416
  config: {},
2339
2417
  tests: tests.map((id) => ({ id }))
2340
2418
  };
2341
2419
  if (options.type) body.jobType = options.type;
2342
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
+ }
2343
2426
  const { data } = await withSpinner(
2344
2427
  "Creating job",
2345
2428
  () => client.post("/api/jobs", body)
@@ -2349,17 +2432,20 @@ jobCommand.command("create").description("Create a new job").requiredOption("--n
2349
2432
  logger.success(`Job "${options.name}" created (${jobId ?? "unknown"})`);
2350
2433
  outputDetail(job);
2351
2434
  });
2352
- 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) => {
2353
2436
  const client = createAuthenticatedClient();
2354
2437
  const body = {};
2355
2438
  if (options.name !== void 0) body.name = options.name;
2356
2439
  if (options.description !== void 0) body.description = options.description;
2357
2440
  if (options.schedule !== void 0) body.cronSchedule = options.schedule;
2358
- if (options.timeout !== void 0) body.timeoutSeconds = parseIntStrict(options.timeout, "--timeout", { min: 1 });
2359
- if (options.retries !== void 0) body.retryCount = parseIntStrict(options.retries, "--retries", { min: 0 });
2360
2441
  if (options.status !== void 0) body.status = options.status;
2361
2442
  if (Object.keys(body).length === 0) {
2362
- 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));
2363
2449
  return;
2364
2450
  }
2365
2451
  const { data } = await withSpinner(
@@ -2435,7 +2521,7 @@ jobCommand.command("run").description("Run a job immediately").requiredOption("-
2435
2521
  "Running job",
2436
2522
  () => client.post(
2437
2523
  "/api/jobs/run",
2438
- { jobId: options.id, tests: payloadTests, trigger: "manual" }
2524
+ { jobId: options.id, tests: payloadTests, trigger: "remote" }
2439
2525
  )
2440
2526
  );
2441
2527
  logger.success(`Job started. Run ID: ${data.runId}`);
@@ -2721,6 +2807,13 @@ function collectLocalTests(cwd, patterns, options) {
2721
2807
  });
2722
2808
  }
2723
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
+ }
2724
2817
  const inputs = tests.map((test) => ({
2725
2818
  name: test.name,
2726
2819
  script: test.script,
@@ -2800,7 +2893,7 @@ testCommand.command("get <id>").description("Get test details").option("--includ
2800
2893
  );
2801
2894
  outputDetail(data);
2802
2895
  });
2803
- 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) => {
2804
2897
  const { readFileSync: readFileSync6 } = await import("fs");
2805
2898
  const { resolve: resolve10 } = await import("path");
2806
2899
  const filePath = resolve10(process.cwd(), options.file);
@@ -2811,7 +2904,10 @@ testCommand.command("create").description("Create a new test").requiredOption("-
2811
2904
  throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
2812
2905
  }
2813
2906
  const typeArg = options.type || inferTestType(options.file) || "playwright";
2814
- const client = createAuthenticatedClient();
2907
+ const typeMismatchError = validateScriptTypeMatch(script, normalizeTestTypeForApi(typeArg));
2908
+ if (typeMismatchError) {
2909
+ throw new CLIError(typeMismatchError, 3 /* ConfigError */);
2910
+ }
2815
2911
  const encodedScript = Buffer2.from(script, "utf-8").toString("base64");
2816
2912
  const body = {
2817
2913
  title: options.title,
@@ -2819,6 +2915,13 @@ testCommand.command("create").description("Create a new test").requiredOption("-
2819
2915
  type: normalizeTestType(typeArg)
2820
2916
  };
2821
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();
2822
2925
  const { data } = await withSpinner(
2823
2926
  "Creating test",
2824
2927
  () => client.post("/api/tests", body)
@@ -2827,7 +2930,7 @@ testCommand.command("create").description("Create a new test").requiredOption("-
2827
2930
  logger.success(`Test "${options.title}" created${createdId ? ` (${createdId})` : ""}`);
2828
2931
  outputDetail(data);
2829
2932
  });
2830
- 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) => {
2831
2934
  const client = createAuthenticatedClient();
2832
2935
  const body = {};
2833
2936
  if (options.title !== void 0) body.title = options.title;
@@ -2847,6 +2950,15 @@ testCommand.command("update <id>").description("Update a test").option("--title
2847
2950
  logger.warn("No fields to update. Use --title, --file, or --description.");
2848
2951
  return;
2849
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
+ }
2850
2962
  const { data } = await withSpinner(
2851
2963
  "Updating test",
2852
2964
  () => client.patch(`/api/tests/${id}`, body)
@@ -2870,25 +2982,7 @@ testCommand.command("delete <id>").description("Delete a test").option("--force"
2870
2982
  );
2871
2983
  logger.success(`Test ${id} deleted`);
2872
2984
  });
2873
- testCommand.command("run").description("Run tests locally or in the cloud").option("--local", "Run locally").option("--cloud", "Run on cloud").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>", "Cloud test ID").option("--location <location>", "Execution location (k6 cloud only)").action(async (options) => {
2874
- if (options.local && options.cloud) {
2875
- throw new CLIError("--local and --cloud are mutually exclusive.", 3 /* ConfigError */);
2876
- }
2877
- const useCloud = options.cloud || !options.local && !!options.id;
2878
- if (useCloud) {
2879
- if (!options.id) {
2880
- throw new CLIError("Cloud runs require --id <testId>.", 3 /* ConfigError */);
2881
- }
2882
- const client2 = createAuthenticatedClient();
2883
- const body = {};
2884
- if (options.location) body.location = options.location;
2885
- const { data } = await withSpinner(
2886
- "Executing test",
2887
- () => client2.post(`/api/tests/${options.id}/execute`, body)
2888
- );
2889
- outputDetail(data);
2890
- return;
2891
- }
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) => {
2892
2986
  if (!options.file && !options.all) {
2893
2987
  throw new CLIError("Local runs require --file <path> or --all.", 3 /* ConfigError */);
2894
2988
  }
@@ -2935,16 +3029,6 @@ testCommand.command("run").description("Run tests locally or in the cloud").opti
2935
3029
  await runK6Tests(k6Tests, cwd);
2936
3030
  }
2937
3031
  });
2938
- testCommand.command("execute <id>").description("Execute a test immediately").option("--location <location>", "Execution location (k6 only)").action(async (id, options) => {
2939
- const client = createAuthenticatedClient();
2940
- const body = {};
2941
- if (options.location) body.location = options.location;
2942
- const { data } = await withSpinner(
2943
- "Executing test",
2944
- () => client.post(`/api/tests/${id}/execute`, body)
2945
- );
2946
- outputDetail(data);
2947
- });
2948
3032
  testCommand.command("tags <id>").description("Get test tags").action(async (id) => {
2949
3033
  const client = createAuthenticatedClient();
2950
3034
  const { data } = await withSpinner(
@@ -2969,6 +3053,11 @@ testCommand.command("validate").description("Validate a test script").requiredOp
2969
3053
  throw new CLIError(`Cannot read file: ${filePath}`, 1 /* GeneralError */);
2970
3054
  }
2971
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
+ }
2972
3061
  const client = createAuthenticatedClient();
2973
3062
  const results = await withSpinner(
2974
3063
  "Validating script",
@@ -3097,23 +3186,29 @@ monitorCommand.command("get <id>").description("Get monitor details").action(asy
3097
3186
  );
3098
3187
  outputDetail(data);
3099
3188
  });
3100
- 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) => {
3101
3190
  const client = createAuthenticatedClient();
3102
3191
  const { data } = await withSpinner(
3103
3192
  "Fetching monitor results",
3104
3193
  () => client.get(
3105
3194
  `/api/monitors/${id}/results`,
3106
- { limit: options.limit }
3195
+ { limit: options.limit, page: options.page }
3107
3196
  )
3108
3197
  );
3109
- output(data.results, {
3198
+ output(data.data, {
3110
3199
  columns: [
3111
- { key: "timestamp", header: "Time" },
3200
+ { key: "checkedAt", header: "Time" },
3112
3201
  { key: "status", header: "Status" },
3113
3202
  { key: "responseTime", header: "Response (ms)" },
3114
3203
  { key: "location", header: "Location" }
3115
3204
  ]
3116
3205
  });
3206
+ if (data.pagination) {
3207
+ logger.info(
3208
+ `
3209
+ Page ${data.pagination.page}/${data.pagination.totalPages} (${data.pagination.total} total)`
3210
+ );
3211
+ }
3117
3212
  });
3118
3213
  monitorCommand.command("stats <id>").description("Get monitor performance statistics").action(async (id) => {
3119
3214
  const client = createAuthenticatedClient();
@@ -3121,7 +3216,28 @@ monitorCommand.command("stats <id>").description("Get monitor performance statis
3121
3216
  "Fetching monitor statistics",
3122
3217
  () => client.get(`/api/monitors/${id}/stats`)
3123
3218
  );
3124
- 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
+ }
3125
3241
  });
3126
3242
  monitorCommand.command("status <id>").description("Get current monitor status").action(async (id) => {
3127
3243
  const client = createAuthenticatedClient();
@@ -3138,10 +3254,22 @@ monitorCommand.command("status <id>").description("Get current monitor status").
3138
3254
  };
3139
3255
  outputDetail(statusInfo);
3140
3256
  });
3141
- 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) => {
3142
3258
  const client = createAuthenticatedClient();
3143
- const intervalSeconds = parseIntStrict(options.interval, "--interval", { min: 1 });
3144
- 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
+ }
3145
3273
  const body = {
3146
3274
  name: options.name,
3147
3275
  target: options.url,
@@ -3153,6 +3281,11 @@ monitorCommand.command("create").description("Create a new monitor").requiredOpt
3153
3281
  method: options.method
3154
3282
  }
3155
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
+ }
3156
3289
  const { data } = await withSpinner(
3157
3290
  "Creating monitor",
3158
3291
  () => client.post("/api/monitors", body)
@@ -3160,14 +3293,23 @@ monitorCommand.command("create").description("Create a new monitor").requiredOpt
3160
3293
  logger.success(`Monitor "${options.name}" created (${data.id})`);
3161
3294
  outputDetail(data);
3162
3295
  });
3163
- 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) => {
3164
3297
  const client = createAuthenticatedClient();
3165
3298
  const body = {};
3166
3299
  if (options.name !== void 0) body.name = options.name;
3167
3300
  if (options.url !== void 0) body.target = options.url;
3168
3301
  if (options.interval !== void 0) {
3169
- const intervalSeconds = parseIntStrict(options.interval, "--interval", { min: 1 });
3170
- 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 });
3171
3313
  }
3172
3314
  if (options.active !== void 0) body.enabled = options.active === "true";
3173
3315
  const config = {};
@@ -3175,7 +3317,12 @@ monitorCommand.command("update <id>").description("Update a monitor").option("--
3175
3317
  if (options.method !== void 0) config.method = options.method;
3176
3318
  if (Object.keys(config).length > 0) body.config = config;
3177
3319
  if (Object.keys(body).length === 0) {
3178
- 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));
3179
3326
  return;
3180
3327
  }
3181
3328
  const { data } = await withSpinner(
@@ -3478,7 +3625,7 @@ function formatChangePlan(changes) {
3478
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) => {
3479
3626
  const cwd = process.cwd();
3480
3627
  const { config } = await loadConfig({ cwd, configPath: options.config });
3481
- const client = createAuthenticatedClient();
3628
+ const client = createAuthenticatedClient(config.api?.baseUrl);
3482
3629
  logger.newline();
3483
3630
  const { localResources, remoteResources } = await withSpinner(
3484
3631
  "Comparing local and remote resources...",
@@ -3522,7 +3669,8 @@ function prepareBodyForApi(type, body) {
3522
3669
  if (Array.isArray(payload.tests)) {
3523
3670
  payload.tests = payload.tests.map((t) => {
3524
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);
3525
- return match ? match[1] : t;
3672
+ const id = match ? match[1] : t;
3673
+ return { id };
3526
3674
  });
3527
3675
  }
3528
3676
  }
@@ -3539,7 +3687,8 @@ async function applyChange(client, change, opts) {
3539
3687
  const endpoint = getApiEndpoint(change.type);
3540
3688
  switch (change.action) {
3541
3689
  case "create": {
3542
- const { id: _id, ...rawBody } = change.local.definition;
3690
+ const rawBody = { ...change.local.definition };
3691
+ delete rawBody.id;
3543
3692
  const body = prepareBodyForApi(change.type, rawBody);
3544
3693
  const response = await client.post(endpoint, body);
3545
3694
  if (change.type === "test") {
@@ -3562,7 +3711,8 @@ async function applyChange(client, change, opts) {
3562
3711
  }
3563
3712
  case "update": {
3564
3713
  const id = change.id ?? change.remote.id;
3565
- const { id: _id, ...rawBody } = change.local.definition;
3714
+ const rawBody = { ...change.local.definition };
3715
+ delete rawBody.id;
3566
3716
  const body = prepareBodyForApi(change.type, rawBody);
3567
3717
  await client.put(`${endpoint}/${id}`, body);
3568
3718
  return { success: true };
@@ -3583,7 +3733,7 @@ async function applyChange(client, change, opts) {
3583
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) => {
3584
3734
  const cwd = process.cwd();
3585
3735
  const { config } = await loadConfig({ cwd, configPath: options.config });
3586
- const client = createAuthenticatedClient();
3736
+ const client = createAuthenticatedClient(config.api?.baseUrl);
3587
3737
  logger.newline();
3588
3738
  logger.header("Deploying from config...");
3589
3739
  logger.newline();
@@ -3601,6 +3751,15 @@ var deployCommand = new Command12("deploy").description("Push local config resou
3601
3751
  changes = changes.filter((c) => c.action !== "delete");
3602
3752
  }
3603
3753
  const actionable = changes.filter((c) => c.action !== "no-change");
3754
+ const unsupportedStatusPageMutations = actionable.filter(
3755
+ (c) => c.type === "statusPage" && (c.action === "create" || c.action === "update")
3756
+ );
3757
+ if (unsupportedStatusPageMutations.length > 0) {
3758
+ throw new CLIError(
3759
+ "Status page create/update is not supported by the current API. Remove statusPages create/update changes from config (or apply them in the dashboard) and run deploy again.",
3760
+ 3 /* ConfigError */
3761
+ );
3762
+ }
3604
3763
  if (actionable.length === 0) {
3605
3764
  logger.success("No changes to deploy. Everything is in sync.");
3606
3765
  return;
@@ -3613,6 +3772,14 @@ var deployCommand = new Command12("deploy").description("Push local config resou
3613
3772
  const testsToValidate = actionable.filter((c) => c.type === "test" && (c.action === "create" || c.action === "update"));
3614
3773
  if (testsToValidate.length > 0) {
3615
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
+ }
3616
3783
  const inputs = testsToValidate.map((change) => ({
3617
3784
  name: change.name,
3618
3785
  script: String(change.local?.definition?.script ?? ""),
@@ -3710,7 +3877,6 @@ async function fetchManagedResources(client, config) {
3710
3877
  if (sp.id) managedIds.add(`statusPage:${sp.id}`);
3711
3878
  }
3712
3879
  }
3713
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3714
3880
  const patterns = {
3715
3881
  playwright: config.tests?.playwright?.testMatch,
3716
3882
  k6: config.tests?.k6?.testMatch
@@ -3719,9 +3885,9 @@ async function fetchManagedResources(client, config) {
3719
3885
  const files = discoverFiles(cwd, patterns);
3720
3886
  const testIds = /* @__PURE__ */ new Set();
3721
3887
  for (const f of files) {
3722
- const stem = f.filename.replace(/\.(pw|k6)\.ts$/, "");
3723
- if (UUID_RE.test(stem)) {
3724
- testIds.add(stem);
3888
+ const uuid = extractUuidFromFilename(f.filename);
3889
+ if (uuid) {
3890
+ testIds.add(uuid);
3725
3891
  }
3726
3892
  }
3727
3893
  try {
@@ -3797,7 +3963,7 @@ function logDestroyFetchError(resourceType, err) {
3797
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) => {
3798
3964
  const cwd = process.cwd();
3799
3965
  const { config } = await loadConfig({ cwd, configPath: options.config });
3800
- const client = createAuthenticatedClient();
3966
+ const client = createAuthenticatedClient(config.api?.baseUrl);
3801
3967
  logger.newline();
3802
3968
  const managed = await withSpinner(
3803
3969
  "Scanning for managed resources...",
@@ -3830,7 +3996,7 @@ var destroyCommand = new Command13("destroy").description("Tear down managed res
3830
3996
  logger.newline();
3831
3997
  let succeeded = 0;
3832
3998
  let failed = 0;
3833
- const deleteOrder = ["test", "monitor", "statusPage", "job", "variable", "tag"];
3999
+ const deleteOrder = ["statusPage", "job", "monitor", "variable", "tag", "test"];
3834
4000
  const sorted = [...managed].sort((a, b) => deleteOrder.indexOf(a.type) - deleteOrder.indexOf(b.type));
3835
4001
  for (const resource of sorted) {
3836
4002
  try {
@@ -3885,7 +4051,11 @@ function decodeScript(script) {
3885
4051
  if (reEncoded !== trimmed) {
3886
4052
  return script;
3887
4053
  }
3888
- if (/^[\x09\x0a\x0d\x20-\x7e\u00a0-\uffff]*$/.test(decoded) && decoded.length > 0) {
4054
+ const isTextLike = decoded.length > 0 && Array.from(decoded).every((char) => {
4055
+ const code = char.codePointAt(0) ?? 0;
4056
+ return code === 9 || code === 10 || code === 13 || code >= 32;
4057
+ });
4058
+ if (isTextLike) {
3889
4059
  return decoded;
3890
4060
  }
3891
4061
  } catch {
@@ -4035,6 +4205,7 @@ function buildMonitorDefinitions(monitors) {
4035
4205
  if (m.config && typeof m.config === "object") {
4036
4206
  const config = { ...m.config };
4037
4207
  delete config.sslLastCheckedAt;
4208
+ delete config.aggregatedAlertState;
4038
4209
  if (config.playwrightOptions && typeof config.playwrightOptions === "object") {
4039
4210
  const opts = { ...config.playwrightOptions };
4040
4211
  if (opts.retries === 0) delete opts.retries;
@@ -4228,7 +4399,7 @@ function generateConfigContent(opts) {
4228
4399
  parts.push(" *");
4229
4400
  parts.push(" * supercheck test list List all tests");
4230
4401
  parts.push(" * supercheck test validate Validate a local test script");
4231
- parts.push(" * supercheck test execute <id> Execute a test immediately");
4402
+ parts.push(" * supercheck test run --local Run local test scripts");
4232
4403
  parts.push(" *");
4233
4404
  parts.push(" * supercheck monitor list List all monitors");
4234
4405
  parts.push(" * supercheck job list List all jobs");
@@ -4305,8 +4476,8 @@ var pullCommand = new Command14("pull").description("Pull tests, monitors, jobs,
4305
4476
  );
4306
4477
  }
4307
4478
  const cwd = process.cwd();
4308
- const client = createAuthenticatedClient();
4309
4479
  const existing = await tryLoadConfig({ cwd, configPath: options.config });
4480
+ const client = createAuthenticatedClient(existing?.config?.api?.baseUrl);
4310
4481
  logger.newline();
4311
4482
  logger.header("Pulling from Supercheck cloud...");
4312
4483
  logger.newline();
@@ -4553,6 +4724,19 @@ var validateCommand = new Command15("validate").description("Validate local test
4553
4724
  logger.warn("No local tests found to validate.");
4554
4725
  return;
4555
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
+ }
4556
4740
  const inputs = tests.map((test) => ({
4557
4741
  name: test.name,
4558
4742
  script: String(test.definition?.script ?? ""),
@@ -4639,7 +4823,7 @@ notificationCommand.command("create").description("Create a notification provide
4639
4823
  logger.success(`Notification provider "${options.name}" created (${data.id})`);
4640
4824
  outputDetail(data);
4641
4825
  });
4642
- 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) => {
4643
4827
  const client = createAuthenticatedClient();
4644
4828
  if (!options.name && !options.type && !options.config) {
4645
4829
  logger.warn("No fields to update. Use --name, --type, or --config.");
@@ -4649,22 +4833,32 @@ notificationCommand.command("update <id>").description("Update a notification pr
4649
4833
  "Fetching existing provider",
4650
4834
  () => client.get(`/api/notification-providers/${id}`)
4651
4835
  );
4652
- 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;
4653
4840
  if (options.config) {
4654
4841
  try {
4655
- updatedConfig = { ...updatedConfig, ...JSON.parse(options.config) };
4842
+ updatedConfig = JSON.parse(options.config);
4656
4843
  } catch {
4657
4844
  throw new CLIError("Invalid JSON in --config", 1 /* GeneralError */);
4658
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;
4659
4854
  }
4660
- const updatedName = options.name ?? String(existing.name ?? "");
4661
- const updatedType = options.type ?? String(existing.type ?? "");
4662
- updatedConfig.name = updatedName;
4663
4855
  const body = {
4664
4856
  name: updatedName,
4665
- type: updatedType,
4666
- config: updatedConfig
4857
+ type: updatedType
4667
4858
  };
4859
+ if (updatedConfig) {
4860
+ body.config = updatedConfig;
4861
+ }
4668
4862
  const { data } = await withSpinner(
4669
4863
  "Updating notification provider",
4670
4864
  () => client.put(`/api/notification-providers/${id}`, body)