@hs-x/cli 0.3.6 → 0.4.0

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 (211) hide show
  1. package/README.md +126 -0
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +58 -59
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/kit.d.ts.map +1 -1
  6. package/dist/cli/kit.js +1 -1
  7. package/dist/cli/kit.js.map +1 -1
  8. package/dist/cloudflare-auth.d.ts +19 -0
  9. package/dist/cloudflare-auth.d.ts.map +1 -1
  10. package/dist/cloudflare-auth.js +83 -2
  11. package/dist/cloudflare-auth.js.map +1 -1
  12. package/dist/cloudflare-oauth.d.ts.map +1 -1
  13. package/dist/cloudflare-oauth.js +18 -19
  14. package/dist/cloudflare-oauth.js.map +1 -1
  15. package/dist/cloudflare-scoped-resource.d.ts +41 -0
  16. package/dist/cloudflare-scoped-resource.d.ts.map +1 -0
  17. package/dist/cloudflare-scoped-resource.js +74 -0
  18. package/dist/cloudflare-scoped-resource.js.map +1 -0
  19. package/dist/command-catalog.d.ts +13 -0
  20. package/dist/command-catalog.d.ts.map +1 -0
  21. package/dist/command-catalog.js +50 -0
  22. package/dist/command-catalog.js.map +1 -0
  23. package/dist/commands/completion.d.ts.map +1 -1
  24. package/dist/commands/completion.js +287 -47
  25. package/dist/commands/completion.js.map +1 -1
  26. package/dist/commands/connect.d.ts.map +1 -1
  27. package/dist/commands/connect.js +274 -38
  28. package/dist/commands/connect.js.map +1 -1
  29. package/dist/commands/control-plane-read.d.ts +31 -3
  30. package/dist/commands/control-plane-read.d.ts.map +1 -1
  31. package/dist/commands/control-plane-read.js +136 -37
  32. package/dist/commands/control-plane-read.js.map +1 -1
  33. package/dist/commands/deploy-retry.d.ts +20 -0
  34. package/dist/commands/deploy-retry.d.ts.map +1 -0
  35. package/dist/commands/deploy-retry.js +106 -0
  36. package/dist/commands/deploy-retry.js.map +1 -0
  37. package/dist/commands/deploy.d.ts +16 -8
  38. package/dist/commands/deploy.d.ts.map +1 -1
  39. package/dist/commands/deploy.js +1027 -373
  40. package/dist/commands/deploy.js.map +1 -1
  41. package/dist/commands/dev.d.ts +19 -1
  42. package/dist/commands/dev.d.ts.map +1 -1
  43. package/dist/commands/dev.js +1378 -88
  44. package/dist/commands/dev.js.map +1 -1
  45. package/dist/commands/doctor.d.ts.map +1 -1
  46. package/dist/commands/doctor.js +27 -10
  47. package/dist/commands/doctor.js.map +1 -1
  48. package/dist/commands/flags.d.ts +9 -4
  49. package/dist/commands/flags.d.ts.map +1 -1
  50. package/dist/commands/flags.js +208 -80
  51. package/dist/commands/flags.js.map +1 -1
  52. package/dist/commands/help-command.d.ts.map +1 -1
  53. package/dist/commands/help-command.js +177 -86
  54. package/dist/commands/help-command.js.map +1 -1
  55. package/dist/commands/init.d.ts.map +1 -1
  56. package/dist/commands/init.js +5 -0
  57. package/dist/commands/init.js.map +1 -1
  58. package/dist/commands/link.d.ts.map +1 -1
  59. package/dist/commands/link.js +74 -15
  60. package/dist/commands/link.js.map +1 -1
  61. package/dist/commands/project.d.ts +107 -0
  62. package/dist/commands/project.d.ts.map +1 -0
  63. package/dist/commands/project.js +586 -0
  64. package/dist/commands/project.js.map +1 -0
  65. package/dist/commands/secrets.d.ts +2 -2
  66. package/dist/commands/status.d.ts.map +1 -1
  67. package/dist/commands/status.js +15 -5
  68. package/dist/commands/status.js.map +1 -1
  69. package/dist/commands/validate.d.ts +19 -0
  70. package/dist/commands/validate.d.ts.map +1 -1
  71. package/dist/commands/validate.js +26 -7
  72. package/dist/commands/validate.js.map +1 -1
  73. package/dist/constants.d.ts +1 -1
  74. package/dist/constants.js +1 -1
  75. package/dist/control-plane-tenant-provisioning.d.ts +24 -0
  76. package/dist/control-plane-tenant-provisioning.d.ts.map +1 -0
  77. package/dist/control-plane-tenant-provisioning.js +85 -0
  78. package/dist/control-plane-tenant-provisioning.js.map +1 -0
  79. package/dist/deploy-targets.d.ts +24 -0
  80. package/dist/deploy-targets.d.ts.map +1 -0
  81. package/dist/deploy-targets.js +38 -0
  82. package/dist/deploy-targets.js.map +1 -0
  83. package/dist/dev/account-selection.d.ts +16 -0
  84. package/dist/dev/account-selection.d.ts.map +1 -0
  85. package/dist/dev/account-selection.js +22 -0
  86. package/dist/dev/account-selection.js.map +1 -0
  87. package/dist/dev/browser-log-bridge.d.ts +32 -0
  88. package/dist/dev/browser-log-bridge.d.ts.map +1 -0
  89. package/dist/dev/browser-log-bridge.js +156 -0
  90. package/dist/dev/browser-log-bridge.js.map +1 -0
  91. package/dist/dev/compat-shim.d.ts +3 -3
  92. package/dist/dev/compat-shim.d.ts.map +1 -1
  93. package/dist/dev/compat-shim.js +29 -4
  94. package/dist/dev/compat-shim.js.map +1 -1
  95. package/dist/dev/component-selection.d.ts +25 -0
  96. package/dist/dev/component-selection.d.ts.map +1 -0
  97. package/dist/dev/component-selection.js +79 -0
  98. package/dist/dev/component-selection.js.map +1 -0
  99. package/dist/dev/console-capture.d.ts +18 -0
  100. package/dist/dev/console-capture.d.ts.map +1 -0
  101. package/dist/dev/console-capture.js +57 -0
  102. package/dist/dev/console-capture.js.map +1 -0
  103. package/dist/dev/event-bus.d.ts +1 -1
  104. package/dist/dev/event-bus.js +1 -1
  105. package/dist/dev/frontend-log-relay.d.ts +27 -0
  106. package/dist/dev/frontend-log-relay.d.ts.map +1 -0
  107. package/dist/dev/frontend-log-relay.js +102 -0
  108. package/dist/dev/frontend-log-relay.js.map +1 -0
  109. package/dist/dev/invoke.d.ts +3 -4
  110. package/dist/dev/invoke.d.ts.map +1 -1
  111. package/dist/dev/invoke.js +9 -4
  112. package/dist/dev/invoke.js.map +1 -1
  113. package/dist/dev/keyboard.d.ts +1 -0
  114. package/dist/dev/keyboard.d.ts.map +1 -1
  115. package/dist/dev/keyboard.js +9 -3
  116. package/dist/dev/keyboard.js.map +1 -1
  117. package/dist/dev/log-server.js +3 -0
  118. package/dist/dev/log-server.js.map +1 -1
  119. package/dist/dev/loopback-http-listener.d.ts +18 -0
  120. package/dist/dev/loopback-http-listener.d.ts.map +1 -0
  121. package/dist/dev/loopback-http-listener.js +91 -0
  122. package/dist/dev/loopback-http-listener.js.map +1 -0
  123. package/dist/dev/managed-session.d.ts +124 -0
  124. package/dist/dev/managed-session.d.ts.map +1 -0
  125. package/dist/dev/managed-session.js +597 -0
  126. package/dist/dev/managed-session.js.map +1 -0
  127. package/dist/dev/port-process.d.ts +21 -0
  128. package/dist/dev/port-process.d.ts.map +1 -0
  129. package/dist/dev/port-process.js +145 -0
  130. package/dist/dev/port-process.js.map +1 -0
  131. package/dist/dev/session-manager.d.ts +11 -1
  132. package/dist/dev/session-manager.d.ts.map +1 -1
  133. package/dist/dev/session-manager.js +65 -14
  134. package/dist/dev/session-manager.js.map +1 -1
  135. package/dist/dev/stream-event-format.d.ts +14 -0
  136. package/dist/dev/stream-event-format.d.ts.map +1 -0
  137. package/dist/dev/stream-event-format.js +262 -0
  138. package/dist/dev/stream-event-format.js.map +1 -0
  139. package/dist/dev/stream-filter.d.ts.map +1 -1
  140. package/dist/dev/stream-filter.js +16 -1
  141. package/dist/dev/stream-filter.js.map +1 -1
  142. package/dist/dev/stream-renderer.d.ts +4 -2
  143. package/dist/dev/stream-renderer.d.ts.map +1 -1
  144. package/dist/dev/stream-renderer.js +224 -84
  145. package/dist/dev/stream-renderer.js.map +1 -1
  146. package/dist/errors-registry.d.ts.map +1 -1
  147. package/dist/errors-registry.js +246 -0
  148. package/dist/errors-registry.js.map +1 -1
  149. package/dist/help.d.ts.map +1 -1
  150. package/dist/help.js +20 -3
  151. package/dist/help.js.map +1 -1
  152. package/dist/hubspot-app-logs.d.ts +38 -5
  153. package/dist/hubspot-app-logs.d.ts.map +1 -1
  154. package/dist/hubspot-app-logs.js +88 -17
  155. package/dist/hubspot-app-logs.js.map +1 -1
  156. package/dist/hubspot-auth.d.ts +19 -1
  157. package/dist/hubspot-auth.d.ts.map +1 -1
  158. package/dist/hubspot-auth.js +228 -83
  159. package/dist/hubspot-auth.js.map +1 -1
  160. package/dist/hubspot-developer-client.d.ts +87 -0
  161. package/dist/hubspot-developer-client.d.ts.map +1 -1
  162. package/dist/hubspot-developer-client.js +232 -1
  163. package/dist/hubspot-developer-client.js.map +1 -1
  164. package/dist/hubspot-project-archive.d.ts +21 -0
  165. package/dist/hubspot-project-archive.d.ts.map +1 -0
  166. package/dist/hubspot-project-archive.js +281 -0
  167. package/dist/hubspot-project-archive.js.map +1 -0
  168. package/dist/hubspot-project.d.ts +32 -0
  169. package/dist/hubspot-project.d.ts.map +1 -0
  170. package/dist/hubspot-project.js +269 -0
  171. package/dist/hubspot-project.js.map +1 -0
  172. package/dist/local-project-picker.js +1 -1
  173. package/dist/local-project-picker.js.map +1 -1
  174. package/dist/machine-artifact.d.ts +16 -0
  175. package/dist/machine-artifact.d.ts.map +1 -0
  176. package/dist/machine-artifact.js +18 -0
  177. package/dist/machine-artifact.js.map +1 -0
  178. package/dist/machine-id.d.ts +15 -0
  179. package/dist/machine-id.d.ts.map +1 -1
  180. package/dist/machine-id.js +53 -0
  181. package/dist/machine-id.js.map +1 -1
  182. package/dist/mutation-consent.d.ts +20 -0
  183. package/dist/mutation-consent.d.ts.map +1 -0
  184. package/dist/mutation-consent.js +9 -0
  185. package/dist/mutation-consent.js.map +1 -0
  186. package/dist/output-context.d.ts +3 -0
  187. package/dist/output-context.d.ts.map +1 -1
  188. package/dist/output-context.js +7 -1
  189. package/dist/output-context.js.map +1 -1
  190. package/dist/prompt.d.ts.map +1 -1
  191. package/dist/prompt.js +123 -48
  192. package/dist/prompt.js.map +1 -1
  193. package/dist/reporter/index.js +1 -1
  194. package/dist/reporter/index.js.map +1 -1
  195. package/dist/reporter/style.d.ts.map +1 -1
  196. package/dist/reporter/style.js +5 -0
  197. package/dist/reporter/style.js.map +1 -1
  198. package/dist/services/hubspot-dev-client.js +3 -0
  199. package/dist/services/hubspot-dev-client.js.map +1 -1
  200. package/dist/services/telemetry.d.ts +1 -2
  201. package/dist/services/telemetry.d.ts.map +1 -1
  202. package/dist/services/telemetry.js +1 -10
  203. package/dist/services/telemetry.js.map +1 -1
  204. package/dist/telemetry.d.ts.map +1 -1
  205. package/dist/telemetry.js +1 -7
  206. package/dist/telemetry.js.map +1 -1
  207. package/dist/tenant-data-plane-provisioning.d.ts +35 -0
  208. package/dist/tenant-data-plane-provisioning.d.ts.map +1 -0
  209. package/dist/tenant-data-plane-provisioning.js +38 -0
  210. package/dist/tenant-data-plane-provisioning.js.map +1 -0
  211. package/package.json +14 -12
@@ -1,14 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
- import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile, } from 'node:fs/promises';
3
+ import { lstat, mkdir, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile, } from 'node:fs/promises';
4
4
  import { createServer } from 'node:http';
5
- import { tmpdir } from 'node:os';
6
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
6
  import { pathToFileURL } from 'node:url';
8
7
  import * as Command from '@effect/cli/Command';
9
8
  import * as Options from '@effect/cli/Options';
10
9
  import { cloudflareResourceName, generateHubSpotRuntimeProject, generateProjectArtifacts, planPortalSchemaManagement, planWorkerCronTriggers, renderAlchemyProgram, renderCloudflareWorkerEntrypoint, stampMigratedRunServerlessShimOrigin, } from '@hs-x/codegen';
11
- import { HttpClientRequest, Schema, schemas } from '@hs-x/types';
10
+ import { HttpClientRequest, Schema, schemas, signDeploymentAttestationGrant } from '@hs-x/types';
12
11
  import { validateProject } from '@hs-x/validator';
13
12
  import { Effect, Option } from 'effect';
14
13
  import { isLinked } from '../account-store.js';
@@ -16,24 +15,32 @@ import { exitWith } from '../cli-error.js';
16
15
  import { accountIdOption, controlPlaneUrlOption, cwdOption, deployIdOption, forceOption, jsonOption, projectIdOption, runHandler, runQuarantined, userIdOption, yesOption, } from '../cli/kit.js';
