@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 +29 -5
- package/dist/cli.mjs +198 -1
- package/dist/cli.mjs.map +1 -1
- package/package.json +4 -4
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
|
|
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`,
|
|
628
|
-
`
|
|
629
|
-
`
|
|
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
|
-
}).
|
|
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);
|