@glubean/cli 0.8.0 → 0.8.2

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.
Files changed (40) hide show
  1. package/README.md +35 -3
  2. package/dist/commands/init.d.ts +1 -1
  3. package/dist/commands/init.d.ts.map +1 -1
  4. package/dist/commands/init.js +172 -151
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/load.d.ts +11 -0
  7. package/dist/commands/load.d.ts.map +1 -1
  8. package/dist/commands/load.js +192 -0
  9. package/dist/commands/load.js.map +1 -1
  10. package/dist/commands/login.d.ts +14 -1
  11. package/dist/commands/login.d.ts.map +1 -1
  12. package/dist/commands/login.js +110 -49
  13. package/dist/commands/login.js.map +1 -1
  14. package/dist/commands/run.d.ts +7 -0
  15. package/dist/commands/run.d.ts.map +1 -1
  16. package/dist/commands/run.js +202 -93
  17. package/dist/commands/run.js.map +1 -1
  18. package/dist/lib/auth.d.ts +57 -0
  19. package/dist/lib/auth.d.ts.map +1 -1
  20. package/dist/lib/auth.js +134 -1
  21. package/dist/lib/auth.js.map +1 -1
  22. package/dist/lib/config.d.ts +15 -5
  23. package/dist/lib/config.d.ts.map +1 -1
  24. package/dist/lib/config.js +6 -1
  25. package/dist/lib/config.js.map +1 -1
  26. package/dist/lib/constants.d.ts +6 -1
  27. package/dist/lib/constants.d.ts.map +1 -1
  28. package/dist/lib/constants.js +6 -1
  29. package/dist/lib/constants.js.map +1 -1
  30. package/dist/lib/print-plan.d.ts.map +1 -1
  31. package/dist/lib/print-plan.js +4 -0
  32. package/dist/lib/print-plan.js.map +1 -1
  33. package/dist/lib/upload.d.ts +88 -10
  34. package/dist/lib/upload.d.ts.map +1 -1
  35. package/dist/lib/upload.js +117 -188
  36. package/dist/lib/upload.js.map +1 -1
  37. package/dist/main.d.ts.map +1 -1
  38. package/dist/main.js +47 -8
  39. package/dist/main.js.map +1 -1
  40. package/package.json +6 -6
@@ -1,13 +1,12 @@
1
1
  import { bootstrap, evaluateThresholds, MetricCollector, ProjectRunner, buildRunContext, } from "@glubean/runner";
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { stat, readdir, readFile, writeFile, mkdir, rm } from "node:fs/promises";
4
5
  import { glob } from "node:fs/promises";
5
6
  import { CONFIG_DEFAULTS, mergeRunOptions, toSharedRunConfig } from "../lib/config.js";
6
7
  import { loadProjectEnv } from "@glubean/runner";
7
8
  import { resolveEnvFileName } from "../lib/active_env.js";
8
9
  import { shouldSkipTest } from "../lib/skip.js";
9
- import { CLI_VERSION } from "../version.js";
10
- import { redactMetadataForUpload } from "../lib/redact-metadata.js";
11
10
  import { extractContractCases, extractFromSource } from "@glubean/scanner/static";