17
16
  import { resolveCloudflareCredentials } from '../cloudflare-kv.js';
18
17
  import { loadCloudflarePointer } from '../cloudflare-pointer.js';
18
+ import { resolveOrProvisionCloudflareScopedResource } from '../cloudflare-scoped-resource.js';
19
19
  import { DEFAULT_CONTROL_PLANE_URL, formatConfigUrl, loadCliConfig, resolveControlPlaneUrl, } from '../config.js';
20
20
  import { CLI_VERSION } from '../constants.js';
21
21
  import { controlPlaneAuthHeaders } from '../control-plane-fetch.js';
22
22
  import { loadCreateControlPlane } from '../control-plane-loader.js';
23
+ import { createControlPlaneTenantProvisioningAuthority, } from '../control-plane-tenant-provisioning.js';
23
24
  import { captureDeployRevision } from '../deploy-revision-capture.js';
25
+ import { resolveDeployTargets } from '../deploy-targets.js';
24
26
  import { executeCliHttp } from '../effect-http.js';
25
- import { createDeployHubSpotDeveloperClient } from '../hubspot-developer-client.js';
27
+ import { DirectHubSpotAccountResolutionError, createDeployHubSpotDeveloperClient, resolveDirectHubSpotContext, } from '../hubspot-developer-client.js';
28
+ import { HubSpotProjectArchiveSourceError, fingerprintHubSpotArchiveSource, writeLocalHubSpotProjectArchive, } from '../hubspot-project-archive.js';
29
+ import { detectLocalProjectMode, readHubSpotProjectDescriptor, validateHubSpotProject, } from '../hubspot-project.js';
26
30
  import { hydrateAncestorEnv } from '../load-env.js';
27
31
  import { LocalProjectPickerCancelled, resolveLocalProject } from '../local-project-picker.js';
28
- import { getMachineId } from '../machine-id.js';
32
+ import { getMachineId, getMachineSecret, previewMachineId } from '../machine-id.js';
33
+ import { requestMutationConsent } from '../mutation-consent.js';
29
34
  import { cachePath } from '../paths.js';
30
35
  import { readProjectBinding, writeProjectBinding } from '../project-binding.js';
31
36
  import { isInteractive, promptConfirm, promptMultiSelect, promptPassword, promptSelect, promptText, } from '../prompt.js';
32
37
  import { createReporter } from '../reporter/index.js';
33
38
  import { Cwd } from '../services/cwd.js';
39
+ import { createTenantDataPlaneProvisioningCoordinator, } from '../tenant-data-plane-provisioning.js';
34
40
  import { ensureTenantStateStore, preflightTenantStateCredentials, } from '../tenant-state.js';
35
41
  import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
36
42
  import { deployPromoteCommand, requestControlPlaneDeployPromotion } from './deploy-promote.js';
