@glubean/cli 0.7.0 → 0.8.1

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 (42) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/init.js +24 -0
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/commands/load.d.ts +65 -0
  5. package/dist/commands/load.d.ts.map +1 -0
  6. package/dist/commands/load.js +462 -0
  7. package/dist/commands/load.js.map +1 -0
  8. package/dist/commands/login.d.ts +14 -1
  9. package/dist/commands/login.d.ts.map +1 -1
  10. package/dist/commands/login.js +110 -49
  11. package/dist/commands/login.js.map +1 -1
  12. package/dist/commands/run.d.ts +14 -2
  13. package/dist/commands/run.d.ts.map +1 -1
  14. package/dist/commands/run.js +216 -122
  15. package/dist/commands/run.js.map +1 -1
  16. package/dist/lib/auth.d.ts +57 -0
  17. package/dist/lib/auth.d.ts.map +1 -1
  18. package/dist/lib/auth.js +134 -1
  19. package/dist/lib/auth.js.map +1 -1
  20. package/dist/lib/config.d.ts +17 -6
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +9 -2
  23. package/dist/lib/config.js.map +1 -1
  24. package/dist/lib/constants.d.ts +6 -1
  25. package/dist/lib/constants.d.ts.map +1 -1
  26. package/dist/lib/constants.js +6 -1
  27. package/dist/lib/constants.js.map +1 -1
  28. package/dist/lib/print-plan.d.ts.map +1 -1
  29. package/dist/lib/print-plan.js +4 -0
  30. package/dist/lib/print-plan.js.map +1 -1
  31. package/dist/lib/upload.d.ts +88 -10
  32. package/dist/lib/upload.d.ts.map +1 -1
  33. package/dist/lib/upload.js +117 -188
  34. package/dist/lib/upload.js.map +1 -1
  35. package/dist/main.d.ts.map +1 -1
  36. package/dist/main.js +57 -7
  37. package/dist/main.js.map +1 -1
  38. package/package.json +6 -6
  39. package/dist/lib/env.d.ts +0 -29
  40. package/dist/lib/env.d.ts.map +0 -1
  41. package/dist/lib/env.js +0 -59
  42. package/dist/lib/env.js.map +0 -1