12
11
  import { buildSuffixes, classifyByStem, extractContractFromFile, findTemplateMatch, GLUBEAN_KINDS, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
13
12
  import { applyEnvTemplating } from "@glubean/runner";
@@ -610,11 +609,15 @@ export async function runCommand(target, options = {}) {
610
609
  console.log(`${colors.dim}Loaded ${Object.keys(envVars).length} vars from ${envFileName}${colors.reset}`);
611
610
  }
612
611
  // ── Preflight: verify auth before running tests when --upload is set ────
612
+ // The resolved upload target is hoisted so the post-run upload reuses it —
613
+ // resolution happens here (pre-run) so a misconfigured destination fails fast.
614
+ let resolvedUploadTargetId;
613
615
  if (options.upload) {
614
- const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
616
+ const { resolveToken, resolveProjectId, resolveApiUrl, resolveTargetId, resolveDefaultTargetId, checkUploadAuth, checkTargetInProject, } = await import("../lib/auth.js");
615
617
  const authOpts = {
616
618
  token: options.token,
617
619
  project: options.project,
620
+ target: options.target,
618
621
  apiUrl: options.apiUrl,
619
622
  };
620
623
  const sources = {
@@ -630,34 +633,87 @@ export async function runCommand(target, options = {}) {
630
633
  console.error(`${colors.dim}This profile's upload.tokenEnv points at '${options.tokenEnv}', but it's empty/unset. Set it in .env.secrets or the environment.${colors.reset}`);
631
634
  }
632
635
  else {
633
- console.error(`${colors.dim}Run 'glubean login', set GLUBEAN_TOKEN, or add token to .env.secrets or package.json glubean.cloud.${colors.reset}`);
636
+ console.error(`${colors.dim}Create a project token (glb_…) in the dashboard (Project → Tokens), then run 'glubean login' to save it, set GLUBEAN_TOKEN / --token, or add it to .env.secrets.${colors.reset}`);
634
637
  }
635
638
  process.exit(1);
636
639
  }
637
640
  if (!preProject) {
638
641
  console.error(`${colors.red}Error: --upload requires a project ID but none found.${colors.reset}`);
639
- console.error(`${colors.dim}Use --project, set projectId in package.json glubean.cloud, or run 'glubean login'.${colors.reset}`);
642
+ console.error(`${colors.dim}Use --project or set GLUBEAN_PROJECT_ID.${colors.reset}`);
640
643
  process.exit(1);
641
644
  }
642
- try {
643
- const resp = await fetch(`${preApiUrl}/open/v1/whoami`, {
644
- headers: { Authorization: `Bearer ${preToken}` },
645
- });
646
- if (!resp.ok) {
647
- console.error(`${colors.red}Error: authentication failed (${resp.status}).${colors.reset}`);
648
- if (resp.status === 401) {
649
- console.error(`${colors.dim}Token is invalid or expired. Run 'glubean login' to re-authenticate.${colors.reset}`);
645
+ // Validate against the SAME server runs upload to. Don't pre-judge token
646
+ // format locally let the server decide. A least-privilege ingest token
647
+ // (runs:write, no projects:read) gets 403 yet can still POST runs, so that
648
+ // alone proceeds; a known-bad config (401 invalid token, 404 mistyped
649
+ // project / wrong API URL, 5xx, unreachable) is fatal BEFORE running tests.
650
+ const check = await checkUploadAuth(preApiUrl, preProject, preToken);
651
+ if (!check.proceed) {
652
+ if (check.status === 401) {
653
+ console.error(`${colors.red}Error: authentication failed (401).${colors.reset}`);
654
+ console.error(`${colors.dim}The token is invalid/expired or not a platform project token (glb_…). Create one in the dashboard (Project → Tokens) and run 'glubean login' (or set GLUBEAN_TOKEN).${colors.reset}`);
655
+ }
656
+ else if (check.status === 404) {
657
+ console.error(`${colors.red}Error: project ${preProject} not found (404).${colors.reset}`);
658
+ console.error(`${colors.dim}Check that --project / GLUBEAN_PROJECT_ID is a real project id and --api-url / GLUBEAN_API_URL points at the right server.${colors.reset}`);
659
+ }
660
+ else if (check.status === 403) {
661
+ console.error(`${colors.red}Error: access to project ${preProject} is forbidden (403).${colors.reset}`);
662
+ console.error(`${colors.dim}The token's org has no access to this project (or its membership was revoked). Use a token whose org owns the project.${colors.reset}`);
663
+ }
664
+ else if (check.status === 0) {
665
+ console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}.${colors.reset}`);
666
+ }
667
+ else {
668
+ console.error(`${colors.red}Error: upload preflight got an unexpected response (${check.status}).${colors.reset}`);
669
+ console.error(`${colors.dim}Check that --api-url / GLUBEAN_API_URL points at the Glubean platform API.${colors.reset}`);
670
+ }
671
+ process.exit(1);
672
+ }
673
+ if (check.unverified) {
674
+ // 403 — can't read the project with this token's scope, but it can write
675
+ // runs. Proceed; the post-run upload surfaces any genuine error.
676
+ console.log(`${colors.dim}Skipping pre-run project check (insufficient read scope); will upload to ${preApiUrl} after the run.${colors.reset}`);
677
+ }
678
+ else {
679
+ console.log(`${colors.dim}Authenticated · upload to ${preApiUrl} (project ${check.projectName ?? preProject})${colors.reset}`);
680
+ }
681
+ // Resolve the upload TARGET here too (pre-run) so a misconfigured target
682
+ // fails fast instead of after the whole suite. The post-run block reuses it.
683
+ let preTarget = await resolveTargetId(authOpts, sources);
684
+ if (preTarget) {
685
+ // EXPLICIT target — validate it belongs to the project (a typo would
686
+ // otherwise 404 only on the final POST, after the suite ran).
687
+ const tcheck = await checkTargetInProject(preApiUrl, preProject, preTarget, preToken);
688
+ if (!tcheck.proceed) {
689
+ if (tcheck.status === 404) {
690
+ console.error(`${colors.red}Error: target ${preTarget} not found in project ${preProject} (404).${colors.reset}`);
691
+ console.error(`${colors.dim}Check upload.targetId / GLUBEAN_TARGET_ID / --upload-target.${colors.reset}`);
692
+ }
693
+ else if (tcheck.status === 401) {
694
+ console.error(`${colors.red}Error: authentication failed validating the target (401).${colors.reset}`);
695
+ }
696
+ else if (tcheck.status === 0) {
697
+ console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}.${colors.reset}`);
698
+ }
699
+ else {
700
+ console.error(`${colors.red}Error: could not validate target ${preTarget} (${tcheck.status}).${colors.reset}`);
650
701
  }
651
702
  process.exit(1);
652
703
  }
653
- const identity = await resp.json();
654
- console.log(`${colors.dim}Authenticated as ${identity.kind === "project_token" ? `project token (${identity.projectName})` : "user"} · upload to ${preApiUrl}${colors.reset}`);
704
+ // 403 insufficient_scope (unverified) → no targets:read; can't validate, proceed.
655
705
  }
656
- catch (err) {
657
- console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}${colors.reset}`);
658
- console.error(`${colors.dim}${err.message}${colors.reset}`);
659
- process.exit(1);
706
+ else {
707
+ // No explicit target the project's default target (deterministic for a
708
+ // default project, else slug-validated by listing).
709
+ preTarget = await resolveDefaultTargetId(preApiUrl, preProject, preToken);
710
+ if (!preTarget) {
711
+ console.error(`${colors.red}Error: could not resolve an upload target for project ${preProject}.${colors.reset}`);
712
+ console.error(`${colors.dim}Set the target explicitly (upload.targetId in glubean.yaml, GLUBEAN_TARGET_ID, or --upload-target). Auto-resolving a non-default project's target needs a token with the targets:read scope.${colors.reset}`);
713
+ process.exit(1);
714
+ }
660
715
  }
