@theholocron/cli 5.0.0-alpha.141 → 5.0.0-alpha.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -347,8 +347,8 @@ release) is on the task's `channel` (default `main`, the stable release) and cha
347
347
  `workspace:*` dependencies between `--from` (the previous release's commit,
348
348
  omitted when there is none) and `--to` (default `HEAD`). It waits for npm to
349
349
  serve the release's new dependency versions, then runs `pnpm --filter <pkg>
350
- delivery.deploy`. A package that doesn't apply is skipped; a failed deploy
351
- exits non-zero. The shared `delivery.publish` workflow runs it as its own
350
+ delivery.deploy`. A package that doesn't apply is skipped; a deploy that
351
+ doesn't succeed exits non-zero. The shared `delivery.publish` workflow runs it as its own
352
352
  `deploy` job after the release job (opt in with `deploy-on-release: true`).
353
353
 
354
354
  ### `holocron run ci`
@@ -385,6 +385,29 @@ Provider plugins support this through the `Deployment` capability's
385
385
  existing `getDeployment(id)`. They should set `errorMessage` on the
386
386
  returned `DeploymentRecord` whenever the provider exposes one.
387
387
 
388
+ ### `holocron monitoring check <url>`
389
+
390
+ Runs every configured `monitoring` check against `<url>` instead of each
391
+ check's own configured `url` — no vendor API call, only a plain HTTP
392
+ request evaluated locally against the exact same `assertions` already in
393
+ `holocron.config.ts`. Built for CI: the `delivery.previewCheck` task runs
394
+ this against a PR's Vercel preview deployment (triggered by
395
+ `deployment_status`, not `push`/`pull_request`), reusing the production
396
+ check definitions to catch a breaking change before merge instead of
397
+ after (holocron#1010).
398
+
399
+ ```ts
400
+ export default defineConfig({
401
+ tasks: ["delivery.previewCheck"],
402
+ providers: {
403
+ monitoring: ["checkly", { accountId: "...", checks: [/* ... */] }],
404
+ },
405
+ });
406
+ ```
407
+
408
+ Exits non-zero if any check fails — the job's own pass/fail is what
409
+ posts as the GitHub check, no separate reporting step needed.
410
+
388
411
  ### Lint parity
389
412
 
390
413
  The `lint` task takes an optional `linters` array — one list that drives
@@ -624,9 +647,10 @@ the local override exists to exempt.
624
647
  reusable `workflow_call` implementations + composite actions from
625
648
  `astromech.reusableTemplates()` to `theholocron/.github` (a pure sync target —
626
649
  never hand-edit its `.github/workflows/*`).
627
- - `src/commands/` — `repo setup`, `repo sync`, `doctor`, `deploy`, `secrets set`,
628
- `secrets sync`, `package publish`, `package bump-versions`, `sync github`,
629
- `repo upgrade node`, `repo upgrade deps`, `new plugin`, `auth`
650
+ - `src/commands/` — `repo setup`, `repo sync`, `doctor`, `deploy`,
651
+ `monitoring check`, `secrets set`, `secrets sync`, `package publish`,
652
+ `package bump-versions`, `sync github`, `repo upgrade node`,
653
+ `repo upgrade deps`, `new plugin`, `auth`
630
654
  - `src/interactive-menu.ts` — the interactive fallback: `COMMAND_REGISTRY`,
631
655
  `launchMenu` (Layers 1–2, picks + spawns a fresh `holocron <command>`),
632
656
  `promptForPositionals` (Layer 3, called inline from each leaf command's
package/dist/cli.mjs CHANGED
@@ -343,6 +343,7 @@ const COMMAND_CONTEXTS = {
343
343
  "secrets sync": "workspace",
344
344
  deploy: "workspace",
345
345
  "deploy cleanup-preview": "workspace",
346
+ "monitoring check": "workspace",
346
347
  "repo sync": "workspace",
347
348
  "sync github": "workspace"
348
349
  };
@@ -1820,6 +1821,171 @@ function mk(capability, provider, status, message) {
1820
1821
  };
1821
1822
  }
1822
1823
  //#endregion
1824
+ //#region src/plugin/run-check.ts
1825
+ async function runCheckAgainstUrl(input, opts = {}) {
1826
+ const doFetch = opts.fetch ?? fetch;
1827
+ const url = resolveUrl(input.url, opts.url);
1828
+ const method = input.method ?? "GET";
1829
+ const hasBody = input.body !== void 0 && method !== "GET" && method !== "HEAD";
1830
+ const needsBody = (input.assertions ?? []).some((a) => a.source === "body");
1831
+ const start = Date.now();
1832
+ let response;
1833
+ let bodyText = "";
1834
+ try {
1835
+ response = await doFetch(url, {
1836
+ method,
1837
+ ...hasBody ? { body: input.body } : {},
1838
+ redirect: "manual",
1839
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
1840
+ });
1841
+ if (needsBody) bodyText = await readBodyCapped(response, opts.maxBodyBytes ?? 1e6);
1842
+ } catch (err) {
1843
+ return {
1844
+ passed: false,
1845
+ failures: [],
1846
+ requestError: requestErrorMessage(err)
1847
+ };
1848
+ }
1849
+ const responseTimeMs = Date.now() - start;
1850
+ const failures = [];
1851
+ if (!input.shouldFail && response.status >= 400) failures.push(`HTTP ${response.status} (>= 400 counts as a failure unless \`shouldFail\` is set)`);
1852
+ for (const assertion of input.assertions ?? []) {
1853
+ const actual = extractActual(assertion, {
1854
+ response,
1855
+ bodyText,
1856
+ responseTimeMs
1857
+ });
1858
+ if (!evaluate(assertion.comparison, actual, assertion.target)) failures.push(`${assertion.source} ${assertion.comparison} "${assertion.target ?? ""}" — got "${truncate(actual)}"`);
1859
+ }
1860
+ return {
1861
+ passed: failures.length === 0,
1862
+ status: response.status,
1863
+ responseTimeMs,
1864
+ failures
1865
+ };
1866
+ }
1867
+ /** Swaps in `overrideUrl`'s origin, keeping `originalUrl`'s own path + query. */
1868
+ function resolveUrl(originalUrl, overrideUrl) {
1869
+ if (!overrideUrl) return originalUrl;
1870
+ const original = new URL(originalUrl);
1871
+ const resolved = new URL(overrideUrl);
1872
+ resolved.pathname = original.pathname;
1873
+ resolved.search = original.search;
1874
+ return resolved.toString();
1875
+ }
1876
+ function requestErrorMessage(err) {
1877
+ if (err instanceof Error) return err.name === "TimeoutError" ? `request timed out: ${err.message}` : err.message;
1878
+ return String(err);
1879
+ }
1880
+ /** Reads up to `maxBytes` of the response body, discarding the rest — a large/unending body can't exhaust memory. */
1881
+ async function readBodyCapped(response, maxBytes) {
1882
+ if (!response.body) return "";
1883
+ const reader = response.body.getReader();
1884
+ const chunks = [];
1885
+ let total = 0;
1886
+ try {
1887
+ for (;;) {
1888
+ const { done, value } = await reader.read();
1889
+ if (done) break;
1890
+ const remaining = maxBytes - total;
1891
+ if (remaining <= 0) break;
1892
+ const stored = value.length > remaining ? value.slice(0, remaining) : value;
1893
+ chunks.push(stored);
1894
+ total += stored.length;
1895
+ }
1896
+ } finally {
1897
+ await reader.cancel().catch(() => {});
1898
+ }
1899
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString("utf8");
1900
+ }
1901
+ /** Keeps a failure message readable when `actual` is a full HTML/JSON response body. */
1902
+ function truncate(value, max = 200) {
1903
+ return value.length > max ? `${value.slice(0, max)}…` : value;
1904
+ }
1905
+ function extractActual(assertion, ctx) {
1906
+ switch (assertion.source) {
1907
+ case "status_code": return String(ctx.response.status);
1908
+ case "response_time": return String(ctx.responseTimeMs);
1909
+ case "body": return ctx.bodyText;
1910
+ case "header": {
1911
+ const name = assertion.property ?? "";
1912
+ return name ? ctx.response.headers.get(name) ?? "" : "";
1913
+ }
1914
+ }
1915
+ }
1916
+ function evaluate(comparison, actual, target) {
1917
+ const t = target ?? "";
1918
+ switch (comparison) {
1919
+ case "equals": return actual === t;
1920
+ case "not_equals": return actual !== t;
1921
+ case "contains": return actual.includes(t);
1922
+ case "not_contains": return !actual.includes(t);
1923
+ case "greater_than": return Number(actual) > Number(t);
1924
+ case "less_than": return Number(actual) < Number(t);
1925
+ }
1926
+ }
1927
+ //#endregion
1928
+ //#region src/commands/monitoring-check.ts
1929
+ async function runMonitoringCheck(input) {
1930
+ const print = input.print ?? ((line) => console.log(line));
1931
+ const logger = input.logger ?? getLogger();
1932
+ const loader = input.loader ?? new PluginLoader(input.loaded.resolved, input.context);
1933
+ await loader.load();
1934
+ assertPluginsResolvable(loader, "monitoring check");
1935
+ const monitoring = loader.get("monitoring");
1936
+ const run = input.runCheck ?? runCheckAgainstUrl;
1937
+ print(`Holocron monitoring check — against ${input.url}`);
1938
+ if (monitoring.checks.length === 0) {
1939
+ print(`✗ no monitoring checks configured — nothing to validate`);
1940
+ logger.warn({ url: input.url }, "monitoring check: no checks configured");
1941
+ return {
1942
+ status: "fail",
1943
+ checks: []
1944
+ };
1945
+ }
1946
+ const checks = [];
1947
+ for (const checkInput of monitoring.checks) {
1948
+ const result = await run(checkInput, { url: input.url });
1949
+ if (result.requestError) {
1950
+ print(` ✗ ${checkInput.name} (request failed: ${result.requestError})`);
1951
+ checks.push({
1952
+ name: checkInput.name,
1953
+ passed: false,
1954
+ failures: [],
1955
+ requestError: result.requestError
1956
+ });
1957
+ } else if (result.passed) {
1958
+ print(` ✓ ${checkInput.name}`);
1959
+ checks.push({
1960
+ name: checkInput.name,
1961
+ passed: true,
1962
+ failures: []
1963
+ });
1964
+ } else {
1965
+ print(` ✗ ${checkInput.name}`);
1966
+ for (const failure of result.failures) print(` ${failure}`);
1967
+ checks.push({
1968
+ name: checkInput.name,
1969
+ passed: false,
1970
+ failures: result.failures
1971
+ });
1972
+ }
1973
+ }
1974
+ const failCount = checks.filter((c) => !c.passed).length;
1975
+ const status = failCount === 0 ? "ok" : "fail";
1976
+ print(`${status === "ok" ? "✓" : "✗"} ${checks.length - failCount} passed, ${failCount} failed`);
1977
+ logger.info({
1978
+ url: input.url,
1979
+ total: checks.length,
1980
+ failed: failCount,
1981
+ status
1982
+ }, "monitoring check: done");
1983
+ return {
1984
+ status,
1985
+ checks
1986
+ };
1987
+ }
1988
+ //#endregion
1823
1989
  //#region src/commands/new.ts
1824
1990
  /**
1825
1991
  * `holocron new repo <type> <name>` — create a GitHub repo from a template and
@@ -8417,6 +8583,16 @@ const COMMAND_REGISTRY = [
8417
8583
  positionals: [],
8418
8584
  supportsDryRun: true
8419
8585
  },
8586
+ {
8587
+ name: "monitoring check",
8588
+ description: "Run every configured `monitoring` check against a given URL instead of its own configured url",
8589
+ positionals: [{
8590
+ key: "url",
8591
+ message: "URL to test against:",
8592
+ type: "input"
8593
+ }],
8594
+ supportsDryRun: false
8595
+ },
8420
8596
  {
8421
8597
  name: "package bump-versions",
8422
8598
  description: "Bump all non-private package versions in lockstep (semantic-release prepareCmd)",
@@ -10229,7 +10405,28 @@ try {
10229
10405
  branch,
10230
10406
  ...argv.target ? { target: argv.target } : {}
10231
10407
  })).status === "fail") process.exitCode = 1;
10232
- }).strict().help().epilogue(`Execution contexts:
10408
+ }).command("monitoring", "Run monitoring checks ad hoc, outside the configured provider", (g) => g.command("check <url>", "Run every configured `monitoring` check against a given URL instead of its own configured url", (y) => y.positional("url", {
10409
+ type: "string",
10410
+ describe: "URL to test every configured check against (e.g. a PR's preview deployment)"
10411
+ }), async (argv) => {
10412
+ const tokens = tokenContext(argv.token);
10413
+ if (!tokens) return;
10414
+ const [url] = await promptForPositionals(getEntry("monitoring check"), argv);
10415
+ const loaded = await loadConfig(argv.cwd);
10416
+ applyResolvedConfig(argv, loaded.resolved);
10417
+ if ((await runMonitoringCheck({
10418
+ loaded,
10419
+ context: {
10420
+ repoRoot: argv.cwd,
10421
+ dryRun: argv.dryRun,
10422
+ ...tokens,
10423
+ org: resolveOrg(argv, loaded.resolved)
10424
+ },
10425
+ url
10426
+ })).status === "fail") process.exitCode = 1;
10427
+ }).command("$0", false, () => {}, async (argv) => {
10428
+ await launchGroupMenu("monitoring", argv);
10429
+ }), () => {}).strict().help().epilogue(`Execution contexts:
10233
10430
  global works from a bare 'npm i -g': ${commandsInContext("global").join(", ")}\n repo-aware needs ./holocron.config in cwd: ${commandsInContext("repo-aware").join(", ")}\n workspace also needs the plugin packages: ${commandsInContext("workspace").join(", ")}\n https://theholocron.github.io/holocron/execution-contexts`).fail((msg, err) => {
10234
10431
  if (err instanceof Error && USER_FACING_ERRORS.has(err.name)) {
10235
10432
  captureException(err);