@@ -1,15 +1,14 @@
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
- import { extractContractFromFile, findTemplateMatch, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
11
+ import { buildSuffixes, classifyByStem, extractContractFromFile, findTemplateMatch, GLUBEAN_KINDS, loadProjectOverlays, matchesTemplateFilter, } from "@glubean/scanner";
13
12
  import { applyEnvTemplating } from "@glubean/runner";
14
13
  // ANSI color codes for pretty output
15
14
  const colors = {
@@ -28,7 +27,7 @@ const CLOUD_MEMORY_LIMITS = {
28
27
  pro: 700,
29
28
  };
30
29
  const MEMORY_WARNING_THRESHOLD_MB = CLOUD_MEMORY_LIMITS.free * 0.67;
31
- async function findProjectConfig(startDir) {
30
+ export async function findProjectConfig(startDir) {
32
31
  let dir = startDir;
33
32
  while (dir !== "/") {
34
33
  try {
@@ -77,13 +76,12 @@ function isGlob(target) {
77
76
  // calls execute and register overlays before discovery runs (attachment-
78
77
  // model §7.4). `discoverTests()` is responsible for distinguishing
79
78
  // bootstrap-only files from runnable-emitting ones.
80
- const TEST_FILE_SUFFIXES = [
81
- ".test.ts",
82
- ".contract.ts",
83
- ".workflow.ts",
84
- ".flow.ts",
85
- ".bootstrap.ts",
86
- ];
79
+ // CLI target resolution for `glubean run` recognizes only `.ts` files across the
80
+ // test-runner kinds (including bootstrap), derived from the canonical kind
81
+ // registry (scanner/kinds.ts). `load` is EXCLUDED here: load plans run through
82
+ // the dedicated `glubean load` command + the closed-model orchestrator, not the
83
+ // per-test ProjectRunner, so `glubean run ./dir` must not sweep in `.load.ts`.
84
+ const TEST_FILE_SUFFIXES = GLUBEAN_KINDS.filter((k) => k.kind !== "load").flatMap((k) => buildSuffixes(k.stems, [".ts"]));
87
85
  function isGlubeanTestFile(name) {
88
86
  return TEST_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix));
89
87
  }
@@ -93,17 +91,9 @@ function isGlubeanTestFile(name) {
93
91
  // the static test path — a `.workflow.` substring would mis-route it into
94
92
  // contract extraction (which ignores `test()` exports), silently dropping its
95
93
  // tests (codex 0.6 P2).
96
- const RUNTIME_ARTIFACT_SUFFIXES = [
97
- ".contract.ts",
98
- ".contract.js",
99
- ".contract.mjs",
100
- ".workflow.ts",
101
- ".workflow.js",
102
- ".workflow.mjs",
103
- ".flow.ts",
104
- ".flow.js",
105
- ".flow.mjs",
106
- ];
94
+ // Contract/workflow/flow artifact files (NOT bootstrap), all extensions —
95
+ // derived from the canonical kind registry (scanner/kinds.ts).
96
+ const RUNTIME_ARTIFACT_SUFFIXES = GLUBEAN_KINDS.filter((k) => k.runtimeArtifact && k.kind !== "bootstrap").flatMap((k) => buildSuffixes(k.stems));
107
97
  function isRuntimeExtractableArtifact(name) {
108
98
  return RUNTIME_ARTIFACT_SUFFIXES.some((suffix) => name.endsWith(suffix));
109
99
  }
@@ -125,15 +115,8 @@ async function walkTestFiles(dir, result) {
125
115
  }
126
116
  }
127
117
  export function classifyGlubeanFile(filePath) {
128
- if (filePath.endsWith(".test.ts"))
129
- return "test";
130
- if (filePath.endsWith(".contract.ts"))
131
- return "contract";
132
- if (filePath.endsWith(".workflow.ts") || filePath.endsWith(".flow.ts"))
133
- return "flow";
134
- if (filePath.endsWith(".bootstrap.ts"))
135
- return "bootstrap";
136
- return undefined;
118
+ // `.ts`-only classification, by stem, from the canonical kind registry.
119
+ return classifyByStem(filePath, [".ts"]);
137
120
  }
138
121
  async function resolveSingleTarget(target) {
139
122
  const abs = resolve(target);
@@ -450,6 +433,7 @@ function matchesFilter(testItem, filter) {
450
433
  export const __testing = {
451
434
  matchesTags: (...args) => matchesTags(...args),
452
435
  matchesExcludeTags: (...args) => matchesExcludeTags(...args),
436
+ isGlubeanTestFile: (name) => isGlubeanTestFile(name),
453
437
  };
454
438
  function matchesTags(testItem, tags, mode = "or") {
455
439
  if (!testItem.meta.tags?.length)
@@ -625,11 +609,15 @@ export async function runCommand(target, options = {}) {
625
609
  console.log(`${colors.dim}Loaded ${Object.keys(envVars).length} vars from ${envFileName}${colors.reset}`);
626
610
  }
627
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;
628
615
  if (options.upload) {
629
- const { resolveToken, resolveProjectId, resolveApiUrl } = await import("../lib/auth.js");
616
+ const { resolveToken, resolveProjectId, resolveApiUrl, resolveTargetId, resolveDefaultTargetId, checkUploadAuth, checkTargetInProject, } = await import("../lib/auth.js");
630
617
  const authOpts = {
631
618
  token: options.token,
632
619
  project: options.project,
620
+ target: options.target,
633
621
  apiUrl: options.apiUrl,
634
622
  };
635
623
  const sources = {
@@ -645,34 +633,87 @@ export async function runCommand(target, options = {}) {
645
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}`);
646
634
  }
647
635
  else {
648
- 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}`);
649
637
  }
650
638
  process.exit(1);
651
639
  }
652
640
  if (!preProject) {
653
641
  console.error(`${colors.red}Error: --upload requires a project ID but none found.${colors.reset}`);
654
- 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}`);
655
643
  process.exit(1);
656
644
  }
657
- try {
658
- const resp = await fetch(`${preApiUrl}/open/v1/whoami`, {
659
- headers: { Authorization: `Bearer ${preToken}` },
660
- });
661
- if (!resp.ok) {
662
- console.error(`${colors.red}Error: authentication failed (${resp.status}).${colors.reset}`);
663
- if (resp.status === 401) {
664
- 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}`);
665
701
  }
666
702
  process.exit(1);
667
703
  }
668
- const identity = await resp.json();
669
- 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.
670
705
  }
671
- catch (err) {
672
- console.error(`${colors.red}Error: cannot reach server at ${preApiUrl}${colors.reset}`);
673
- console.error(`${colors.dim}${err.message}${colors.reset}`);
674
- 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
+ }
675
715
  }
716
+ resolvedUploadTargetId = preTarget;
676
717
  }
677
718
  // ── Bootstrap plugins BEFORE discovery ─────────────────────────────────
678
719
  // CLI's `discoverTests` dynamically imports each .contract.ts / .test.ts
@@ -1909,6 +1950,7 @@ export async function runCommand(target, options = {}) {
1909
1950
  const authOpts = {
1910
1951
  token: options.token,
1911
1952
  project: options.project,
1953
+ target: options.target,
1912
1954
  apiUrl: options.apiUrl,
1913
1955
  };
1914
1956
  const sources = {
@@ -1927,7 +1969,7 @@ export async function runCommand(target, options = {}) {
1927
1969
  process.exit(1);
1928
1970
  }
1929
1971
  else {
1930
- const { compileScopes, redactEvent, BUILTIN_SCOPES } = await import("@glubean/redaction");
1972
+ const { compileScopes, redactEvent, redactValue, BUILTIN_SCOPES } = await import("@glubean/redaction");
1931
1973
  // Prefer the v1 plan's full redaction config when supplied
1932
1974
  // (Phase 4 init scaffolds `defaults.redaction` in glubean.yaml,
1933
1975
  // including any custom globalRules / sensitiveKeys / customPatterns).
@@ -1940,82 +1982,134 @@ export async function runCommand(target, options = {}) {
1940
1982
  globalRules: effectiveRedaction.globalRules,
1941
1983
  replacementFormat: effectiveRedaction.replacementFormat,
1942
1984
  });
1943
- // Generate metadata for test registry
1944
- let metadata;
1945
- try {
1946
- const { scan } = await import("@glubean/scanner");
1947
- const { buildMetadata } = await import("../metadata.js");
1948
- const scanResult = await scan(rootDir);
1949
- const built = await buildMetadata(scanResult, {
1950
- generatedBy: `@glubean/cli@${CLI_VERSION}`,
1951
- projectId,
1952
- // Upload path only: carry the lossless full CONTRACT projection for
1953
- // the Cloud c/f metadata snapshot. Deep-redacted below before upload;
1954
- // never written to the on-disk metadata.json (that path omits it).
1955
- // `workflows` is always present (Design Y) and redacted in the same
1956
- // pass below.
1957
- includeProjection: true,
1958
- });
1959
- metadata = built;
1960
- }
1961
- catch {
1962
- // 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);
1963
1993
  }
1964
- // Phase 5 5a — attach run-plan provenance to the upload metadata
1965
- // bucket. Cloud server projects this to top-level RunEntity fields
1966
- // (see apps/server/src/tasks/helpers/extract-run-plan.ts). Nested
1967
- // under `metadata` to clear the server DTO's `forbidNonWhitelisted`
1968
- // top-level gate. Only emitted when:
1969
- // 1. The run used a profile (no profile nothing to record).
1970
- // 2. The scan path produced metadata.
1971
- // Skipping runPlan in the degraded-scan path is intentional —
1972
- // synthesizing a runPlan-only shell with `files: {}` would make
1973
- // the server's upsertTests treat all active tests as "removed"
1974
- // (authoritative file map = empty). Better to lose runPlan
1975
- // provenance on degraded scans than to corrupt the test registry.
1976
- if (metadata && options.profile) {
1977
- const runPlan = {
1978
- 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
+ })),
1979
2018
  };
1980
- if (options.suites && options.suites.length > 0) {
1981
- 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);
1982
2112
  }