43
+ import { withTransientDeployRetry } from './deploy-retry.js';
37
44
  async function hostedHttp(input) {
38
45
  const headers = { ...(input.headers ?? {}) };
39
46
  let request = HttpClientRequest.make(input.method ?? 'GET')(input.url).pipe(HttpClientRequest.setHeaders(headers));
@@ -149,10 +156,11 @@ async function promptMissingText(message, current, validate) {
149
156
  * across concurrent worker deploys), then persist answers to the process env so
150
157
  * sibling workers reuse them.
151
158
  *
152
- * OAuth-only bootstrap deploys retain the historical warning-only behavior
153
- * because the Auth page may not exist until after upload. Card backends fail
154
- * closed before the Worker deploy: shipping one without the HMAC key guarantees
155
- * that every live `hubspot.fetch` returns 401.
159
+ * OAuth-only bootstrap deploys may proceed with a warning because the Auth page
160
+ * may not exist until after upload, but every HubSpot-authored runtime route
161
+ * returns 503 until the secret is configured and the Worker is redeployed.
162
+ * Card backends fail earlier, at deploy time, because they cannot be useful in
163
+ * that bootstrap state.
156
164
  */
157
165
  /** @internal Exported for the deploy credential-gate regression suite. */
158
166
  export async function resolveHubSpotRuntimeCredentials(input) {
@@ -333,7 +341,7 @@ async function promptMissingCloudflareCredentials(input) {
333
341
  reporter.info('Easiest: `hs-x connect cloudflare` signs in via your browser and stores it for every future deploy.');
334
342
  reporter.info(`Or paste an API token (HS-X template): ${CLOUDFLARE_TOKEN_TEMPLATE_URL}`);
335
343
  for (;;) {
336
- const token = await promptText({
344
+ const token = await promptPassword({
337
345
  message: 'Paste your Cloudflare API token',
338
346
  validate: (v) => (v.length >= 30 ? undefined : 'Token looks too short.'),
339
347
  });
@@ -534,6 +542,447 @@ function workerSourceSummaries(workers) {
534
542
  function workerSyncSchemaModes(workers) {
535
543
  return workers.flatMap((worker) => worker.capabilities.flatMap((capability) => capability.kind === 'sync' ? [`${capability.id}: ${capability.manageSchema ?? false}`] : []));
536
544
  }
545
+ function includesCliFlag(argv, flag) {
546
+ return argv.some((argument) => argument === flag || argument.startsWith(`${flag}=`));
547
+ }
548
+ function directDeployAccountView(account) {
549
+ return {
550
+ id: account.id,
551
+ ...(account.name ? { name: account.name } : {}),
552
+ source: account.source,
553
+ selectedBy: account.selectedBy,
554
+ authReady: account.authReady,
555
+ ...(account.configSource ? { configSource: account.configSource } : {}),
556
+ };
557
+ }
558
+ function directDeployAccountLabel(account) {
559
+ if (!account)
560
+ return 'unresolved HubSpot account';
561
+ return account.name ? `${account.name} (${account.id})` : account.id;
562
+ }
563
+ function directDeployAccountFailure(cause) {
564
+ if (cause instanceof DirectHubSpotAccountResolutionError) {
565
+ const suffix = ` ${cause.hint}`;
566
+ return {
567
+ reasonCode: cause.code,
568
+ message: cause.message.endsWith(suffix)
569
+ ? cause.message.slice(0, -suffix.length)
570
+ : cause.message,
571
+ hint: cause.hint,
572
+ };
573
+ }
574
+ return {
575
+ reasonCode: 'HSX_E_DIRECT_HUBSPOT_ACCOUNT_CONFIG',
576
+ message: cause instanceof Error ? cause.message : String(cause),
577
+ hint: 'Fix the HubSpot CLI account configuration and retry.',
578
+ };
579
+ }
580
+ function describeHubSpotDeployFailure(cause) {
581
+ const record = isRecord(cause) ? cause : undefined;
582
+ const data = isRecord(record?.data) ? record.data : undefined;
583
+ const body = isRecord(record?.body) ? record.body : undefined;
584
+ const validationErrors = Array.isArray(record?.validationErrors)
585
+ ? record.validationErrors.filter((item) => typeof item === 'string')
586
+ : [];
587
+ const values = [
588
+ cause instanceof Error ? cause.message : String(cause),
589
+ typeof record?.status === 'number' ? `HTTP ${record.status}` : undefined,
590
+ typeof data?.message === 'string' ? data.message : undefined,
591
+ typeof body?.message === 'string' ? body.message : undefined,
592
+ typeof record?.category === 'string' ? record.category : undefined,
593
+ typeof data?.category === 'string' ? data.category : undefined,
594
+ ...validationErrors,
595
+ typeof record?.correlationId === 'string'
596
+ ? `correlation ${record.correlationId}`
597
+ : typeof data?.correlationId === 'string'
598
+ ? `correlation ${data.correlationId}`
599
+ : typeof body?.correlationId === 'string'
600
+ ? `correlation ${body.correlationId}`
601
+ : undefined,
602
+ ].filter((value) => Boolean(value));
603
+ return [...new Set(values)].join(' · ');
604
+ }
605
+ function directDeployDoneMessage(upload) {
606
+ if (upload.deploySkipped && !upload.autoDeployExpected) {
607
+ return 'Uploaded and built in HubSpot; deployment skipped';
608
+ }
609
+ if (upload.autoDeployExpected)
610
+ return 'Uploaded, built, and auto-deployed in HubSpot';
611
+ return 'Uploaded, built, and deployed in HubSpot';
612
+ }
613
+ async function hubSpotDirectDeployCommand(input) {
614
+ try {
615
+ return await executeHubSpotDirectDeployCommand(input);
616
+ }
617
+ catch (cause) {
618
+ if (!(cause instanceof HubSpotProjectArchiveSourceError))
619
+ throw cause;
620
+ return inputValidationError({
621
+ argv: input.argv,
622
+ command: 'deploy',
623
+ json: input.json,
624
+ code: cause.code,
625
+ message: cause.message,
626
+ hint: cause.hint,
627
+ detail: 'No archive was retained and no upload or deploy request was made.',
628
+ });
629
+ }
630
+ }
631
+ async function executeHubSpotDirectDeployCommand({ argv, root, json, }) {
632
+ const reporter = createReporter({ command: 'deploy', argv, entry: !json });
633
+ const machineOutput = json || reporter.mode === 'ndjson';
634
+ const unsupported = [
635
+ '--account-id',
636
+ '--project-id',
637
+ '--control-plane-url',
638
+ '--build-only',
639
+ '--cloudflare-deploy',
640
+ '--cloudflare-dry-run',
641
+ '--local-control-plane',
642
+ '--record-local',
643
+ '--promote-when-healthy',
644
+ '--no-record',
645
+ '--heartbeat',
646
+ '--no-heartbeat',
647
+ '--apply-schema',
648
+ '--portal-schema-live',
649
+ '--portal-schema-fixture',
650
+ '--local-hubspot-schema',
651
+ ].find((flag) => includesCliFlag(argv, flag));
652
+ if (unsupported) {
653
+ return inputValidationError({
654
+ argv,
655
+ command: 'deploy',
656
+ json,
657
+ code: 'HSX_E_INPUT_INVALID',
658
+ message: `${unsupported} is an HS-X project feature.`,
659
+ hint: 'Remove the flag to deploy this native HubSpot project directly to HubSpot.',
660
+ detail: 'HubSpot-direct mode never contacts the HS-X control plane or Cloudflare.',
661
+ });
662
+ }
663
+ const preparationFingerprint = await fingerprintHubSpotArchiveSource(root, false);
664
+ const [descriptor, validation, projectName, intermediateRepresentation] = await Promise.all([
665
+ readHubSpotProjectDescriptor(root),
666
+ validateHubSpotProject({ root }),
667
+ resolveHubSpotProjectName(argv, root),
668
+ readHubSpotProjectIntermediateRepresentation(root),
669
+ ]);
670
+ const appUid = readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
671
+ (await readAppUidFromProjectSource(root));
672
+ const sourceFingerprint = validation.ok
673
+ ? await fingerprintHubSpotArchiveSource(root, intermediateRepresentation !== undefined)
674
+ : preparationFingerprint;
675
+ // Enclose every source-derived preparation read (validation, descriptor,
676
+ // IR, app uid, and scoped fingerprint) in one full-tree stability check.
677
+ // A later edit is still caught against sourceFingerprint in the staged copy.
678
+ if ((await fingerprintHubSpotArchiveSource(root, false)) !== preparationFingerprint) {
679
+ return inputValidationError({
680
+ argv,
681
+ command: 'deploy',
682
+ json,
683
+ code: 'HSX_E_INPUT_INVALID',
684
+ message: 'HubSpot project sources changed while preparing the deploy plan.',
685
+ hint: 'Wait for local edits or generators to settle, then review a fresh plan.',
686
+ detail: 'No upload or deploy request was made.',
687
+ });
688
+ }
689
+ const uploadOnly = argv.includes('--hubspot-upload-only') || argv.includes('--skip-deploy');
690
+ const planOnly = argv.includes('--plan') || argv.includes('--dry-run');
691
+ const localHubSpotAdapter = argv.includes('--local-hubspot-upload');
692
+ const platformVersion = resolveFlag(argv, '--platform-version') ?? descriptor.platformVersion ?? '2026.03';
693
+ const buildMessage = resolveFlag(argv, '--message') ?? 'HS-X HubSpot-direct deploy';
694
+ const buildTimeoutMs = Number(resolveFlag(argv, '--hubspot-build-timeout-ms') ?? '60000');
695
+ const force = argv.includes('--force');
696
+ let account;
697
+ let accountFailure;
698
+ let preparedUpload;
699
+ if (localHubSpotAdapter) {
700
+ account = {
701
+ id: resolveFlag(argv, '--developer-account-id') ?? 'local',
702
+ name: 'local HubSpot adapter',
703
+ source: 'local-adapter',
704
+ selectedBy: 'local-adapter',
705
+ authReady: true,
706
+ };
707
+ preparedUpload = {
708
+ projectName,
709
+ intermediateRepresentation,
710
+ appUid,
711
+ developerAccountId: resolveFlag(argv, '--developer-account-id') ?? process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID,
712
+ platformVersion,
713
+ buildMessage,
714
+ buildTimeoutMs,
715
+ force,
716
+ sourceFingerprint,
717
+ createClient: () => createDeployHubSpotDeveloperClient({
718
+ argv,
719
+ local: true,
720
+ allowedOperations: ['hubspot-project-upload'],
721
+ cwd: root,
722
+ }),
723
+ };
724
+ }
725
+ else {
726
+ try {
727
+ const context = await resolveDirectHubSpotContext({ argv, cwd: root });
728
+ account = directDeployAccountView(context.account);
729
+ if (!account.authReady) {
730
+ accountFailure = {
731
+ reasonCode: 'HSX_E_DIRECT_HUBSPOT_AUTH_REQUIRED',
732
+ message: `HubSpot account ${directDeployAccountLabel(account)} does not have deploy authentication available.`,
733
+ hint: 'Run `hs account auth` for that account, or pass --pak for this run.',
734
+ };
735
+ }
736
+ else {
737
+ preparedUpload = {
738
+ projectName,
739
+ intermediateRepresentation,
740
+ appUid,
741
+ developerAccountId: context.account.id,
742
+ platformVersion,
743
+ buildMessage,
744
+ buildTimeoutMs,
745
+ force,
746
+ sourceFingerprint,
747
+ createClient: async () => context.createClient(),
748
+ };
749
+ }
750
+ }
751
+ catch (cause) {
752
+ accountFailure = directDeployAccountFailure(cause);
753
+ }
754
+ }
755
+ const ready = validation.ok &&
756
+ account !== undefined &&
757
+ accountFailure === undefined &&
758
+ preparedUpload !== undefined;
759
+ const plan = {
760
+ schema_version: 1,
761
+ ok: ready,
762
+ command: 'deploy',
763
+ mode: 'hubspot-direct',
764
+ root,
765
+ project: {
766
+ name: projectName,
767
+ ...(projectName !== descriptor.name ? { configuredName: descriptor.name } : {}),
768
+ platformVersion,
769
+ srcDir: descriptor.srcDir,
770
+ sourceFingerprint,
771
+ ...(descriptor.appUid ? { appUid: descriptor.appUid } : {}),
772
+ },
773
+ account: account
774
+ ? { ...account, ...(accountFailure ? { error: accountFailure } : {}) }
775
+ : {
776
+ authReady: false,
777
+ error: accountFailure,
778
+ },
779
+ targets: { hubspot: true, cloudflare: false, controlPlane: false },
780
+ action: uploadOnly ? 'upload-and-build' : 'upload-build-and-deploy',
781
+ options: {
782
+ force,
783
+ buildMessage,
784
+ buildTimeoutMs,
785
+ skipAutoDeploy: uploadOnly,
786
+ },
787
+ validation,
788
+ };
789
+ const rows = [
790
+ { key: 'mode', value: 'HubSpot direct' },
791
+ { key: 'source', value: root },
792
+ { key: 'project', value: projectName },
793
+ ...(account
794
+ ? [
795
+ {
796
+ key: 'account',
797
+ value: directDeployAccountLabel(account),
798
+ status: account.authReady ? 'ok' : 'fail',
799
+ detail: account.configSource ?? account.selectedBy,
800
+ ...(account.authReady
801
+ ? {}
802
+ : { hint: 'Run `hs account auth` for this account, then retry.' }),
803
+ },
804
+ ]
805
+ : [
806
+ {
807
+ key: 'account',
808
+ value: 'not ready',
809
+ status: 'fail',
810
+ ...(accountFailure?.reasonCode ? { detail: accountFailure.reasonCode } : {}),
811
+ ...(accountFailure?.hint ? { hint: accountFailure.hint } : {}),
812
+ },
813
+ ]),
814
+ { key: 'platform', value: platformVersion },
815
+ { key: 'build message', value: buildMessage },
816
+ { key: 'build timeout', value: `${buildTimeoutMs}ms` },
817
+ {
818
+ key: 'HubSpot',
819
+ value: uploadOnly ? 'upload + build only' : 'upload + build + deploy',
820
+ status: 'ok',
821
+ },
822
+ { key: 'auto-deploy', value: uploadOnly ? 'explicitly skipped' : 'project setting' },
823
+ { key: 'force', value: force ? 'yes' : 'no', ...(force ? { status: 'warn' } : {}) },
824
+ { key: 'Cloudflare', value: 'not used' },
825
+ { key: 'HS-X control plane', value: 'not used' },
826
+ {
827
+ key: 'validation',
828
+ value: validation.ok ? 'pass' : 'fail',
829
+ status: validation.ok ? 'ok' : 'fail',
830
+ },
831
+ ...validation.diagnostics.map((diagnostic) => ({
832
+ key: diagnostic.code,
833
+ value: diagnostic.message,
834
+ status: diagnostic.severity === 'error' ? 'fail' : 'warn',
835
+ ...(diagnostic.file ? { detail: diagnostic.file } : {}),
836
+ })),
837
+ ];
838
+ if (planOnly || !ready) {
839
+ if (json) {
840
+ write(`${JSON.stringify(plan, null, 2)}\n`);
841
+ }
842
+ else if (reporter.mode === 'ndjson') {
843
+ reporter.data(plan);
844
+ reporter.done(ready ? 'Plan ready' : 'Plan not ready', ready ? 0 : 10);
845
+ }
846
+ else {
847
+ reporter.header(projectName);
848
+ reporter.rows(rows);
849
+ reporter.done(ready ? 'Plan ready' : 'Plan not ready', ready ? 0 : 10);
850
+ }
851
+ return { exitCode: ready ? 0 : validation.ok ? 10 : 1 };
852
+ }
853
+ const summary = [
854
+ 'HubSpot-direct deploy plan',
855
+ `Project: ${projectName}`,
856
+ `Account: ${directDeployAccountLabel(account)}`,
857
+ `Source: ${root}`,
858
+ `Action: ${uploadOnly ? 'upload and build only (skip auto-deploy)' : 'upload, wait for build, and deploy'}`,
859
+ `Platform: ${platformVersion}`,
860
+ `Force: ${force ? 'yes' : 'no'}`,
861
+ `Build message: ${buildMessage}`,
862
+ `Build timeout: ${buildTimeoutMs}ms`,
863
+ 'Cloudflare: not used',
864
+ 'HS-X control plane: not used',
865
+ ].join('\n');
866
+ // MutationConsent requires the exact read-only preview to be visible before
867
+ // it asks. In machine modes the same plan is available via --plan; --yes is
868
+ // mandatory and no interactive output is emitted.
869
+ if (!machineOutput) {
870
+ reporter.header(projectName);
871
+ reporter.rows(rows);
872
+ }
873
+ const consent = await requestMutationConsent({
874
+ yes: argv.includes('--yes') || argv.includes('-y'),
875
+ machineOutput,
876
+ interactive: isInteractive(),
877
+ message: `Deploy ${projectName} to ${directDeployAccountLabel(account)}?`,
878
+ confirm: promptConfirm,
879
+ });
880
+ if (consent === 'require-yes') {
881
+ return inputValidationError({
882
+ argv,
883
+ command: 'deploy',
884
+ json,
885
+ code: 'HSX_E_INPUT_INVALID',
886
+ message: 'Deploy requires explicit consent in machine or non-interactive mode.',
887
+ hint: 'Review with `hs-x deploy --plan`, then re-run with --yes.',
888
+ detail: summary,
889
+ });
890
+ }
891
+ if (consent === 'cancelled')
892
+ return cancelledResult(argv, 'deploy');
893
+ if (!preparedUpload) {
894
+ return inputValidationError({
895
+ argv,
896
+ command: 'deploy',
897
+ json,
898
+ code: 'HSX_E_DIRECT_HUBSPOT_ACCOUNT_CONFIG',
899
+ message: 'The HubSpot deploy target changed while preparing the execution plan.',
900
+ hint: 'Review a fresh plan and retry.',
901
+ detail: 'No upload or deploy request was made.',
902
+ });
903
+ }
904
+ try {
905
+ const step = machineOutput ? undefined : reporter.step('Uploading to HubSpot');
906
+ const uploaded = await executeHubSpotOnlyUpload({
907
+ argv,
908
+ root,
909
+ local: localHubSpotAdapter,
910
+ persistBinding: false,
911
+ uploadOnly,
912
+ prepared: preparedUpload,
913
+ });
914
+ const buildState = readStatus(uploaded.buildStatus) ?? 'UNKNOWN';
915
+ const deployState = uploaded.deployStatus
916
+ ? (readStatus(uploaded.deployStatus) ?? 'UNKNOWN')
917
+ : undefined;
918
+ const deployUnverified = uploaded.autoDeployExpected && deployState === undefined;
919
+ const ok = buildState === 'SUCCESS' && !deployUnverified && (!deployState || deployState === 'SUCCESS');
920
+ step?.[ok ? 'ok' : 'fail'](`build #${uploaded.buildId} ${buildState}${deployState ? ` · deploy ${deployState}` : deployUnverified ? ' · deploy unverified' : ''}`);
921
+ const result = {
922
+ schema_version: 1,
923
+ ok,
924
+ command: 'deploy',
925
+ mode: 'hubspot-direct',
926
+ account,
927
+ projectName: uploaded.projectName,
928
+ appId: uploaded.appId || undefined,
929
+ buildId: uploaded.buildId,
930
+ buildStatus: buildState,
931
+ ...(uploaded.deployId !== undefined ? { deployId: uploaded.deployId } : {}),
932
+ ...(deployState ? { deployStatus: deployState } : {}),
933
+ deploySkipped: uploaded.deploySkipped,
934
+ deployVerification: deployUnverified ? 'unverified' : deployState ? 'verified' : 'skipped',
935
+ controlPlaneUsed: false,
936
+ cloudflareUsed: false,
937
+ };
938
+ if (json) {
939
+ write(`${JSON.stringify(result, null, 2)}\n`);
940
+ }
941
+ else if (reporter.mode === 'ndjson') {
942
+ reporter.data(result);
943
+ reporter.done(ok ? directDeployDoneMessage(uploaded) : 'HubSpot deploy failed', ok ? 0 : 1);
944
+ }
945
+ else {
946
+ streamHubSpotUploadOutcome(reporter, uploaded);
947
+ if (deployUnverified) {
948
+ reporter.error('HSX_E_DEPLOY_HUBSPOT', 'HubSpot auto-deploy could not be verified before the timeout.', { hint: 'Check the project deploy status in HubSpot, then retry if it did not ship.' });
949
+ }
950
+ reporter.done(ok ? directDeployDoneMessage(uploaded) : 'HubSpot deploy failed', ok ? 0 : 1);
951
+ }
952
+ return { exitCode: ok ? 0 : 1 };
953
+ }
954
+ catch (cause) {
955
+ if (cause instanceof HubSpotProjectArchiveSourceError)
956
+ throw cause;
957
+ const code = cause instanceof DirectHubSpotAccountResolutionError
958
+ ? 'HSX_E_DEPLOY_HUBSPOT_AUTH'
959
+ : 'HSX_E_DEPLOY_HUBSPOT';
960
+ const message = describeHubSpotDeployFailure(cause);
961
+ const hint = cause instanceof DirectHubSpotAccountResolutionError
962
+ ? cause.hint
963
+ : 'Review the HubSpot status and correlation id above, fix the reported issue, and retry.';
964
+ if (json) {
965
+ write(`${JSON.stringify({
966
+ schema_version: 1,
967
+ command: 'deploy',
968
+ ok: false,
969
+ error: { code, message, hint },
970
+ }, null, 2)}\n`);
971
+ }
972
+ else if (reporter.mode === 'ndjson') {
973
+ reporter.error(code, message, { hint });
974
+ reporter.done('HubSpot deploy failed', 1);
975
+ }
976
+ else {
977
+ reporter.error(code, message, {
978
+ hint,
979
+ docs_url: `https://hs-x.dev/errors/${code}`,
980
+ });
981
+ reporter.done('HubSpot deploy failed', 1);
982
+ }
983
+ return { exitCode: 1 };
984
+ }
985
+ }
537
986
  export async function deployCommand({ argv, root, json, }) {
538
987
  // Stamp the whole invocation up front: the human summary reporter is created
539
988
  // late (render time), so without this "Deployed … in 1ms" measured only the
@@ -560,6 +1009,9 @@ export async function deployCommand({ argv, root, json, }) {
560
1009
  throw error;
561
1010
  }
562
1011
  }
1012
+ if ((await detectLocalProjectMode(root)) === 'hubspot-direct') {
1013
+ return hubSpotDirectDeployCommand({ argv, root, json });
1014
+ }
563
1015
  // bun only auto-loads .env/.env.local from the CWD; when deploying from a
564
1016
  // nested app dir, pull in a repo-root .env.local (CLOUDFLARE_ACCOUNT_ID, etc.)
565
1017
  // so the deploy doesn't fail for vars that are set at the root.
@@ -591,7 +1043,6 @@ export async function deployCommand({ argv, root, json, }) {
591
1043
  const explicitControlPlaneUrl = resolveFlag(argv, '--control-plane-url');
592
1044
  const controlPlaneUrl = explicitControlPlaneUrl ?? (planOnly ? undefined : process.env.HSX_CONTROL_PLANE_URL);
593
1045
  const linked = await isLinked();
594
- const machineId = await getMachineId();
595
1046
  const userId = resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user';
596
1047
  const resolvedAcct = await resolveAccountIdOrPrompt({
597
1048
  argv,
@@ -610,10 +1061,6 @@ export async function deployCommand({ argv, root, json, }) {
610
1061
  // One-off --account-id override: warn but leave the recorded binding intact.
611
1062
  progress?.reporter.warn('HSX_W_DEPLOY_ACCOUNT_OVERRIDE', `This project is bound to ${resolvedAcct.overrodeBinding}; deploying under ${accountId} for this run only (binding unchanged).`);
612
1063
  }
613
- else {
614
- // Sticky binding: remember which HS-X account this project deploys under.
615
- await writeProjectBinding(root, accountId).catch(() => { });
616
- }
617
1064
  }
618
1065
  const explicitProjectId = resolveFlag(argv, '--project-id') ?? process.env.HSX_PROJECT_ID;
619
1066
  let projectId = explicitProjectId;
@@ -647,9 +1094,16 @@ export async function deployCommand({ argv, root, json, }) {
647
1094
  }
648
1095
  const needsRuntime = workers.some((worker) => worker.capabilities.some((capability) => capability.runtimeNeeds.some((need) => need !== 'hubspot-only')));
649
1096
  const needsHubSpotBundle = needsRuntime || app.mcpServers.length > 0;
650
- const cloudflareDeployRequested = !buildOnly &&
651
- (explicitCloudflareDeployRequested || (!planOnly && needsRuntime && !explicitHubspotUpload));
652
- const hubspotUploadRequested = !buildOnly && (explicitHubspotUpload || (!planOnly && !cloudflareDryRun && Boolean(projectId)));
1097
+ const { deploymentMode, cloudflareDeployRequested, hubspotUploadRequested } = resolveDeployTargets({
1098
+ buildOnly,
1099
+ planOnly,
1100
+ needsRuntime,
1101
+ hasProjectId: Boolean(projectId),
1102
+ hubspotOnly,
1103
+ explicitHubspotUpload,
1104
+ explicitCloudflareDeploy: explicitCloudflareDeployRequested,
1105
+ cloudflareDryRun,
1106
+ });
653
1107
  if (echo) {
654
1108
  echo.info(`Runtime: ${needsRuntime ? 'Cloudflare Worker required' : 'HubSpot-only compatible'}`);
655
1109
  if (hubspotOnly && needsRuntime) {
@@ -724,15 +1178,11 @@ export async function deployCommand({ argv, root, json, }) {
724
1178
  projectId = answer;
725
1179
  }
726
1180
  }
727
- if (projectId && accountId && !resolvedAcct.overrodeBinding) {
728
- // Sticky binding, project half: project-scoped reads (status/logs/routes/
729
- // drift) resolve their project id from here when no flag is passed.
730
- await writeProjectBinding(root, accountId, projectId).catch(() => { });
731
- }
732
- if (!json && willChangeAnything && !flagYes && isInteractive()) {
1181
+ if (willChangeAnything) {
733
1182
  const summary = buildDeployPlanSummary({
734
1183
  accountId,
735
1184
  projectId,
1185
+ deploymentMode,
736
1186
  workers,
737
1187
  portalSchemaPlan,
738
1188
  applySchema,
@@ -743,14 +1193,37 @@ export async function deployCommand({ argv, root, json, }) {
743
1193
  shouldRecordControlPlane,
744
1194
  promoteWhenHealthy,
745
1195
  });
746
- progress?.reporter.block(summary);
747
- const confirm = await promptConfirm({
1196
+ const machineOutput = json || progress?.reporter.mode === 'ndjson';
1197
+ if (!machineOutput && !flagYes)
1198
+ progress?.reporter.block(summary);
1199
+ const consent = await requestMutationConsent({
1200
+ yes: flagYes,
1201
+ machineOutput,
1202
+ interactive: isInteractive(),
748
1203
  message: 'Apply this plan?',
749
- default: false,
1204
+ confirm: promptConfirm,
750
1205
  });
751
- if (confirm === undefined || confirm === false) {
752
- return cancelledResult(argv, 'deploy');
1206
+ if (consent === 'require-yes') {
1207
+ return inputValidationError({
1208
+ argv,
1209
+ command: 'deploy',
1210
+ json,
1211
+ code: 'HSX_E_INPUT_INVALID',
1212
+ message: 'Deploy requires explicit consent in machine or non-interactive mode.',
1213
+ hint: 'Review with `hs-x deploy --plan`, then re-run the mutating command with --yes.',
1214
+ detail: summary,
1215
+ });
753
1216
  }
1217
+ if (consent === 'cancelled')
1218
+ return cancelledResult(argv, 'deploy');
1219
+ }
1220
+ // Consent is the boundary for durable command state too. A declined deploy,
1221
+ // JSON/non-TTY invocation without --yes, or --plan must not create a machine
1222
+ // id or rewrite the project's sticky account/project binding.
1223
+ const machineId = willChangeAnything ? await getMachineId() : await previewMachineId();
1224
+ const machineSecret = willChangeAnything && !linked ? await getMachineSecret() : undefined;
1225
+ if (willChangeAnything && accountId && !resolvedAcct.overrodeBinding) {
1226
+ await writeProjectBinding(root, accountId, projectId).catch(() => { });
754
1227
  }
755
1228
  // `--apply-schema` writes property definitions to the live portal, but
756
1229
  // `--plan`/`--dry-run` promise to change nothing. Honoring both would let a
@@ -1018,6 +1491,7 @@ export async function deployCommand({ argv, root, json, }) {
1018
1491
  controlPlanePlan,
1019
1492
  userId,
1020
1493
  machineId,
1494
+ ...(machineSecret ? { machineSecret } : {}),
1021
1495
  heartbeatEnabled: noHeartbeat
1022
1496
  ? false
1023
1497
  : controlPlaneBackedPlan
@@ -1040,8 +1514,8 @@ export async function deployCommand({ argv, root, json, }) {
1040
1514
  }
1041
1515
  if (echo && cloudflareDeployResult) {
1042
1516
  for (const worker of cloudflareDeployResult.workers) {
1043
- if (worker.unauthenticatedInvoke) {
1044
- echo.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1517
+ if (worker.signatureVerificationUnavailable) {
1518
+ echo.warn('HSX_W_DEPLOY_SIGNATURE_UNAVAILABLE', `${worker.workerName}: HubSpot workflow-action and trigger requests will fail closed until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1045
1519
  }
1046
1520
  }
1047
1521
  }
@@ -1209,6 +1683,16 @@ export async function deployCommand({ argv, root, json, }) {
1209
1683
  : planOnly
1210
1684
  ? 'plan'
1211
1685
  : 'local-artifacts',
1686
+ deploymentMode,
1687
+ targets: {
1688
+ cloudflare: {
1689
+ requested: cloudflareDeployRequested,
1690
+ dryRun: cloudflareDryRun,
1691
+ },
1692
+ hubspot: {
1693
+ uploadRequested: hubspotUploadRequested,
1694
+ },
1695
+ },
1212
1696
  root,
1213
1697
  hubspotOnly,
1214
1698
  needsRuntime,
@@ -1254,6 +1738,7 @@ export async function deployCommand({ argv, root, json, }) {
1254
1738
  hubspotUploadResult,
1255
1739
  linked,
1256
1740
  machineId,
1741
+ machineSecret,
1257
1742
  });
1258
1743
  }
1259
1744
  // Record tenant state + emit the deploy event whenever the worker is actually
@@ -1268,14 +1753,17 @@ export async function deployCommand({ argv, root, json, }) {
1268
1753
  plan: controlPlanePlan,
1269
1754
  environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1270
1755
  });
1271
- await emitAnonymousDeployEvent({
1272
- argv,
1273
- machineId,
1274
- projectId: controlPlanePlan?.projectId ?? projectId,
1275
- deployId: cloudflareDeployResult.deployId,
1276
- workerUrl: cloudflareDeployResult.workers.find((worker) => worker.url)?.url,
1277
- environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1278
- });
1756
+ if (machineSecret) {
1757
+ await emitAnonymousDeployEvent({
1758
+ argv,
1759
+ machineId,
1760
+ machineSecret,
1761
+ projectId: controlPlanePlan?.projectId ?? projectId,
1762
+ deployId: cloudflareDeployResult.deployId,
1763
+ workerUrl: cloudflareDeployResult.workers.find((worker) => worker.url)?.url,
1764
+ environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1765
+ });
1766
+ }
1279
1767
  }
1280
1768
  // 0 = clean, 20 = partial (worker live, HubSpot upload failed — distinct so
1281
1769
  // automation doesn't treat a serving worker as a total failure), 1 = failure.
@@ -1288,6 +1776,7 @@ function buildDeployPlanSummary(input) {
1288
1776
  lines.push(` account: ${input.accountId}`);
1289
1777
  if (input.projectId)
1290
1778
  lines.push(` project: ${input.projectId}`);
1779
+ lines.push(` deploy mode: ${input.deploymentMode}`);
1291
1780
  lines.push(` workers: ${input.workers.length} (${capCount} capabilities)`);
1292
1781
  if (input.applySchema && input.portalSchemaPlan) {
1293
1782
  const adds = input.portalSchemaPlan.actions.filter((item) => item.kind === 'will-create-object' || item.kind === 'will-create-property').length;
@@ -1306,8 +1795,7 @@ function buildDeployPlanSummary(input) {
1306
1795
  if (input.cloudflareDeployRequested) {
1307
1796
  lines.push(` cloudflare: ${input.cloudflareDryRun ? 'dry-run' : 'deploy'}`);
1308
1797
  }
1309
- if (input.hubspotUpload)
1310
- lines.push(' hubspot upload: yes');
1798
+ lines.push(` hubspot upload: ${input.hubspotUpload ? 'yes' : 'no'}`);
1311
1799
  return lines.join('\n');
1312
1800
  }
1313
1801
  function createUnlinkedDeployPlan(input) {
@@ -1351,6 +1839,7 @@ async function emitAnonymousDeployEvent(input) {
1351
1839
  headers: {
1352
1840
  'content-type': 'application/json',
1353
1841
  accept: 'application/json',
1842
+ authorization: `Bearer ${input.machineSecret}`,
1354
1843
  },
1355
1844
  body: JSON.stringify({
1356
1845
  schema_version: 1,
@@ -1446,7 +1935,12 @@ function deployDoneMessage(input, firstWorkerUrl) {
1446
1935
  }
1447
1936
  /** The railed key/value facts of what this deploy produced (human/plain). */
1448
1937
  function buildDeploySummaryRows(input) {
1449
- const rows = [];
1938
+ const rows = [
1939
+ {
1940
+ key: 'deploy mode',
1941
+ value: input.plan.deploymentMode,
1942
+ },
1943
+ ];
1450
1944
  if (input.controlPlanePlan) {
1451
1945
  rows.push({
1452
1946
  key: 'deploy',
@@ -1524,7 +2018,10 @@ function buildDeploySummaryRows(input) {
1524
2018
  }
1525
2019
  }
1526
2020
  if (!input.linked && input.cloudflareDeployResult) {
1527
- rows.push({ key: 'history', value: machineDashboardUrl(input.machineId) });
2021
+ rows.push({
2022
+ key: 'history',
2023
+ value: machineDashboardUrl(input.machineId, input.machineSecret),
2024
+ });
1528
2025
  }
1529
2026
  return rows;
1530
2027
  }
@@ -1623,13 +2120,13 @@ function renderDeployNdjsonSummary(input) {
1623
2120
  .join(', ');
1624
2121
  reporter.info(`Cloudflare deploy: ${deployed}`);
1625
2122
  for (const worker of input.cloudflareDeployResult.workers) {
1626
- if (worker.unauthenticatedInvoke) {
1627
- reporter.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
2123
+ if (worker.signatureVerificationUnavailable) {
2124
+ reporter.warn('HSX_W_DEPLOY_SIGNATURE_UNAVAILABLE', `${worker.workerName}: HubSpot workflow-action and trigger requests will fail closed until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
1628
2125
  }
1629
2126
  }
1630
2127
  }
1631
2128
  if (!input.linked && input.cloudflareDeployResult) {
1632
- reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId)}`);
2129
+ reporter.info(`Deploy history for this machine: ${machineDashboardUrl(input.machineId, input.machineSecret)}`);
1633
2130
  }
1634
2131
  if (input.hubspotUploadResult) {
1635
2132
  const upload = input.hubspotUploadResult;
@@ -1679,9 +2176,12 @@ function renderDeployNdjsonSummary(input) {
1679
2176
  }
1680
2177
  reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1);
1681
2178
  }
1682
- function machineDashboardUrl(machineId) {
2179
+ function machineDashboardUrl(machineId, machineSecret) {
1683
2180
  const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
1684
- return `${base.replace(/\/$/, '')}/m/${encodeURIComponent(machineId)}`;
2181
+ const url = new URL(`/m/${encodeURIComponent(machineId)}`, `${base.replace(/\/$/, '')}/`);
2182
+ if (machineSecret)
2183
+ url.hash = new URLSearchParams({ access: machineSecret }).toString();
2184
+ return url.toString();
1685
2185
  }
1686
2186
  /**
1687
2187
  * The HubSpot developer-console URL for an uploaded app's auth tab, where a
@@ -2129,7 +2629,7 @@ function defaultHubSpotFieldType(type) {
2129
2629
  return 'text';
2130
2630
  }
2131
2631
  }
2132
- async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
2632
+ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, machineSecret, heartbeatEnabled, dryRun, note, }) {
2133
2633
  // Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
2134
2634
  // an unlinked deploy can't leave a Worker live and then error on the
2135
2635
  // tenant-state write for a missing account id.
@@ -2172,6 +2672,17 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2172
2672
  });
2173
2673
  }
2174
2674
  const sourcePaths = await discoverWorkerSourcePaths(root);
2675
+ // Provision once per tenant trust domain, then let every Worker continue its
2676
+ // deploy in parallel with the exact same resources and verifier secret.
2677
+ const tenantDataPlaneProvisioning = createTenantDataPlaneProvisioningCoordinator(controlPlaneUrl && !dryRun
2678
+ ? {
2679
+ authority: createControlPlaneTenantProvisioningAuthority({
2680
+ controlPlaneUrl,
2681
+ authorizationHeaders: () => controlPlaneAuthHeaders(userId),
2682
+ http: hostedHttp,
2683
+ }),
2684
+ }
2685
+ : {});
2175
2686
  const concurrency = Math.max(1, Math.min(4, workers.length));
2176
2687
  const results = await Effect.runPromise(Effect.forEach(workers.map((worker, index) => ({ worker, index })), ({ worker, index }) => Effect.promise(() => executeCloudflareWorkerDeploy({
2177
2688
  argv,
@@ -2183,8 +2694,10 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2183
2694
  controlPlanePlan,
2184
2695
  userId,
2185
2696
  machineId,
2697
+ ...(machineSecret ? { machineSecret } : {}),
2186
2698
  heartbeatEnabled,
2187
2699
  dryRun,
2700
+ tenantDataPlaneProvisioning,
2188
2701
  ...(controlPlaneUrl ? { controlPlaneUrl } : {}),
2189
2702
  ...(note ? { note } : {}),
2190
2703
  })), { concurrency }));
@@ -2193,7 +2706,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2193
2706
  workers: results,
2194
2707
  };
2195
2708
  }
2196
- async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowCredentialPrompt, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
2709
+ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowCredentialPrompt, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, machineSecret, heartbeatEnabled, dryRun, tenantDataPlaneProvisioning, note, }) {
2197
2710
  const emitNote = note ??
2198
2711
  ((line) => {
2199
2712
  process.stderr.write(line);
@@ -2217,10 +2730,24 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2217
2730
  const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
2218
2731
  const deployCliConfig = loadCliConfig(argv);
2219
2732
  const runtimeControlPlaneUrl = formatConfigUrl(deployCliConfig.runtimeControlPlaneUrl ?? new URL(DEFAULT_CONTROL_PLANE_URL));
2733
+ const exactTenantScope = {
2734
+ accountId: controlPlanePlan.accountId,
2735
+ projectId: controlPlanePlan.projectId,
2736
+ environment,
2737
+ };
2220
2738
  await writeFile(entrypointPath, renderCloudflareWorkerEntrypoint({
2221
2739
  workerImportPath: relativeImportPath(dirname(entrypointPath), workerSourcePath),
2222
2740
  requireActiveCardInstall: appConfig.auth === 'oauth',
2223
- ...(cronPlan.dispatch.length > 0 ? { syncCron: cronPlan } : {}),
2741
+ // Tenant D1/KV and the grant secret are shared across the linked app's
2742
+ // Workers. Wire the matching routes on every linked Worker even though
2743
+ // revision-bound heartbeat/attestation remains primary-only below.
2744
+ ...(controlPlaneUrl ? { tenantDataPlaneScope: exactTenantScope } : {}),
2745
+ ...(cronPlan.dispatch.length > 0
2746
+ ? {
2747
+ syncCron: cronPlan,
2748
+ syncRunScope: exactTenantScope,
2749
+ }
2750
+ : {}),
2224
2751
  ...(appConfig.appEventBatching !== undefined
2225
2752
  ? { appEventBatching: appConfig.appEventBatching }
2226
2753
  : {}),
@@ -2236,7 +2763,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2236
2763
  },
2237
2764
  }
2238
2765
  : {}),
2239
- ...(heartbeatEnabled
2766
+ // A linked deploy records the primary Worker's Cloudflare revision on
2767
+ // the deploy record, so only that Worker may attest against it. Anonymous
2768
+ // telemetry remains per Worker because it has no revision trust binding.
2769
+ ...(heartbeatEnabled && (!controlPlaneUrl || index === 0)
2240
2770
  ? {
2241
2771
  heartbeat: controlPlaneUrl
2242
2772
  ? {
@@ -2254,6 +2784,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2254
2784
  controlPlaneUrl: formatConfigUrl(loadCliConfig(argv).runtimeControlPlaneUrl ??
2255
2785
  new URL(DEFAULT_CONTROL_PLANE_URL)),
2256
2786
  machineId,
2787
+ machineSecretBinding: 'HSX_MACHINE_SECRET',
2257
2788
  projectId: controlPlanePlan.projectId,
2258
2789
  deployId: controlPlanePlan.deployId,
2259
2790
  environment,
@@ -2295,7 +2826,12 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2295
2826
  // flags KV + grant secret, persisting the scope + secret to local state so the
2296
2827
  // leaveable `hs-x flags` CLI can sign without the control plane.
2297
2828
  const tenantDataPlane = installRuntimeBinding && controlPlaneUrl && hubspotAppId && !dryRun
2298
- ? await ensureTenantDataPlane({
2829
+ ? await tenantDataPlaneProvisioning.resolve({
2830
+ accountId: controlPlanePlan.accountId,
2831
+ projectId: controlPlanePlan.projectId,
2832
+ environment,
2833
+ hubSpotAppId: hubspotAppId,
2834
+ }, (claim) => ensureTenantDataPlane({
2299
2835
  argv,
2300
2836
  root,
2301
2837
  accountId: controlPlanePlan.accountId,
@@ -2303,9 +2839,14 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2303
2839
  environment,
2304
2840
  hubSpotAppId: hubspotAppId,
2305
2841
  install: installRuntimeBinding,
2306
- controlPlaneUrl,
2307
- userId,
2308
- ...(note ? { note } : {}),
2842
+ ...(claim ? { canonicalSyncGrantSecretValue: claim.syncGrantSecretValue } : {}),
2843
+ }), async () => {
2844
+ const { stateStore } = await ensureTenantStateStore({ argv, root });
2845
+ return (await stateStore.getBinding({
2846
+ projectId: controlPlanePlan.projectId,
2847
+ environment,
2848
+ hubSpotAppId: hubspotAppId,
2849
+ }))?.syncGrantSecretValue;
2309
2850
  })
2310
2851
  : undefined;
2311
2852
  const hubSpotOAuthSecret = hubspotAppId && controlPlaneUrl
@@ -2377,6 +2918,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2377
2918
  ? { installKvNamespaceId: installRuntimeBinding.installKvNamespaceId }
2378
2919
  : {}),
2379
2920
  ...(controlPlaneUrl ? { checkpointDataset: 'hsx_checkpoint', observability: true } : {}),
2921
+ ...(controlPlaneUrl ? { versionMetadataBinding: 'CF_VERSION_METADATA' } : {}),
2380
2922
  ...(tenantDataPlane
2381
2923
  ? {
2382
2924
  flagsKvNamespaceId: tenantDataPlane.flagsKvNamespaceId,
@@ -2452,10 +2994,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2452
2994
  ...(dryRun ? ['--dry-run'] : []),
2453
2995
  ];
2454
2996
  const deployEnv = cloudflareDeployEnv(argv);
2455
- const output = await runCloudflareCommand(command, {
2997
+ const output = await withTransientDeployRetry(() => runCloudflareCommand(command, {
2456
2998
  cwd: root,
2457
2999
  env: deployEnv,
2458
- });
3000
+ }));
2459
3001
  if (installRuntimeBinding?.tokenKeySecretValue && !dryRun) {
2460
3002
  await runCloudflareCommand([
2461
3003
  'bun',
@@ -2472,6 +3014,13 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2472
3014
  stdin: installRuntimeBinding.tokenKeySecretValue,
2473
3015
  });
2474
3016
  }
3017
+ if (!controlPlaneUrl && heartbeatEnabled && machineSecret && !dryRun) {
3018
+ await runCloudflareCommand(['bun', 'x', 'wrangler', 'secret', 'put', 'HSX_MACHINE_SECRET', '--name', workerName], {
3019
+ cwd: root,
3020
+ env: deployEnv,
3021
+ stdin: machineSecret,
3022
+ });
3023
+ }
2475
3024
  if (resolvedHubSpotClientSecret && !dryRun) {
2476
3025
  await runCloudflareCommand([
2477
3026
  'bun',
@@ -2495,9 +3044,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2495
3044
  stdin: billingRuntimeToken.runtimeToken,
2496
3045
  });
2497
3046
  }
2498
- // ADR-015 grant-verifier secret only set when freshly generated (a re-deploy
2499
- // reuses the value already on the Worker + in local state).
2500
- if (tenantDataPlane?.syncGrantSecretValue && !dryRun) {
3047
+ // ADR-015 grant-verifier secret. Cloudflare Worker secrets are write-only and
3048
+ // per Worker, so install the canonical stored value on EVERY deployed Worker
3049
+ // (including one added after the trust domain was first provisioned).
3050
+ if (tenantDataPlane && !dryRun) {
2501
3051
  await runCloudflareCommand([
2502
3052
  'bun',
2503
3053
  'x',
@@ -2553,10 +3103,10 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2553
3103
  emitNote(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
2554
3104
  }
2555
3105
  }
2556
- // Without the client secret on the Worker, the runtime accepts UNSIGNED
2557
- // workflow-action invokes (HubSpot must still be able to call them pre-OAuth)
2558
- // surface that as a one-line warning instead of leaving it silent.
2559
- const hasWorkflowActions = worker.capabilities.some((capability) => capability.kind === 'tool');
3106
+ // Without the client secret, all HubSpot-authored request surfaces fail
3107
+ // closed. Surface the unavailable state instead of leaving bootstrap deploys
3108
+ // live but mysteriously non-functional.
3109
+ const hasHubSpotAuthoredIngress = worker.capabilities.some((capability) => capability.kind === 'tool' || capability.kind === 'trigger');
2560
3110
  return {
2561
3111
  workerName,
2562
3112
  workerSourcePath: relative(root, workerSourcePath),
@@ -2567,8 +3117,8 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2567
3117
  dryRun,
2568
3118
  stdout: output.stdout,
2569
3119
  stderr: output.stderr,
2570
- ...(hasWorkflowActions && !resolvedHubSpotClientSecret && !dryRun
2571
- ? { unauthenticatedInvoke: true }
3120
+ ...(hasHubSpotAuthoredIngress && !resolvedHubSpotClientSecret && !dryRun
3121
+ ? { signatureVerificationUnavailable: true }
2572
3122
  : {}),
2573
3123
  };
2574
3124
  }
@@ -2621,49 +3171,68 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
2621
3171
  installKvNamespaceName: existing.installKvNamespaceName,
2622
3172
  tokenKeySecretName: existing.tokenKeySecretName,
2623
3173
  tokenKeyCustody: existing.tokenKeyCustody,
3174
+ ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
3175
+ ...(existing.tenantD1DatabaseName
3176
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
3177
+ : {}),
3178
+ ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
3179
+ ...(existing.flagsKvNamespaceName
3180
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
3181
+ : {}),
2624
3182
  };
2625
3183
  }
2626
3184
  if (input.dryRun)
2627
3185
  return undefined;
2628
- // ADR-022: generate the token key and escrow it BEFORE creating any
2629
- // Cloudflare resource — if custody is unavailable, the deploy stops with
2630
- // zero mutations instead of minting a key that exists nowhere durable.
2631
3186
  const tokenKeySecretName = 'HSX_TOKEN_KEY';
2632
- const tokenKeySecretValue = randomBytes(32).toString('base64url');
2633
- const tokenKeyCustody = await escrowTokenKey({
2634
- adapter: defaultTokenKeyCustodyAdapter(),
2635
- custodyId: tokenKeyCustodyIdFor({
2636
- ownerId: pointer.ownerId,
3187
+ const installResource = await resolveOrProvisionCloudflareScopedResource({
3188
+ identity: {
3189
+ accountId: input.ownerId,
2637
3190
  projectId: input.projectId,
2638
3191
  environment: input.environment,
2639
3192
  hubSpotAppId: input.hubSpotAppId,
2640
- }),
2641
- tokenKeySecretValue,
2642
- });
2643
- const installKvNamespaceName = installKvNamespaceNameFor({
2644
- accountId: input.ownerId,
2645
- projectId: input.projectId,
2646
- environment: input.environment,
2647
- hubSpotAppId: input.hubSpotAppId,
2648
- });
2649
- const namespaceOutput = await runCloudflareCommand([
2650
- 'bun',
2651
- 'x',
2652
- 'wrangler',
2653
- 'kv',
2654
- 'namespace',
2655
- 'create',
2656
- installKvNamespaceName,
2657
- '--binding',
2658
- 'INSTALL_KV',
2659
- ], {
2660
- cwd: input.root,
2661
- env: cloudflareDeployEnv(input.argv),
3193
+ },
3194
+ kind: 'install-kv',
3195
+ async provisionByName(installKvNamespaceName) {
3196
+ // ADR-022: generate and escrow the token key before creating Cloudflare
3197
+ // state, but only after long ambiguous names have already failed closed.
3198
+ const tokenKeySecretValue = randomBytes(32).toString('base64url');
3199
+ const tokenKeyCustody = await escrowTokenKey({
3200
+ adapter: defaultTokenKeyCustodyAdapter(),
3201
+ custodyId: tokenKeyCustodyIdFor({
3202
+ ownerId: pointer.ownerId,
3203
+ projectId: input.projectId,
3204
+ environment: input.environment,
3205
+ hubSpotAppId: input.hubSpotAppId,
3206
+ }),
3207
+ tokenKeySecretValue,
3208
+ });
3209
+ const namespaceOutput = await runCloudflareCommand([
3210
+ 'bun',
3211
+ 'x',
3212
+ 'wrangler',
3213
+ 'kv',
3214
+ 'namespace',
3215
+ 'create',
3216
+ installKvNamespaceName,
3217
+ '--binding',
3218
+ 'INSTALL_KV',
3219
+ ], {
3220
+ cwd: input.root,
3221
+ env: cloudflareDeployEnv(input.argv),
3222
+ });
3223
+ const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
3224
+ if (!resourceId) {
3225
+ throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3226
+ }
3227
+ return { resourceId, value: { tokenKeySecretValue, tokenKeyCustody } };
3228
+ },
2662
3229
  });
2663
- const installKvNamespaceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2664
- if (!installKvNamespaceId) {
2665
- throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3230
+ if (!installResource.value) {
3231
+ throw new Error('Install KV provisioning did not return token-key custody.');
2666
3232
  }
3233
+ const installKvNamespaceId = installResource.resourceId;
3234
+ const installKvNamespaceName = installResource.resourceName;
3235
+ const { tokenKeySecretValue, tokenKeyCustody } = installResource.value;
2667
3236
  await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
2668
3237
  schemaVersion: 2,
2669
3238
  ownerId: pointer.ownerId,
@@ -2693,49 +3262,68 @@ async function ensureDeployInstallRuntimeBinding(input) {
2693
3262
  installKvNamespaceId: existing.installKvNamespaceId,
2694
3263
  installKvNamespaceName: existing.installKvNamespaceName,
2695
3264
  tokenKeySecretName: existing.tokenKeySecretName,
3265
+ ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
3266
+ ...(existing.tenantD1DatabaseName
3267
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
3268
+ : {}),
3269
+ ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
3270
+ ...(existing.flagsKvNamespaceName
3271
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
3272
+ : {}),
2696
3273
  };
2697
3274
  }
2698
3275
  if (input.dryRun) {
2699
3276
  return undefined;
2700
3277
  }
2701
- // ADR-022: the linked path generates the token key at the same seam, so it
2702
- // gets the same escrow — before any Cloudflare mutation. Only the custody
2703
- // REFERENCE lands in local state; the control plane never sees key bytes.
2704
- const tokenKeySecretValue = randomBytes(32).toString('base64url');
2705
- const tokenKeyCustody = await escrowTokenKey({
2706
- adapter: defaultTokenKeyCustodyAdapter(),
2707
- custodyId: tokenKeyCustodyIdFor({
2708
- ownerId: input.accountId,
3278
+ const installResource = await resolveOrProvisionCloudflareScopedResource({
3279
+ identity: {
3280
+ accountId: input.accountId,
2709
3281
  projectId: input.projectId,
2710
3282
  environment: input.environment,
2711
3283
  hubSpotAppId: input.hubSpotAppId,
2712
- }),
2713
- tokenKeySecretValue,
2714
- });
2715
- const installKvNamespaceName = installKvNamespaceNameFor({
2716
- accountId: input.accountId,
2717
- projectId: input.projectId,
2718
- environment: input.environment,
2719
- hubSpotAppId: input.hubSpotAppId,
2720
- });
2721
- const namespaceOutput = await runCloudflareCommand([
2722
- 'bun',
2723
- 'x',
2724
- 'wrangler',
2725
- 'kv',
2726
- 'namespace',
2727
- 'create',
2728
- installKvNamespaceName,
2729
- '--binding',
2730
- 'INSTALL_KV',
2731
- ], {
2732
- cwd: input.root,
2733
- env: cloudflareDeployEnv(input.argv),
3284
+ },
3285
+ kind: 'install-kv',
3286
+ async provisionByName(installKvNamespaceName) {
3287
+ // ADR-022: escrow precedes Cloudflare mutation, while the ambiguity
3288
+ // guard precedes both. The control plane never receives key bytes.
3289
+ const tokenKeySecretValue = randomBytes(32).toString('base64url');
3290
+ const tokenKeyCustody = await escrowTokenKey({
3291
+ adapter: defaultTokenKeyCustodyAdapter(),
3292
+ custodyId: tokenKeyCustodyIdFor({
3293
+ ownerId: input.accountId,
3294
+ projectId: input.projectId,
3295
+ environment: input.environment,
3296
+ hubSpotAppId: input.hubSpotAppId,
3297
+ }),
3298
+ tokenKeySecretValue,
3299
+ });
3300
+ const namespaceOutput = await runCloudflareCommand([
3301
+ 'bun',
3302
+ 'x',
3303
+ 'wrangler',
3304
+ 'kv',
3305
+ 'namespace',
3306
+ 'create',
3307
+ installKvNamespaceName,
3308
+ '--binding',
3309
+ 'INSTALL_KV',
3310
+ ], {
3311
+ cwd: input.root,
3312
+ env: cloudflareDeployEnv(input.argv),
3313
+ });
3314
+ const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
3315
+ if (!resourceId) {
3316
+ throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3317
+ }
3318
+ return { resourceId, value: { tokenKeySecretValue, tokenKeyCustody } };
3319
+ },
2734
3320
  });
2735
- const installKvNamespaceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
2736
- if (!installKvNamespaceId) {
2737
- throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3321
+ if (!installResource.value) {
3322
+ throw new Error('Install KV provisioning did not return token-key custody.');
2738
3323
  }
3324
+ const installKvNamespaceId = installResource.resourceId;
3325
+ const installKvNamespaceName = installResource.resourceName;
3326
+ const { tokenKeySecretValue, tokenKeyCustody } = installResource.value;
2739
3327
  const tokenKeySecretName = 'HSX_TOKEN_KEY';
2740
3328
  await putDeployInstallRuntimeBinding({
2741
3329
  ...input,
@@ -2820,31 +3408,11 @@ async function requestDeployBillingRuntimeToken(input) {
2820
3408
  binding: body.binding,
2821
3409
  };
2822
3410
  }
2823
- function installKvNamespaceNameFor(input) {
2824
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-install`);
2825
- }
2826
- function cloudflareNameSlug(value) {
2827
- return (value
2828
- .toLowerCase()
2829
- .replace(/[^a-z0-9]+/g, '-')
2830
- .replace(/^-+|-+$/g, '') || 'x');
2831
- }
2832
- function truncateCloudflareResourceName(value) {
2833
- return value.length <= 63 ? value : value.slice(0, 63).replace(/-+$/g, '');
2834
- }
2835
3411
  function extractKvNamespaceId(output) {
2836
3412
  return (/"id"\s*:\s*"([^"]+)"/.exec(output)?.[1] ??
2837
3413
  /id\s*=\s*"([^"]+)"/.exec(output)?.[1] ??
2838
3414
  /id[:\s]+([a-f0-9]{16,})/i.exec(output)?.[1]);
2839
3415
  }
2840
- // ADR-015/ADR-014 tenant data plane: one project-scoped D1 (flag config +
2841
- // installer PII + platform events) and one flags-snapshot KV per install.
2842
- function tenantD1DatabaseNameFor(input) {
2843
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-tenant`);
2844
- }
2845
- function flagsKvNamespaceNameFor(input) {
2846
- return truncateCloudflareResourceName(`hsx-${cloudflareNameSlug(input.accountId)}-${cloudflareNameSlug(input.projectId)}-${cloudflareNameSlug(input.environment)}-${cloudflareNameSlug(String(input.hubSpotAppId))}-flags`);
2847
- }
2848
3416
  function extractD1DatabaseId(output) {
2849
3417
  return (/"database_id"\s*:\s*"([^"]+)"/.exec(output)?.[1] ??
2850
3418
  /database_id\s*=\s*"([^"]+)"/.exec(output)?.[1] ??
@@ -2859,10 +3427,12 @@ function extractD1DatabaseId(output) {
2859
3427
  *
2860
3428
  * Idempotent: re-uses already-provisioned ids + an already-generated secret from
2861
3429
  * local state (so a re-deploy keeps the same secret the Worker already holds).
2862
- * Returns `syncGrantSecretValue` only when freshly generated the caller does
2863
- * a `wrangler secret put` only then. Tenant-data-plane activation is
2864
- * linked-only (codegen emits the options solely for heartbeat.mode==='linked'),
2865
- * so this is never called for unlinked direct-to-HubSpot deploys.
3430
+ * Returns the canonical `syncGrantSecretValue` on every callincluding when
3431
+ * it came from the durable binding — because a newly-added Worker does not yet
3432
+ * have the write-only Cloudflare secret even though sibling Workers do.
3433
+ * Tenant-data-plane activation is linked-only. Codegen receives the exact
3434
+ * tenant scope for every linked Worker independently of primary-only heartbeat
3435
+ * ownership, so this is never called for unlinked direct-to-HubSpot deploys.
2866
3436
  */
2867
3437
  async function ensureTenantDataPlane(input) {
2868
3438
  const { pointer, stateStore } = await ensureTenantStateStore({
@@ -2875,69 +3445,86 @@ async function ensureTenantDataPlane(input) {
2875
3445
  hubSpotAppId: input.hubSpotAppId,
2876
3446
  });
2877
3447
  const env = cloudflareDeployEnv(input.argv);
2878
- const tenantD1DatabaseName = existing?.tenantD1DatabaseName ?? tenantD1DatabaseNameFor(input);
2879
- let tenantD1DatabaseId = existing?.tenantD1DatabaseId;
2880
- if (!tenantD1DatabaseId) {
2881
- // Create, or ADOPT on a name conflict: resource names are deterministic,
2882
- // so a database that already exists is this project's own tenant D1 from
2883
- // an earlier deploy whose local state was lost — failing here would brick
2884
- // every redeploy while the data-holding database sits one lookup away.
2885
- const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
2886
- if (!/already exists/i.test(String(error)))
2887
- throw error;
2888
- return undefined;
2889
- });
2890
- if (created) {
2891
- tenantD1DatabaseId = extractD1DatabaseId(`${created.stdout}\n${created.stderr}`);
2892
- }
2893
- else {
2894
- const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
2895
- tenantD1DatabaseId =
2896
- extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
2897
- }
2898
- if (!tenantD1DatabaseId) {
2899
- throw new Error('Could not read tenant D1 database id from wrangler output.');
2900
- }
2901
- }
2902
- const flagsKvNamespaceName = existing?.flagsKvNamespaceName ?? flagsKvNamespaceNameFor(input);
2903
- let flagsKvNamespaceId = existing?.flagsKvNamespaceId;
2904
- if (!flagsKvNamespaceId) {
2905
- // Same create-or-adopt posture as the tenant D1 above.
2906
- const created = await runCloudflareCommand([
2907
- 'bun',
2908
- 'x',
2909
- 'wrangler',
2910
- 'kv',
2911
- 'namespace',
2912
- 'create',
2913
- flagsKvNamespaceName,
2914
- '--binding',
2915
- 'FLAGS_KV',
2916
- ], { cwd: input.root, env }).catch((error) => {
2917
- if (!/already exists/i.test(String(error)))
2918
- throw error;
2919
- return undefined;
2920
- });
2921
- if (created) {
2922
- flagsKvNamespaceId = extractKvNamespaceId(`${created.stdout}\n${created.stderr}`);
2923
- }
2924
- else {
2925
- const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
2926
- try {
2927
- const namespaces = JSON.parse(listed.stdout);
2928
- flagsKvNamespaceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
3448
+ // Exact local state wins; exact control-plane refs recover a linked deploy
3449
+ // when its tenant-state binding was lost. Name-only create/adopt remains
3450
+ // available for short legacy-compatible names only.
3451
+ const exactTenantD1DatabaseId = existing?.tenantD1DatabaseId ?? input.install.tenantD1DatabaseId;
3452
+ const exactTenantD1DatabaseName = existing?.tenantD1DatabaseName ?? input.install.tenantD1DatabaseName;
3453
+ const tenantD1 = await resolveOrProvisionCloudflareScopedResource({
3454
+ identity: input,
3455
+ kind: 'tenant-d1',
3456
+ ...(exactTenantD1DatabaseId ? { exactResourceId: exactTenantD1DatabaseId } : {}),
3457
+ ...(exactTenantD1DatabaseName ? { exactResourceName: exactTenantD1DatabaseName } : {}),
3458
+ async provisionByName(tenantD1DatabaseName) {
3459
+ const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
3460
+ if (!/already exists/i.test(String(error)))
3461
+ throw error;
3462
+ return undefined;
3463
+ });
3464
+ let resourceId = created
3465
+ ? extractD1DatabaseId(`${created.stdout}\n${created.stderr}`)
3466
+ : undefined;
3467
+ if (!created) {
3468
+ const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
3469
+ resourceId =
3470
+ extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
2929
3471
  }
2930
- catch {
2931
- flagsKvNamespaceId = undefined;
3472
+ if (!resourceId) {
3473
+ throw new Error('Could not read tenant D1 database id from wrangler output.');
2932
3474
  }
2933
- }
2934
- if (!flagsKvNamespaceId) {
2935
- throw new Error('Could not read flags KV namespace id from wrangler output.');
2936
- }
2937
- }
3475
+ return { resourceId };
3476
+ },
3477
+ });
3478
+ const tenantD1DatabaseId = tenantD1.resourceId;
3479
+ const tenantD1DatabaseName = tenantD1.resourceName;
3480
+ const exactFlagsKvNamespaceId = existing?.flagsKvNamespaceId ?? input.install.flagsKvNamespaceId;
3481
+ const exactFlagsKvNamespaceName = existing?.flagsKvNamespaceName ?? input.install.flagsKvNamespaceName;
3482
+ const flagsKv = await resolveOrProvisionCloudflareScopedResource({
3483
+ identity: input,
3484
+ kind: 'flags-kv',
3485
+ ...(exactFlagsKvNamespaceId ? { exactResourceId: exactFlagsKvNamespaceId } : {}),
3486
+ ...(exactFlagsKvNamespaceName ? { exactResourceName: exactFlagsKvNamespaceName } : {}),
3487
+ async provisionByName(flagsKvNamespaceName) {
3488
+ const created = await runCloudflareCommand([
3489
+ 'bun',
3490
+ 'x',
3491
+ 'wrangler',
3492
+ 'kv',
3493
+ 'namespace',
3494
+ 'create',
3495
+ flagsKvNamespaceName,
3496
+ '--binding',
3497
+ 'FLAGS_KV',
3498
+ ], { cwd: input.root, env }).catch((error) => {
3499
+ if (!/already exists/i.test(String(error)))
3500
+ throw error;
3501
+ return undefined;
3502
+ });
3503
+ let resourceId = created
3504
+ ? extractKvNamespaceId(`${created.stdout}\n${created.stderr}`)
3505
+ : undefined;
3506
+ if (!created) {
3507
+ const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
3508
+ try {
3509
+ const namespaces = JSON.parse(listed.stdout);
3510
+ resourceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
3511
+ }
3512
+ catch {
3513
+ resourceId = undefined;
3514
+ }
3515
+ }
3516
+ if (!resourceId) {
3517
+ throw new Error('Could not read flags KV namespace id from wrangler output.');
3518
+ }
3519
+ return { resourceId };
3520
+ },
3521
+ });
3522
+ const flagsKvNamespaceId = flagsKv.resourceId;
3523
+ const flagsKvNamespaceName = flagsKv.resourceName;
2938
3524
  const syncGrantSecretName = 'HSX_SYNC_GRANT_KEY';
2939
- const reusedSecret = existing?.syncGrantSecretValue;
2940
- const syncGrantSecretValue = reusedSecret ?? randomBytes(32).toString('base64url');
3525
+ const syncGrantSecretValue = input.canonicalSyncGrantSecretValue ??
3526
+ existing?.syncGrantSecretValue ??
3527
+ randomBytes(32).toString('base64url');
2941
3528
  await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
2942
3529
  schemaVersion: 2,
2943
3530
  ownerId: pointer.ownerId,
@@ -2965,58 +3552,15 @@ async function ensureTenantDataPlane(input) {
2965
3552
  syncGrantSecretValue,
2966
3553
  updatedAt: new Date().toISOString(),
2967
3554
  }));
2968
- // ADR-015 §5 "Both": register the secret control-plane-side for the (gated)
2969
- // dashboard/agent/sync consumers. Best-effort — the leaveable CLI-direct path
2970
- // reads the value from local state and needs no control plane.
2971
- if (input.controlPlaneUrl && input.userId) {
2972
- try {
2973
- await registerDeploySyncGrantSecret({
2974
- controlPlaneUrl: input.controlPlaneUrl,
2975
- userId: input.userId,
2976
- accountId: input.accountId,
2977
- projectId: input.projectId,
2978
- environment: input.environment,
2979
- hubSpotAppId: input.hubSpotAppId,
2980
- syncGrantSecret: syncGrantSecretValue,
2981
- });
2982
- }
2983
- catch (error) {
2984
- const warning = `warning: could not register the sync grant secret with the control plane: ${error instanceof Error ? error.message : String(error)}\n`;
2985
- if (input.note) {
2986
- input.note(warning);
2987
- }
2988
- else {
2989
- process.stderr.write(warning);
2990
- }
2991
- }
2992
- }
2993
3555
  return {
2994
3556
  tenantD1DatabaseId,
2995
3557
  tenantD1DatabaseName,
2996
3558
  flagsKvNamespaceId,
2997
3559
  flagsKvNamespaceName,
2998
3560
  syncGrantSecretName,
2999
- ...(reusedSecret ? {} : { syncGrantSecretValue }),
3561
+ syncGrantSecretValue,
3000
3562
  };
3001
3563
  }
3002
- async function registerDeploySyncGrantSecret(input) {
3003
- const response = await hostedHttp({
3004
- url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/sync-grant-secret`, input.controlPlaneUrl),
3005
- method: 'PUT',
3006
- headers: await controlPlaneAuthHeaders(input.userId),
3007
- body: {
3008
- environment: input.environment,
3009
- syncGrantSecret: input.syncGrantSecret,
3010
- },
3011
- });
3012
- const body = await response.json();
3013
- if (!response.ok) {
3014
- const message = isRecord(body) && typeof body.message === 'string'
3015
- ? body.message
3016
- : `Control plane returned ${response.status}.`;
3017
- throw new Error(`Could not register the sync grant secret: ${message}`);
3018
- }
3019
- }
3020
3564
  export function renderWranglerConfig(input) {
3021
3565
  const lines = [
3022
3566
  '# Generated by hs-x. Do not edit by hand.',
@@ -3032,12 +3576,19 @@ export function renderWranglerConfig(input) {
3032
3576
  // when --compatibility-date is set to an older date.
3033
3577
  'compatibility_flags = [ "nodejs_compat", "nodejs_compat_populate_process_env" ]',
3034
3578
  ];
3579
+ if (input.versionMetadataBinding) {
3580
+ lines.push('', '[version_metadata]', `binding = "${tomlString(input.versionMetadataBinding)}"`);
3581
+ }
3035
3582
  if (input.installKvNamespaceId) {
3036
3583
  lines.push('', '[[kv_namespaces]]', 'binding = "INSTALL_KV"', `id = "${tomlString(input.installKvNamespaceId)}"`);
3037
3584
  }
3038
3585
  // ADR-015 flag-snapshot KV (the eval-path hot replica).
3039
3586
  if (input.flagsKvNamespaceId) {
3040
3587
  lines.push('', '[[kv_namespaces]]', 'binding = "FLAGS_KV"', `id = "${tomlString(input.flagsKvNamespaceId)}"`);
3588
+ // Route-level abuse protection for the public flag evaluate + authoring
3589
+ // surfaces. The request key includes deployment identity + exact route, so
3590
+ // a namespace collision cannot merge counters between generated Workers.
3591
+ lines.push('', '[[ratelimits]]', 'name = "HSX_FLAGS_RATE_LIMITER"', `namespace_id = "${flagsRateLimitNamespaceId(input.workerName)}"`, '', ' [ratelimits.simple]', ' limit = 120', ' period = 60');
3041
3592
  }
3042
3593
  // ADR-015/ADR-014 project-scoped tenant D1 (flag config + installer PII +
3043
3594
  // platform events). migrations_dir lets wrangler auto-apply the tenant
@@ -3069,6 +3620,14 @@ export function renderWranglerConfig(input) {
3069
3620
  }
3070
3621
  return `${lines.join('\n')}\n`;
3071
3622
  }
3623
+ function flagsRateLimitNamespaceId(workerName) {
3624
+ let hash = 2_166_136_261;
3625
+ for (const character of `${workerName}:HSX_FLAGS_RATE_LIMITER`) {
3626
+ hash ^= character.codePointAt(0) ?? 0;
3627
+ hash = Math.imul(hash, 16_777_619) >>> 0;
3628
+ }
3629
+ return String(hash || 1);
3630
+ }
3072
3631
  function tomlString(value) {
3073
3632
  return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
3074
3633
  }
@@ -3659,44 +4218,56 @@ function runCloudflareCommand(command, options) {
3659
4218
  },
3660
4219
  })));
3661
4220
  }
3662
- async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
4221
+ async function executeHubSpotOnlyUpload({ argv, root, local, persistBinding = true, uploadOnly, prepared, }) {
3663
4222
  // The HubSpot project name must match the name baked into the generated
3664
4223
  // bundle (hsproject.json + app-hsmeta uid), not the on-disk directory name.
3665
4224
  // Using basename(root) breaks when the project lives in a generic dir (e.g.
3666
4225
  // `app/`): the upload targets a project named "app" while the bundle declares
3667
4226
  // the real project id, and HubSpot rejects the mismatch with an opaque 400.
3668
- const projectName = await resolveHubSpotProjectName(argv, root);
3669
- const intermediateRepresentation = await readHubSpotProjectIntermediateRepresentation(root);
3670
- const appUid = readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
4227
+ const projectName = prepared?.projectName ?? (await resolveHubSpotProjectName(argv, root));
4228
+ const intermediateRepresentation = prepared?.intermediateRepresentation ??
4229
+ (await readHubSpotProjectIntermediateRepresentation(root));
4230
+ const appUid = prepared?.appUid ??
4231
+ readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
3671
4232
  (await readAppUidFromProjectSource(root));
3672
4233
  // For the post-deploy app Auth URL (unlinked installs). Resolved from
3673
4234
  // flag/env here so the URL is correct on the unlinked path; on a
3674
4235
  // control-plane lease path it stays undefined and the URL is skipped.
3675
- const developerAccountId = resolveFlag(argv, '--developer-account-id') ?? process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID;
4236
+ const developerAccountId = prepared?.developerAccountId ??
4237
+ resolveFlag(argv, '--developer-account-id') ??
4238
+ process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID;
3676
4239
  const archivePath = await writeLocalHubSpotProjectArchive(root, projectName, {
3677
4240
  sourceOnly: intermediateRepresentation !== undefined,
4241
+ ...(prepared ? { expectedSourceFingerprint: prepared.sourceFingerprint } : {}),
3678
4242
  });
3679
- const client = await createDeployHubSpotDeveloperClient({
3680
- argv,
3681
- local,
3682
- allowedOperations: ['hubspot-project-upload'],
3683
- cwd: root,
3684
- });
4243
+ const client = prepared
4244
+ ? await prepared.createClient()
4245
+ : await createDeployHubSpotDeveloperClient({
4246
+ argv,
4247
+ local,
4248
+ allowedOperations: ['hubspot-project-upload'],
4249
+ cwd: root,
4250
+ });
3685
4251
  await client.projects.ensureProject({ projectName });
3686
4252
  const hsproject = await readHubSpotProjectConfig(root);
3687
4253
  const configuredPlatformVersion = typeof hsproject.platformVersion === 'string' ? hsproject.platformVersion : undefined;
3688
4254
  const upload = await client.projects.upload({
3689
4255
  projectName,
3690
4256
  archivePath,
3691
- message: resolveFlag(argv, '--message') ?? 'HS-X HubSpot-only deploy',
3692
- platformVersion: resolveFlag(argv, '--platform-version') ?? configuredPlatformVersion ?? '2026.03',
4257
+ message: prepared?.buildMessage ?? resolveFlag(argv, '--message') ?? 'HS-X HubSpot-only deploy',
4258
+ platformVersion: prepared?.platformVersion ??
4259
+ resolveFlag(argv, '--platform-version') ??
4260
+ configuredPlatformVersion ??
4261
+ '2026.03',
3693
4262
  intermediateRepresentation,
4263
+ skipAutoDeploy: uploadOnly,
3694
4264
  });
4265
+ const timeoutMs = prepared?.buildTimeoutMs ?? Number(resolveFlag(argv, '--hubspot-build-timeout-ms') ?? '60000');
3695
4266
  const buildStatus = await waitForHubSpotBuildStatus({
3696
4267
  client,
3697
4268
  projectName,
3698
4269
  buildId: upload.buildId,
3699
- timeoutMs: Number(resolveFlag(argv, '--hubspot-build-timeout-ms') ?? '60000'),
4270
+ timeoutMs,
3700
4271
  intervalMs: 1000,
3701
4272
  });
3702
4273
  const autoDeployEnabled = readBooleanProperty(buildStatus, 'isAutoDeployEnabled');
@@ -3704,7 +4275,7 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3704
4275
  const appId = readStatus(buildStatus) === 'SUCCESS'
3705
4276
  ? await optionalHubSpotAppId(client, projectName, appUid)
3706
4277
  : 0;
3707
- if (appId)
4278
+ if (persistBinding && appId)
3708
4279
  await persistHubSpotAppBinding(root, {
3709
4280
  appId,
3710
4281
  projectName,
@@ -3713,10 +4284,17 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3713
4284
  // Build SUCCESS does NOT mean the app shipped: HubSpot's auto-deploy can
3714
4285
  // still fail (found live: "Apps are not allowed to have 0 redirect URLs"
3715
4286
  // failed deploy #4 while the CLI reported green). Verify the deploy too.
3716
- // Auto-deploy fires on uploads too verify it regardless of uploadOnly
3717
- // (the f1-data 0-redirect failure rode exactly this path).
3718
- const autoDeployStatus = autoDeployEnabled && readStatus(buildStatus) === 'SUCCESS'
3719
- ? await waitForHubSpotAutoDeploy({ client, projectName, buildStatus })
4287
+ // Normal uploads can auto-deploy; uploadOnly sends skipAutoDeploy and is
4288
+ // therefore a strict build-only result.
4289
+ const autoDeployExpected = !uploadOnly && autoDeployEnabled === true && readStatus(buildStatus) === 'SUCCESS';
4290
+ const autoDeployStatus = autoDeployExpected
4291
+ ? await waitForHubSpotAutoDeploy({
4292
+ client,
4293
+ projectName,
4294
+ buildId: upload.buildId,
4295
+ buildStatus,
4296
+ timeoutMs,
4297
+ })
3720
4298
  : undefined;
3721
4299
  return {
3722
4300
  projectName,
@@ -3729,27 +4307,37 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3729
4307
  buildStatus,
3730
4308
  deployStatus: autoDeployStatus,
3731
4309
  deploySkipped: Boolean(uploadOnly || autoDeployEnabled),
4310
+ autoDeployExpected,
3732
4311
  local,
3733
4312
  };
3734
4313
  }
3735
- const deploy = await client.projects.deployBuild({
4314
+ const deploy = await withTransientDeployRetry(() => client.projects.deployBuild({
3736
4315
  projectName,
3737
4316
  buildId: upload.buildId,
3738
- force: argv.includes('--force'),
4317
+ force: prepared?.force ?? argv.includes('--force'),
4318
+ }));
4319
+ const deployResultType = readStringProperty(deploy, 'buildResultType');
4320
+ if (deployResultType === 'DEPLOY_BLOCKED') {
4321
+ throw new Error(describeBlockedHubSpotDeploy(deploy));
4322
+ }
4323
+ const deployId = readNumericProperty(deploy, 'deployId') ?? readNumericProperty(deploy, 'id');
4324
+ if (deployId === undefined) {
4325
+ throw new Error(`HubSpot returned ${deployResultType ?? 'an unrecognized response'} without a deploy id.`);
4326
+ }
4327
+ const deployStatus = await waitForHubSpotDeployStatus({
4328
+ client,
4329
+ projectName,
4330
+ deployId,
4331
+ timeoutMs,
3739
4332
  });
3740
- const deployId = readNumericProperty(deploy, 'deployId');
3741
- const deployStatus = deployId === undefined
3742
- ? deploy
3743
- : await client.projects.getDeployStatus({
4333
+ const appId = await optionalHubSpotAppId(client, projectName, appUid);
4334
+ if (persistBinding) {
4335
+ await persistHubSpotAppBinding(root, {
4336
+ appId,
3744
4337
  projectName,
3745
- deployId,
4338
+ developerAccountId,
3746
4339
  });
3747
- const appId = await optionalHubSpotAppId(client, projectName, appUid);
3748
- await persistHubSpotAppBinding(root, {
3749
- appId,
3750
- projectName,
3751
- developerAccountId,
3752
- });
4340
+ }
3753
4341
  return {
3754
4342
  projectName,
3755
4343
  archivePath,
@@ -3761,6 +4349,7 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
3761
4349
  buildStatus,
3762
4350
  deployStatus,
3763
4351
  deploySkipped: false,
4352
+ autoDeployExpected: false,
3764
4353
  local,
3765
4354
  };
3766
4355
  }
@@ -3778,20 +4367,26 @@ async function optionalHubSpotAppId(client, projectName, appUid) {
3778
4367
  * (deployStatusTaskLocator.id); absent/transient lookups resolve undefined —
3779
4368
  * the caller treats that as "could not verify", never as success.
3780
4369
  */
3781
- async function waitForHubSpotAutoDeploy({ client, projectName, buildStatus, timeoutMs = 60_000, intervalMs = 2_000, }) {
3782
- const locator = buildStatus && typeof buildStatus === 'object'
3783
- ? buildStatus.deployStatusTaskLocator
3784
- : undefined;
3785
- const deployId = locator && typeof locator.id === 'number'
3786
- ? locator.id
3787
- : locator && typeof locator.id === 'string'
3788
- ? Number.parseInt(locator.id, 10)
3789
- : undefined;
3790
- if (deployId === undefined || Number.isNaN(deployId))
3791
- return undefined;
4370
+ async function waitForHubSpotAutoDeploy({ client, projectName, buildId, buildStatus, timeoutMs = 60_000, intervalMs = 2_000, }) {
3792
4371
  const deadline = Date.now() + timeoutMs;
4372
+ let latestBuildStatus = buildStatus;
4373
+ let deployId = readHubSpotDeployLocator(latestBuildStatus);
3793
4374
  let last;
3794
4375
  while (Date.now() < deadline) {
4376
+ if (deployId === undefined) {
4377
+ try {
4378
+ latestBuildStatus = await client.projects.getBuildStatus({ projectName, buildId });
4379
+ deployId = readHubSpotDeployLocator(latestBuildStatus);
4380
+ }
4381
+ catch {
4382
+ // The build may be visible before its auto-deploy locator. Keep trying
4383
+ // until the same bounded timeout instead of declaring success.
4384
+ }
4385
+ if (deployId === undefined) {
4386
+ await sleep(intervalMs);
4387
+ continue;
4388
+ }
4389
+ }
3795
4390
  try {
3796
4391
  last = await client.projects.getDeployStatus({ projectName, deployId });
3797
4392
  }
@@ -3799,13 +4394,47 @@ async function waitForHubSpotAutoDeploy({ client, projectName, buildStatus, time
3799
4394
  last = undefined;
3800
4395
  }
3801
4396
  const status = readStatus(last);
3802
- if (status && status !== 'PENDING' && status !== 'BUILDING' && status !== 'DEPLOYING') {
4397
+ if (status && !isHubSpotDeployInProgress(status)) {
3803
4398
  return last;
3804
4399
  }
3805
4400
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
3806
4401
  }
3807
4402
  return last;
3808
4403
  }
4404
+ async function waitForHubSpotDeployStatus({ client, projectName, deployId, timeoutMs, intervalMs = 1_000, }) {
4405
+ const deadline = Date.now() + timeoutMs;
4406
+ let latest;
4407
+ do {
4408
+ latest = await client.projects.getDeployStatus({ projectName, deployId });
4409
+ const status = readStatus(latest);
4410
+ if (status && !isHubSpotDeployInProgress(status))
4411
+ return latest;
4412
+ await sleep(intervalMs);
4413
+ } while (Date.now() < deadline);
4414
+ return latest;
4415
+ }
4416
+ function isHubSpotDeployInProgress(status) {
4417
+ return new Set([
4418
+ 'PENDING',
4419
+ 'QUEUED',
4420
+ 'BUILDING',
4421
+ 'DEPLOYING',
4422
+ 'PROCESSING',
4423
+ 'STARTED',
4424
+ 'IN_PROGRESS',
4425
+ ]).has(status.toUpperCase());
4426
+ }
4427
+ function readHubSpotDeployLocator(value) {
4428
+ if (!isRecord(value) || !isRecord(value.deployStatusTaskLocator))
4429
+ return undefined;
4430
+ const id = value.deployStatusTaskLocator.id;
4431
+ if (typeof id === 'number')
4432
+ return id;
4433
+ if (typeof id !== 'string')
4434
+ return undefined;
4435
+ const parsed = Number.parseInt(id, 10);
4436
+ return Number.isNaN(parsed) ? undefined : parsed;
4437
+ }
3809
4438
  async function waitForHubSpotBuildStatus({ client, projectName, buildId, timeoutMs, intervalMs, }) {
3810
4439
  const startedAt = Date.now();
3811
4440
  let latest;
@@ -3822,38 +4451,6 @@ async function waitForHubSpotBuildStatus({ client, projectName, buildId, timeout
3822
4451
  function sleep(ms) {
3823
4452
  return new Promise((resolve) => setTimeout(resolve, ms));
3824
4453
  }
3825
- async function writeLocalHubSpotProjectArchive(root, projectName, options = { sourceOnly: false }) {
3826
- const outDir = await mkdtemp(join(tmpdir(), 'hs-x-hubspot-project-'));
3827
- const archivePath = join(outDir, `${projectName.replace(/[^a-zA-Z0-9._-]+/g, '-')}-hubspot-project.zip`);
3828
- const stageDir = join(outDir, 'project');
3829
- await cp(root, stageDir, {
3830
- recursive: true,
3831
- filter(source) {
3832
- return (!source.includes(`${join(root, 'node_modules')}/`) &&
3833
- !source.includes(`${join(root, '.hs-x')}/`));
3834
- },
3835
- });
3836
- await patchHubSpotProjectName(stageDir, projectName);
3837
- await runZip(options.sourceOnly ? await hubSpotProjectSourceRoot(stageDir) : stageDir, archivePath);
3838
- return archivePath;
3839
- }
3840
- async function hubSpotProjectSourceRoot(root) {
3841
- const hsproject = await readHubSpotProjectConfig(root);
3842
- const srcDir = typeof hsproject.srcDir === 'string' ? hsproject.srcDir : 'src';
3843
- return join(root, srcDir);
3844
- }
3845
- async function patchHubSpotProjectName(root, projectName) {
3846
- const path = join(root, 'hsproject.json');
3847
- const raw = await readFile(path, 'utf8').catch(() => undefined);
3848
- if (!raw) {
3849
- return;
3850
- }
3851
- const parsed = JSON.parse(raw);
3852
- if (!isRecord(parsed)) {
3853
- return;
3854
- }
3855
- await writeFile(path, `${JSON.stringify({ ...parsed, name: projectName }, null, 2)}\n`);
3856
- }
3857
4454
  async function readHubSpotProjectIntermediateRepresentation(root) {
3858
4455
  const hsproject = await readHubSpotProjectConfig(root);
3859
4456
  const srcDir = typeof hsproject.srcDir === 'string' ? hsproject.srcDir : 'src';
@@ -3964,30 +4561,40 @@ async function collectHubSpotMetaFiles(root) {
3964
4561
  await walk(root);
3965
4562
  return files.sort();
3966
4563
  }
3967
- function runZip(root, archivePath) {
3968
- return new Promise((resolve, reject) => {
3969
- const child = spawn('zip', ['-qr', archivePath, '.', '-x', 'node_modules/*', '*/node_modules/*', '.hs-x/*'], {
3970
- cwd: root,
3971
- stdio: ['ignore', 'ignore', 'pipe'],
3972
- });
3973
- let stderr = '';
3974
- child.stderr?.setEncoding('utf8');
3975
- child.stderr?.on('data', (chunk) => {
3976
- stderr += chunk;
3977
- });
3978
- child.on('error', reject);
3979
- child.on('close', (code) => {
3980
- if (code === 0) {
3981
- resolve();
3982
- }
3983
- else {
3984
- reject(new Error(`Unable to create HubSpot project zip archive: ${stderr.trim()}`));
3985
- }
4564
+ function readNumericProperty(value, key) {
4565
+ if (!isRecord(value))
4566
+ return undefined;
4567
+ const property = value[key];
4568
+ if (typeof property === 'number')
4569
+ return property;
4570
+ if (typeof property !== 'string')
4571
+ return undefined;
4572
+ const parsed = Number.parseInt(property, 10);
4573
+ return Number.isNaN(parsed) ? undefined : parsed;
4574
+ }
4575
+ function readStringProperty(value, key) {
4576
+ return isRecord(value) && typeof value[key] === 'string' ? value[key] : undefined;
4577
+ }
4578
+ function describeBlockedHubSpotDeploy(value) {
4579
+ if (!isRecord(value))
4580
+ return 'HubSpot blocked the deploy.';
4581
+ const candidates = [value.issues, value.blockingIssues, value.errors, value.message];
4582
+ const details = candidates.flatMap((candidate) => {
4583
+ if (typeof candidate === 'string')
4584
+ return [candidate];
4585
+ if (!Array.isArray(candidate))
4586
+ return [];
4587
+ return candidate.map((item) => {
4588
+ if (typeof item === 'string')
4589
+ return item;
4590
+ if (!isRecord(item))
4591
+ return String(item);
4592
+ return String(item.message ?? item.reason ?? item.description ?? JSON.stringify(item));
3986
4593
  });
3987
4594
  });
3988
- }
3989
- function readNumericProperty(value, key) {
3990
- return isRecord(value) && typeof value[key] === 'number' ? value[key] : undefined;
4595
+ return details.length > 0
4596
+ ? `HubSpot blocked the deploy: ${details.join('; ')}`
4597
+ : 'HubSpot blocked the deploy. Re-run with --force only after reviewing its removal warnings.';
3991
4598
  }
3992
4599
  function readBooleanProperty(value, key) {
3993
4600
  return isRecord(value) && typeof value[key] === 'boolean' ? value[key] : undefined;
@@ -4162,8 +4769,28 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
4162
4769
  return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
4163
4770
  }
4164
4771
  async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
4772
+ const localAttestationAuth = revision
4773
+ ? { grantSecret: randomBytes(32).toString('base64url'), hubSpotAppId: 1 }
4774
+ : undefined;
4165
4775
  const controlPlane = (await loadCreateControlPlane())({
4166
4776
  now: () => new Date('2026-05-18T14:00:00.000Z'),
4777
+ ...(localAttestationAuth
4778
+ ? {
4779
+ secretsKv: {
4780
+ async get() {
4781
+ return JSON.stringify({
4782
+ accountId: request.accountId,
4783
+ projectId: request.projectId,
4784
+ environment: 'dev',
4785
+ hubSpotAppId: localAttestationAuth.hubSpotAppId,
4786
+ syncGrantSecret: localAttestationAuth.grantSecret,
4787
+ updatedAt: '2026-05-18T14:00:00.000Z',
4788
+ });
4789
+ },
4790
+ async put() { },
4791
+ },
4792
+ }
4793
+ : {}),
4167
4794
  });
4168
4795
  const headers = {
4169
4796
  'content-type': 'application/json',
@@ -4200,20 +4827,37 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
4200
4827
  if (!promoteWhenHealthy) {
4201
4828
  return { record };
4202
4829
  }
4830
+ if (!localAttestationAuth || !revision) {
4831
+ throw new Error('Local promotion requires a scoped runtime grant secret, HubSpot app id, and captured Cloudflare revision.');
4832
+ }
4833
+ const attestationBody = JSON.stringify({
4834
+ manifestHash: plan.manifestHash,
4835
+ bindingFingerprint: `bindings:${plan.deployId}`,
4836
+ sdkVersion: CLI_VERSION,
4837
+ taggedResourceCount: Math.max(1, plan.resources.workerNames.length),
4838
+ hsXAccountId: request.accountId,
4839
+ projectId: request.projectId,
4840
+ environment: 'dev',
4841
+ hubSpotAppId: localAttestationAuth.hubSpotAppId,
4842
+ deployId: plan.deployId,
4843
+ revisionId: revision.versionId,
4844
+ timestamp: '2026-05-18T14:00:00.000Z',
4845
+ });
4846
+ const attestationGrant = await signDeploymentAttestationGrant({
4847
+ secret: localAttestationAuth.grantSecret,
4848
+ body: attestationBody,
4849
+ accountId: request.accountId,
4850
+ projectId: request.projectId,
4851
+ environment: 'dev',
4852
+ hubSpotAppId: localAttestationAuth.hubSpotAppId,
4853
+ deployId: plan.deployId,
4854
+ revisionId: revision.versionId,
4855
+ now: new Date('2026-05-18T14:00:00.000Z'),
4856
+ });
4203
4857
  const driftResponse = await controlPlane.fetch(new Request(`https://api.hs-x.dev/v1/projects/${request.projectId}/attestation`, {
4204
4858
  method: 'POST',
4205
- headers,
4206
- body: JSON.stringify({
4207
- manifestHash: plan.manifestHash,
4208
- bindingFingerprint: `bindings:${plan.deployId}`,
4209
- sdkVersion: CLI_VERSION,
4210
- taggedResourceCount: Math.max(1, plan.resources.workerNames.length),
4211
- hsXAccountId: request.accountId,
4212
- projectId: request.projectId,
4213
- environment: 'dev',
4214
- deployId: plan.deployId,
4215
- timestamp: '2026-05-18T14:00:00.000Z',
4216
- }),
4859
+ headers: { ...headers, authorization: `Bearer ${attestationGrant}` },
4860
+ body: attestationBody,
4217
4861
  }));
4218
4862
  const driftBody = await driftResponse.json();
4219
4863
  if (!driftResponse.ok) {
@@ -4693,8 +5337,12 @@ const deployOptions = {
4693
5337
  hubspotClientId: Options.text('hubspot-client-id').pipe(Options.optional), // --hubspot-client-id
4694
5338
  hubspotClientSecret: Options.text('hubspot-client-secret').pipe(Options.optional), // --hubspot-client-secret
4695
5339
  developerAccountId: Options.text('developer-account-id').pipe(Options.optional), // --developer-account-id
5340
+ developerApiKey: Options.text('developer-api-key').pipe(Options.optional), // --developer-api-key
5341
+ pak: Options.text('pak').pipe(Options.optional), // --pak
5342
+ account: Options.text('account').pipe(Options.withAlias('a'), Options.optional), // --account / -a
5343
+ config: Options.text('config').pipe(Options.withAlias('c'), Options.optional), // --config / -c
4696
5344
  hubspotBuildTimeoutMs: Options.text('hubspot-build-timeout-ms').pipe(Options.optional), // --hubspot-build-timeout-ms
4697
- message: Options.text('message').pipe(Options.optional), // --message
5345
+ message: Options.text('message').pipe(Options.withAlias('m'), Options.optional), // --message / -m
4698
5346
  platformVersion: Options.text('platform-version').pipe(Options.optional), // --platform-version
4699
5347
  hubspotProjectName: Options.text('hubspot-project-name').pipe(Options.optional), // --hubspot-project-name
4700
5348
  // ── boolean flags (argv.includes) ──
@@ -4715,13 +5363,14 @@ const deployOptions = {
4715
5363
  applySchema: Options.boolean('apply-schema').pipe(Options.withDefault(false)), // --apply-schema
4716
5364
  heartbeat: Options.boolean('heartbeat').pipe(Options.withDefault(false)), // --heartbeat
4717
5365
  noHeartbeat: Options.boolean('no-heartbeat').pipe(Options.withDefault(false)), // --no-heartbeat
5366
+ useEnv: Options.boolean('use-env').pipe(Options.withDefault(false)), // --use-env
4718
5367
  // Declared explicitly: without it @effect/cli treats `--no-record` as the
4719
5368
  // negation of an undeclared `record` boolean and fails routing with a
4720
5369
  // confusing root-level "Invalid subcommand" error (the flag is documented).
4721
5370
  noRecord: Options.boolean('no-record').pipe(Options.withDefault(false)), // --no-record
4722
5371
  portalSchemaLive: Options.boolean('portal-schema-live').pipe(Options.withDefault(false)), // --portal-schema-live
4723
5372
  localHubspotSchema: Options.boolean('local-hubspot-schema').pipe(Options.withDefault(false)), // --local-hubspot-schema
4724
- force: Options.boolean('force').pipe(Options.withDefault(false)), // --force
5373
+ force: Options.boolean('force').pipe(Options.withAlias('f'), Options.withDefault(false)), // --force / -f
4725
5374
  yes: Options.boolean('yes').pipe(Options.withAlias('y'), Options.withDefault(false)), // --yes / -y
4726
5375
  };
4727
5376
  const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler('deploy', { json: opts.json }, Effect.gen(function* () {
@@ -4753,6 +5402,10 @@ const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler
4753
5402
  push('--hubspot-client-id', opts.hubspotClientId);
4754
5403
  push('--hubspot-client-secret', opts.hubspotClientSecret);
4755
5404
  push('--developer-account-id', opts.developerAccountId);
5405
+ push('--developer-api-key', opts.developerApiKey);
5406
+ push('--pak', opts.pak);
5407
+ push('--account', opts.account);
5408
+ push('--config', opts.config);
4756
5409
  push('--hubspot-build-timeout-ms', opts.hubspotBuildTimeoutMs);
4757
5410
  push('--message', opts.message);
4758
5411
  push('--platform-version', opts.platformVersion);
@@ -4774,6 +5427,7 @@ const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler
4774
5427
  flag('--apply-schema', opts.applySchema);
4775
5428
  flag('--heartbeat', opts.heartbeat);
4776
5429
  flag('--no-heartbeat', opts.noHeartbeat);
5430
+ flag('--use-env', opts.useEnv);
4777
5431
  flag('--no-record', opts.noRecord);
4778
5432
  flag('--portal-schema-live', opts.portalSchemaLive);
4779
5433
  flag('--local-hubspot-schema', opts.localHubspotSchema);