716
+ resolvedUploadTargetId = preTarget;
661
717
  }
662
718
  // ── Bootstrap plugins BEFORE discovery ─────────────────────────────────
663
719
  // CLI's `discoverTests` dynamically imports each .contract.ts / .test.ts
@@ -1894,6 +1950,7 @@ export async function runCommand(target, options = {}) {
1894
1950
  const authOpts = {
1895
1951
  token: options.token,
1896
1952
  project: options.project,
1953
+ target: options.target,
1897
1954
  apiUrl: options.apiUrl,
1898
1955
  };
1899
1956
  const sources = {
@@ -1912,7 +1969,7 @@ export async function runCommand(target, options = {}) {
1912
1969
  process.exit(1);
1913
1970
  }
1914
1971
  else {
1915
- const { compileScopes, redactEvent, BUILTIN_SCOPES } = await import("@glubean/redaction");
1972
+ const { compileScopes, redactEvent, redactValue, BUILTIN_SCOPES } = await import("@glubean/redaction");
1916
1973
  // Prefer the v1 plan's full redaction config when supplied
1917
1974
  // (Phase 4 init scaffolds `defaults.redaction` in glubean.yaml,
1918
1975
  // including any custom globalRules / sensitiveKeys / customPatterns).
@@ -1925,82 +1982,134 @@ export async function runCommand(target, options = {}) {
1925
1982
  globalRules: effectiveRedaction.globalRules,
1926
1983
  replacementFormat: effectiveRedaction.replacementFormat,
1927
1984
  });
1928
- // Generate metadata for test registry
1929
- let metadata;
1930
- try {
1931
- const { scan } = await import("@glubean/scanner");
1932
- const { buildMetadata } = await import("../metadata.js");
1933
- const scanResult = await scan(rootDir);
1934
- const built = await buildMetadata(scanResult, {
1935
- generatedBy: `@glubean/cli@${CLI_VERSION}`,
1936
- projectId,
1937
- // Upload path only: carry the lossless full CONTRACT projection for
1938
- // the Cloud c/f metadata snapshot. Deep-redacted below before upload;
1939
- // never written to the on-disk metadata.json (that path omits it).
1940
- // `workflows` is always present (Design Y) and redacted in the same
1941
- // pass below.
1942
- includeProjection: true,
1943
- });
1944
- metadata = built;
1945
- }
1946
- catch {
1947
- // Non-critical: upload results without metadata
1985
+ // The upload TARGET (the API/system under test runs belong to — ADR 0007)
1986
+ // was resolved + validated in the preflight (pre-run, so a misconfigured
1987
+ // destination fails fast); reuse it. The guard is defensive — the preflight
1988
+ // exits on a null target, so this can't normally fire.
1989
+ const targetId = resolvedUploadTargetId;
1990
+ if (!targetId) {
1991
+ console.error(`${colors.red}Upload failed: no upload target resolved.${colors.reset}`);
1992
+ process.exit(1);
1948
1993
  }
1949
- // Phase 5 5a — attach run-plan provenance to the upload metadata
1950
- // bucket. Cloud server projects this to top-level RunEntity fields
1951
- // (see apps/server/src/tasks/helpers/extract-run-plan.ts). Nested
1952
- // under `metadata` to clear the server DTO's `forbidNonWhitelisted`
1953
- // top-level gate. Only emitted when:
1954
- // 1. The run used a profile (no profile nothing to record).
1955
- // 2. The scan path produced metadata.
1956
- // Skipping runPlan in the degraded-scan path is intentional —
1957
- // synthesizing a runPlan-only shell with `files: {}` would make
1958
- // the server's upsertTests treat all active tests as "removed"
1959
- // (authoritative file map = empty). Better to lose runPlan
1960
- // provenance on degraded scans than to corrupt the test registry.
1961
- if (metadata && options.profile) {
1962
- const runPlan = {
1963
- profile: options.profile,
1994
+ else {
1995
+ // ── Result blob: the full ExecutionResult, run-data ONLY (per D7 the
1996
+ // contract/workflow projection is a separate c/f line). Events are
1997
+ // scope-redacted; the rest of the payload can ALSO carry secrets, so
1998
+ // scrub it too: `context.command` is raw argv (e.g. `--token glb_…`,
1999
+ // `--input-json '{"password":…}'`)dropped outright; `customMetadata`
2000
+ // is user-supplied → deep-redacted. Without this the blob would store
2001
+ // those verbatim in Cloud.
2002
+ const { command: _rawCommand, ...safeContext } = runContext ?? {};
2003
+ const redactNonEvent = (v) => redactValue(v, {
2004
+ globalRules: effectiveRedaction.globalRules,
2005
+ replacementFormat: effectiveRedaction.replacementFormat,
2006
+ maxDepth: 64,
2007
+ });
2008
+ const redactedResult = {
2009
+ ...resultPayload,
2010
+ context: redactNonEvent(safeContext),
2011
+ ...(resultPayload.customMetadata
2012
+ ? { customMetadata: redactNonEvent(resultPayload.customMetadata) }
2013
+ : {}),
2014
+ tests: resultPayload.tests.map((t) => ({
2015
+ ...t,
2016
+ events: t.events.map((e) => redactEvent(e, compiledScopes, effectiveRedaction.replacementFormat)),
2017
+ })),
1964
2018
  };
1965
- if (options.suites && options.suites.length > 0) {
1966
- runPlan.suites = options.suites;
2019
+ // ── Analytics substrate. Server derive-on-ingest (plan D2) isn't built
2020
+ // yet, so the CLI sends per-test rows + metric points explicitly.
2021
+ const testResults = collectedRuns.map((r) => ({
2022
+ testId: r.testId,
2023
+ name: r.testName,
2024
+ // Mirror the CLI's own pass/fail/skip classification: a clean skip
2025
+ // (success:true + a status:"skipped" event) → "skipped" (excluded from
2026
+ // flaky denominators, plan D3). A test that FAILED then emitted skip is
2027
+ // counted as failed (success:false), so gate skip on success first —
2028
+ // otherwise the failure would wrongly drop out of the denominators.
2029
+ status: r.success
2030
+ ? r.events.some((e) => e.type === "status" && e.status === "skipped")
2031
+ ? "skipped"
2032
+ : "passed"
2033
+ : "failed",
2034
+ durationMs: r.durationMs,
2035
+ ...(r.tags && r.tags.length ? { tags: r.tags } : {}),
2036
+ eventCount: r.events.length,
2037
+ }));
2038
+ // Metric tags (method/path) can in rare cases embed a secret in a path
2039
+ // segment — redact them with the same engine the projection line uses.
2040
+ const redactTags = (tags) => tags
2041
+ ? redactValue(tags, {
2042
+ globalRules: effectiveRedaction.globalRules,
2043
+ replacementFormat: effectiveRedaction.replacementFormat,
2044
+ })
2045
+ : undefined;
2046
+ const metrics = [];
2047
+ for (const r of collectedRuns) {
2048
+ for (const e of r.events) {
2049
+ if (e.type !== "metric")
2050
+ continue;
2051
+ // Skip valueless metric events: the server requires a finite numeric
2052
+ // value, and one bad point must not reject the whole run's upload.
2053
+ if (!Number.isFinite(e.value))
2054
+ continue;
2055
+ metrics.push({
2056
+ name: e.name,
2057
+ value: e.value,
2058
+ ...(e.unit ? { unit: e.unit } : {}),
2059
+ ...(e.tags ? { tags: redactTags(e.tags) } : {}),
2060
+ testId: r.testId,
2061
+ });
2062
+ }
2063
+ }
2064
+ const input = {
2065
+ kind: "test",
2066
+ schemaVersion: "glubean.test.v1",
2067
+ // Stable idempotency id for this run — reused across the upload retry so
2068
+ // a lost-response retry replaces this run instead of duplicating it (P1).
2069
+ clientRunId: randomUUID(),
2070
+ // A breached metric threshold fails the run (mirrors the process exit
2071
+ // below) even when every test passed — don't record it as "passed".
2072
+ status: failed > 0 || (thresholdSummary && !thresholdSummary.pass) ? "failed" : "passed",
2073
+ startedAt: runStartTime,
2074
+ completedAt: new Date(Date.parse(runStartTime) + totalDurationMs).toISOString(),
2075
+ durationMs: totalDurationMs,
2076
+ summary: {
2077
+ total: passed + failed + skipped,
2078
+ passed,
2079
+ failed,
2080
+ skipped,
2081
+ durationMs: totalDurationMs,
2082
+ // Run-plan provenance (was metadata.runPlan). The summary jsonb keeps
2083
+ // extras (SUMMARY_SCHEMA catchall), so profile/suite facets survive
2084
+ // for grouping even though the new run row has no dedicated columns.
2085
+ ...(options.profile ? { profile: options.profile } : {}),
2086
+ ...(options.suites && options.suites.length ? { suites: options.suites } : {}),
2087
+ },
2088
+ result: redactedResult,
2089
+ ...(testResults.length ? { testResults } : {}),
2090
+ ...(metrics.length ? { metrics } : {}),
2091
+ };
2092
+ const uploadReceipt = await uploadToCloud(input, {
2093
+ apiUrl,
2094
+ token,
2095
+ projectId,
2096
+ targetId,
2097
+ envFile: effectiveRun.envFile,
2098
+ rootDir,
2099
+ });
2100
+ if (options.uploadReceiptJson) {
2101
+ const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
2102
+ await mkdir(dirname(receiptPath), { recursive: true });
2103
+ await writeFile(receiptPath, JSON.stringify(uploadReceipt, null, 2) + "\n", "utf-8");
2104
+ console.log(`${colors.dim}Upload receipt written to: ${receiptPath}${colors.reset}`);
2105
+ }
2106
+ // A requested --upload that didn't create a run is a failure, even on a
2107
+ // green test run — exit non-zero so CI doesn't read false-green. (The
2108
+ // receipt is written above first, so the failure is still recorded.)
2109
+ if (uploadReceipt.resultUpload.status === "failed") {
2110
+ console.error(`${colors.red}Upload failed: the run was not recorded in Cloud (see the error above).${colors.reset}`);
2111
+ process.exit(1);
1967
2112
  }
1968
- metadata = { ...metadata, runPlan };
1969
- }
1970
- // Deep-redact the FULL contract + workflow projection before upload. Test
1971
- // events are redacted below via scope-based `redactEvent`, but the
1972
- // projection is a free-form tree that can carry secrets anywhere
1973
- // (examples, default headers, gRPC metadata, `extensions`/`meta`, literal
1974
- // compare/switch values, assertion messages). `redactMetadataForUpload`
1975
- // redacts ONLY the projection buckets (contractsProjection + workflows) —
1976
- // never `files`/`rootHash` — so the server's test registry/dedup keeps
1977
- // its verbatim sha256 hashes. The projection is uploaded WHOLE (branch/
1978
- // poll included): it is the lossless source for the server snapshot, not
1979
- // a run view (see the buildMetadata R14 note); the branch/poll run-view
1980
- // gate is a separate layer, untouched here.
1981
- if (metadata) {
1982
- metadata = await redactMetadataForUpload(metadata, effectiveRedaction);
1983
- }
1984
- const redactedPayload = {
1985
- ...resultPayload,
1986
- metadata,
1987
- tests: resultPayload.tests.map((t) => ({
1988
- ...t,
1989
- events: t.events.map((e) => redactEvent(e, compiledScopes, effectiveRedaction.replacementFormat)),
1990
- })),
1991
- };
1992
- const uploadReceipt = await uploadToCloud(redactedPayload, {
1993
- apiUrl,
1994
- token,
1995
- projectId,
1996
- envFile: effectiveRun.envFile,
1997
- rootDir,
1998
- });
1999
- if (options.uploadReceiptJson) {
2000
- const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
2001
- await mkdir(dirname(receiptPath), { recursive: true });
2002
- await writeFile(receiptPath, JSON.stringify(uploadReceipt, null, 2) + "\n", "utf-8");
2003
- console.log(`${colors.dim}Upload receipt written to: ${receiptPath}${colors.reset}`);
2004
2113
  }
2005
2114
  }
2006
2115
  }