@hs-x/cli 0.4.9 → 0.4.11

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.
@@ -571,6 +571,7 @@ function createDeployProgress(input) {
571
571
  },
572
572
  fail: (detail) => {
573
573
  tally.fail += 1;
574
+ input.onStepFail?.(label, detail);
574
575
  handle.fail(detail);
575
576
  },
576
577
  warn: (detail) => {
@@ -582,6 +583,54 @@ function createDeployProgress(input) {
582
583
  counts: () => ({ ...tally }),
583
584
  };
584
585
  }
586
+ function createDeployFailureSink() {
587
+ let context;
588
+ let projectId;
589
+ let deployId;
590
+ let failure;
591
+ let flushed = false;
592
+ return {
593
+ arm(next) {
594
+ context = next;
595
+ },
596
+ setProject(next) {
597
+ projectId = next;
598
+ },
599
+ setDeployId(next) {
600
+ deployId = next;
601
+ },
602
+ noteStepFailure(step, detail) {
603
+ if (!failure)
604
+ failure = { step, ...(detail ? { detail } : {}) };
605
+ },
606
+ async flush(fallbackDetail) {
607
+ if (flushed || !context || !projectId)
608
+ return;
609
+ flushed = true;
610
+ try {
611
+ const provenance = await collectDeployProvenance(context.userId);
612
+ await hostedHttp({
613
+ url: new URL('/v1/deploys/failures', context.controlPlaneUrl),
614
+ method: 'POST',
615
+ headers: await controlPlaneAuthHeaders(context.userId, undefined, context.accountId),
616
+ body: {
617
+ accountId: context.accountId,
618
+ projectId,
619
+ ...(deployId ? { deployId } : {}),
620
+ failedStep: failure?.step ?? 'deploy',
621
+ errorExcerpt: (failure?.detail ?? fallbackDetail).slice(0, 2000),
622
+ ...(provenance.gitCommit ? { gitCommit: provenance.gitCommit } : {}),
623
+ ...(provenance.gitBranch ? { gitBranch: provenance.gitBranch } : {}),
624
+ ...(provenance.cliVersion ? { cliVersion: provenance.cliVersion } : {}),
625
+ },
626
+ });
627
+ }
628
+ catch {
629
+ // Best-effort; the deploy's own failure is the outcome that matters.
630
+ }
631
+ },
632
+ };
633
+ }
585
634
  /** ADR-007 source summaries for the human transcript (`Sources: …`). */
586
635
  function workerSourceSummaries(workers) {
587
636
  return workers.flatMap((worker) => worker.capabilities.flatMap((capability) => capability.kind === 'sync' && capability.source
@@ -1047,6 +1096,23 @@ async function executeHubSpotDirectDeployCommand({ argv, root, json, }) {
1047
1096
  }
1048
1097
  }
1049
1098
  export async function deployCommand({ argv, root, json, }) {
1099
+ // Failure boundary: a linked deploy that dies — thrown or via a non-zero
1100
+ // exit — reports itself to the control plane (deploy-failed email) before
1101
+ // the error propagates. Ctrl-C (130) is not a failure.
1102
+ const failureSink = createDeployFailureSink();
1103
+ try {
1104
+ const result = await runDeploy({ argv, root, json, failureSink });
1105
+ if (result.exitCode !== 0 && result.exitCode !== 130) {
1106
+ await failureSink.flush(`deploy failed (exit ${result.exitCode})`);
1107
+ }
1108
+ return result;
1109
+ }
1110
+ catch (error) {
1111
+ await failureSink.flush(firstErrorLine(error));
1112
+ throw error;
1113
+ }
1114
+ }
1115
+ async function runDeploy({ argv, root, json, failureSink, }) {
1050
1116
  // Stamp the whole invocation up front: the human summary reporter is created
1051
1117
  // late (render time), so without this "Deployed … in 1ms" measured only the
1052
1118
  // summary step rather than the deploy itself.
@@ -1083,7 +1149,13 @@ export async function deployCommand({ argv, root, json, }) {
1083
1149
  // `echo` additionally gates the inline info/warn stream to human/plain: the
1084
1150
  // ndjson stream keeps emitting those lines from the final summary (its
1085
1151
  // pre-existing event order), so wiring steps stays purely additive there.
1086
- const progress = json ? undefined : createDeployProgress({ argv, startedAt: commandStartedAt });
1152
+ const progress = json
1153
+ ? undefined
1154
+ : createDeployProgress({
1155
+ argv,
1156
+ startedAt: commandStartedAt,
1157
+ onStepFail: (step, detail) => failureSink.noteStepFailure(step, detail),
1158
+ });
1087
1159
  const echo = progress && progress.reporter.mode !== 'ndjson' ? progress.reporter : undefined;
1088
1160
  progress?.reporter.header(basename(root));
1089
1161
  const planOnly = argv.includes('--plan') || argv.includes('--dry-run');
@@ -1132,8 +1204,15 @@ export async function deployCommand({ argv, root, json, }) {
1132
1204
  progress?.reporter.warn('HSX_W_DEPLOY_ACCOUNT_OVERRIDE', `This project is bound to ${resolvedAcct.overrodeBinding}; deploying under ${accountId} for this run only (binding unchanged).`);
1133
1205
  }
1134
1206
  }
1207
+ // Failure reports only make sense for a linked deploy headed at a hosted
1208
+ // control plane; a plan/no-record run never emails.
1209
+ if (linked && controlPlaneUrl && accountId && !planOnly && !noRecord) {
1210
+ failureSink.arm({ controlPlaneUrl, userId, accountId });
1211
+ }
1135
1212
  const explicitProjectId = resolveFlag(argv, '--project-id') ?? process.env.HSX_PROJECT_ID;
1136
1213
  let projectId = explicitProjectId;
1214
+ if (projectId)
1215
+ failureSink.setProject(projectId);
1137
1216
  const validateStep = progress?.step('Validating project');
1138
1217
  const validation = await validateProject({ root });
1139
1218
  const workers = await discoverWorkerManifests(root, { noManageSchema });
@@ -1388,6 +1467,8 @@ export async function deployCommand({ argv, root, json, }) {
1388
1467
  });
1389
1468
  }
1390
1469
  }