1983
- metadata = { ...metadata, runPlan };
1984
- }
1985
- // Deep-redact the FULL contract + workflow projection before upload. Test
1986
- // events are redacted below via scope-based `redactEvent`, but the
1987
- // projection is a free-form tree that can carry secrets anywhere
1988
- // (examples, default headers, gRPC metadata, `extensions`/`meta`, literal
1989
- // compare/switch values, assertion messages). `redactMetadataForUpload`
1990
- // redacts ONLY the projection buckets (contractsProjection + workflows) —
1991
- // never `files`/`rootHash` — so the server's test registry/dedup keeps
1992
- // its verbatim sha256 hashes. The projection is uploaded WHOLE (branch/
1993
- // poll included): it is the lossless source for the server snapshot, not
1994
- // a run view (see the buildMetadata R14 note); the branch/poll run-view
1995
- // gate is a separate layer, untouched here.
1996
- if (metadata) {
1997
- metadata = await redactMetadataForUpload(metadata, effectiveRedaction);
1998
- }
1999
- const redactedPayload = {
2000
- ...resultPayload,
2001
- metadata,
2002
- tests: resultPayload.tests.map((t) => ({
2003
- ...t,
2004
- events: t.events.map((e) => redactEvent(e, compiledScopes, effectiveRedaction.replacementFormat)),
2005
- })),
2006
- };
2007
- const uploadReceipt = await uploadToCloud(redactedPayload, {
2008
- apiUrl,
2009
- token,
2010
- projectId,
2011
- envFile: effectiveRun.envFile,
2012
- rootDir,
2013
- });
2014
- if (options.uploadReceiptJson) {
2015
- const receiptPath = resolveOutputPath(options.uploadReceiptJson, process.cwd());
2016
- await mkdir(dirname(receiptPath), { recursive: true });
2017
- await writeFile(receiptPath, JSON.stringify(uploadReceipt, null, 2) + "\n", "utf-8");
2018
- console.log(`${colors.dim}Upload receipt written to: ${receiptPath}${colors.reset}`);
2019
2113
  }
2020
2114
  }
2021
2115
  }