1470
+ if (controlPlaneRequest)
1471
+ failureSink.setProject(controlPlaneRequest.projectId);
1391
1472
  if (echo && controlPlaneRequest) {
1392
1473
  echo.info(`Control plane: ready to POST /v1/deploys/plan for ${controlPlaneRequest.projectId}`);
1393
1474
  }
@@ -1423,6 +1504,8 @@ export async function deployCommand({ argv, root, json, }) {
1423
1504
  const controlPlaneBackedPlan = Boolean(controlPlanePlan && controlPlaneRequest && (localControlPlane || controlPlaneUrl));
1424
1505
  const unlinkedDeployPlan = Boolean(controlPlanePlan && !controlPlaneBackedPlan);
1425
1506
  if (controlPlanePlan) {
1507
+ if (controlPlaneBackedPlan)
1508
+ failureSink.setDeployId(controlPlanePlan.deployId);
1426
1509
  controlPlanePlanStep?.ok(controlPlanePlan.deployId);
1427
1510
  if (echo) {
1428
1511
  if (unlinkedDeployPlan)
@@ -1508,6 +1591,7 @@ export async function deployCommand({ argv, root, json, }) {
1508
1591
  argv,
1509
1592
  root,
1510
1593
  local: localHubspotUpload,
1594
+ useControlPlaneLease: controlPlaneBackedPlan,
1511
1595
  uploadOnly: hubspotUploadOnly,
1512
1596
  ...(progress
1513
1597
  ? {
@@ -4683,7 +4767,7 @@ function runCloudflareCommand(command, options) {
4683
4767
  },
4684
4768
  })));
4685
4769
  }
4686
- async function executeHubSpotOnlyUpload({ argv, root, local, persistBinding = true, uploadOnly, prepared, progress, }) {
4770
+ async function executeHubSpotOnlyUpload({ argv, root, local, useControlPlaneLease = true, persistBinding = true, uploadOnly, prepared, progress, }) {
4687
4771
  // The HubSpot project name must match the name baked into the generated
4688
4772
  // bundle (hsproject.json + app-hsmeta uid), not the on-disk directory name.
4689
4773
  // Using basename(root) breaks when the project lives in a generic dir (e.g.
@@ -4741,6 +4825,7 @@ async function executeHubSpotOnlyUpload({ argv, root, local, persistBinding = tr
4741
4825
  argv,
4742
4826
  local,
4743
4827
  allowedOperations: ['hubspot-project-upload'],
4828
+ useControlPlaneLease,
4744
4829
  cwd: root,
4745
4830
  });
4746
4831
  const hsproject = await readHubSpotProjectConfig(root);