@hs-x/cli 0.4.5 → 0.4.7

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 (89) hide show
  1. package/README.md +6 -5
  2. package/dist/account-store.d.ts +9 -0
  3. package/dist/account-store.d.ts.map +1 -1
  4. package/dist/account-store.js +11 -0
  5. package/dist/account-store.js.map +1 -1
  6. package/dist/cloudflare-account-preflight.d.ts +26 -0
  7. package/dist/cloudflare-account-preflight.d.ts.map +1 -0
  8. package/dist/cloudflare-account-preflight.js +64 -0
  9. package/dist/cloudflare-account-preflight.js.map +1 -0
  10. package/dist/cloudflare-kv.d.ts +9 -0
  11. package/dist/cloudflare-kv.d.ts.map +1 -1
  12. package/dist/cloudflare-kv.js +47 -13
  13. package/dist/cloudflare-kv.js.map +1 -1
  14. package/dist/cloudflare-oauth.js +2 -2
  15. package/dist/cloudflare-oauth.js.map +1 -1
  16. package/dist/cloudflare-pointer.d.ts.map +1 -1
  17. package/dist/cloudflare-pointer.js +9 -3
  18. package/dist/cloudflare-pointer.js.map +1 -1
  19. package/dist/cloudflare-scoped-resource.d.ts +23 -2
  20. package/dist/cloudflare-scoped-resource.d.ts.map +1 -1
  21. package/dist/cloudflare-scoped-resource.js +56 -9
  22. package/dist/cloudflare-scoped-resource.js.map +1 -1
  23. package/dist/commands/api.d.ts.map +1 -1
  24. package/dist/commands/api.js +1 -1
  25. package/dist/commands/api.js.map +1 -1
  26. package/dist/commands/connect.js +3 -3
  27. package/dist/commands/connect.js.map +1 -1
  28. package/dist/commands/control-plane-read.d.ts +2 -2
  29. package/dist/commands/control-plane-read.d.ts.map +1 -1
  30. package/dist/commands/control-plane-read.js +35 -22
  31. package/dist/commands/control-plane-read.js.map +1 -1
  32. package/dist/commands/deploy-promote.js +3 -3
  33. package/dist/commands/deploy-promote.js.map +1 -1
  34. package/dist/commands/deploy.d.ts +17 -0
  35. package/dist/commands/deploy.d.ts.map +1 -1
  36. package/dist/commands/deploy.js +485 -47
  37. package/dist/commands/deploy.js.map +1 -1
  38. package/dist/commands/dev.js +7 -9
  39. package/dist/commands/dev.js.map +1 -1
  40. package/dist/commands/link.d.ts.map +1 -1
  41. package/dist/commands/link.js +41 -7
  42. package/dist/commands/link.js.map +1 -1
  43. package/dist/commands/rollback.d.ts +23 -0
  44. package/dist/commands/rollback.d.ts.map +1 -1
  45. package/dist/commands/rollback.js +60 -13
  46. package/dist/commands/rollback.js.map +1 -1
  47. package/dist/commands/status.d.ts.map +1 -1
  48. package/dist/commands/status.js +4 -4
  49. package/dist/commands/status.js.map +1 -1
  50. package/dist/commands/sync.d.ts.map +1 -1
  51. package/dist/commands/sync.js +24 -9
  52. package/dist/commands/sync.js.map +1 -1
  53. package/dist/constants.d.ts +1 -1
  54. package/dist/constants.js +1 -1
  55. package/dist/control-plane-fetch.d.ts +3 -1
  56. package/dist/control-plane-fetch.d.ts.map +1 -1
  57. package/dist/control-plane-fetch.js +9 -7
  58. package/dist/control-plane-fetch.js.map +1 -1
  59. package/dist/control-plane-tenant-provisioning.d.ts +2 -0
  60. package/dist/control-plane-tenant-provisioning.d.ts.map +1 -1
  61. package/dist/control-plane-tenant-provisioning.js +15 -2
  62. package/dist/control-plane-tenant-provisioning.js.map +1 -1
  63. package/dist/deploy-targets.d.ts +0 -1
  64. package/dist/deploy-targets.d.ts.map +1 -1
  65. package/dist/deploy-targets.js +2 -5
  66. package/dist/deploy-targets.js.map +1 -1
  67. package/dist/dev/invoke.d.ts +7 -0
  68. package/dist/dev/invoke.d.ts.map +1 -1
  69. package/dist/dev/invoke.js +18 -5
  70. package/dist/dev/invoke.js.map +1 -1
  71. package/dist/errors-registry.d.ts.map +1 -1
  72. package/dist/errors-registry.js +44 -8
  73. package/dist/errors-registry.js.map +1 -1
  74. package/dist/hubspot-developer-client.js +2 -2
  75. package/dist/hubspot-developer-client.js.map +1 -1
  76. package/dist/services/cloudflare-kv.d.ts.map +1 -1
  77. package/dist/services/cloudflare-kv.js +22 -4
  78. package/dist/services/cloudflare-kv.js.map +1 -1
  79. package/dist/services/control-plane.d.ts +3 -1
  80. package/dist/services/control-plane.d.ts.map +1 -1
  81. package/dist/services/control-plane.js +8 -6
  82. package/dist/services/control-plane.js.map +1 -1
  83. package/dist/services/hubspot-dev-client.js +1 -1
  84. package/dist/services/hubspot-dev-client.js.map +1 -1
  85. package/dist/tenant-state.d.ts +28 -0
  86. package/dist/tenant-state.d.ts.map +1 -1
  87. package/dist/tenant-state.js +109 -42
  88. package/dist/tenant-state.js.map +1 -1
  89. package/package.json +8 -8
@@ -15,9 +15,10 @@ import { Effect, Option } from 'effect';
15
15
  import { isLinked } from '../account-store.js';
16
16
  import { exitWith } from '../cli-error.js';
17
17
  import { accountIdOption, controlPlaneUrlOption, cwdOption, deployIdOption, forceOption, jsonOption, projectIdOption, runHandler, runQuarantined, userIdOption, yesOption, } from '../cli/kit.js';
18
- import { resolveCloudflareCredentials } from '../cloudflare-kv.js';
18
+ import { classifyCloudflareCommandFailure, preflightCloudflareAnalyticsEngine, validateCloudflareResourceForAccount, } from '../cloudflare-account-preflight.js';
19
+ import { applyLeasedCloudflareCredentials, resolveCloudflareCredentials, } from '../cloudflare-kv.js';
19
20
  import { loadCloudflarePointer } from '../cloudflare-pointer.js';
20
- import { resolveOrProvisionCloudflareScopedResource } from '../cloudflare-scoped-resource.js';
21
+ import { parseWranglerJsonArray, resolveOrProvisionCloudflareScopedResource, } from '../cloudflare-scoped-resource.js';
21
22
  import { DEFAULT_CONTROL_PLANE_URL, formatConfigUrl, loadCliConfig, resolveControlPlaneUrl, } from '../config.js';
22
23
  import { CLI_VERSION } from '../constants.js';
23
24
  import { controlPlaneAuthHeaders } from '../control-plane-fetch.js';
@@ -30,6 +31,7 @@ import { DirectHubSpotAccountResolutionError, createDeployHubSpotDeveloperClient
30
31
  import { HubSpotProjectArchiveSourceError, fingerprintHubSpotArchiveSource, writeLocalHubSpotProjectArchive, } from '../hubspot-project-archive.js';
31
32
  import { deployHubSpotProjectBuild, readHubSpotSubbuildStatuses, waitForHubSpotAutoDeploy, waitForHubSpotBuildStatus, } from '../hubspot-project-lifecycle.js';
32
33
  import { detectLocalProjectMode, readHubSpotProjectDescriptor, validateHubSpotProject, } from '../hubspot-project.js';
34
+ import { linkMigrationJournalId, loadLinkMigrationJournal } from '../link-migration-journal.js';
33
35
  import { hydrateAncestorEnv } from '../load-env.js';
34
36
  import { LocalProjectPickerCancelled, resolveLocalProject } from '../local-project-picker.js';
35
37
  import { getMachineId, getMachineSecret, previewMachineId } from '../machine-id.js';
@@ -41,7 +43,7 @@ import { createReporter } from '../reporter/index.js';
41
43
  import { Cwd } from '../services/cwd.js';
42
44
  import { createTenantDataPlaneProvisioningCoordinator, } from '../tenant-data-plane-provisioning.js';
43
45
  import { ensureTenantStateStore, preflightTenantStateCredentials, } from '../tenant-state.js';
44
- import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
46
+ import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, sealTokenKeyCanary, tokenKeyCustodyAdapterFor, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
45
47
  import { deployPromoteCommand, requestControlPlaneDeployPromotion } from './deploy-promote.js';
46
48
  import { withTransientDeployRetry } from './deploy-retry.js';
47
49
  async function hostedHttp(input) {
@@ -475,7 +477,31 @@ async function hydrateCloudflareTokenFromStoredOAuth(input) {
475
477
  return false;
476
478
  }
477
479
  }
478
- async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, deployId, userId, timeoutMs, workerUrl, }) {
480
+ /**
481
+ * Prefer the account-scoped Cloudflare connection held by the control plane.
482
+ * This keeps an explicit `--account-id` deploy from being redirected by a
483
+ * stale local `.hs-x/cloudflare.json` pointer or an unrelated stored OAuth
484
+ * connection on a machine that works across several HS-X accounts.
485
+ */
486
+ async function hydrateCloudflareTokenFromControlPlane(input) {
487
+ // A command-line credential is an intentional one-run override. Ambient
488
+ // env credentials are not: on a multi-account machine they commonly point
489
+ // at a different Cloudflare account, so a linked deploy must replace them
490
+ // with the selected HS-X account's leased credential pair.
491
+ if (resolveFlag(input.argv, '--cloudflare-api-token') || resolveFlag(input.argv, '--api-token')) {
492
+ return false;
493
+ }
494
+ const { leaseCloudflareViaSession } = await import('../tenant-state.js');
495
+ const lease = await leaseCloudflareViaSession(input.argv, input.root);
496
+ if (!lease)
497
+ return false;
498
+ applyLeasedCloudflareCredentials({
499
+ apiToken: lease.apiToken,
500
+ accountId: lease.cloudflareAccountId,
501
+ });
502
+ return true;
503
+ }
504
+ async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, accountId, projectId, deployId, userId, timeoutMs, workerUrl, }) {
479
505
  const startedAt = Date.now();
480
506
  let lastState = 'unknown';
481
507
  let lastReason;
@@ -485,7 +511,7 @@ async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, dep
485
511
  }
486
512
  const response = await hostedHttp({
487
513
  url: new URL(`/v1/projects/${encodeURIComponent(projectId)}/drift`, controlPlaneUrl),
488
- headers: await controlPlaneAuthHeaders(userId),
514
+ headers: await controlPlaneAuthHeaders(userId, undefined, accountId),
489
515
  });
490
516
  const body = await response.json().catch(() => undefined);
491
517
  if (response.ok) {
@@ -953,7 +979,7 @@ async function executeHubSpotDirectDeployCommand({ argv, root, json, }) {
953
979
  ? (readStatus(uploaded.deployStatus) ?? 'UNKNOWN')
954
980
  : undefined;
955
981
  const deployUnverified = uploaded.autoDeployExpected && deployState === undefined;
956
- const ok = buildState === 'SUCCESS' && !deployUnverified && (!deployState || deployState === 'SUCCESS');
982
+ const ok = hubSpotUploadSucceeded(uploaded);
957
983
  const result = {
958
984
  schema_version: 1,
959
985
  ok,
@@ -1078,8 +1104,14 @@ export async function deployCommand({ argv, root, json, }) {
1078
1104
  const noHeartbeat = argv.includes('--no-heartbeat');
1079
1105
  const noRecord = argv.includes('--no-record');
1080
1106
  const explicitControlPlaneUrl = resolveFlag(argv, '--control-plane-url');
1081
- const controlPlaneUrl = explicitControlPlaneUrl ?? (planOnly ? undefined : process.env.HSX_CONTROL_PLANE_URL);
1082
1107
  const linked = await isLinked();
1108
+ const controlPlaneUrl = resolveDeployControlPlaneUrl({
1109
+ explicitControlPlaneUrl,
1110
+ environmentControlPlaneUrl: process.env.HSX_CONTROL_PLANE_URL,
1111
+ platformControlPlaneUrl: resolveControlPlaneUrl(argv),
1112
+ linked,
1113
+ planOnly,
1114
+ });
1083
1115
  const userId = resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user';
1084
1116
  const resolvedAcct = await resolveAccountIdOrPrompt({
1085
1117
  argv,
@@ -1133,7 +1165,6 @@ export async function deployCommand({ argv, root, json, }) {
1133
1165
  const needsHubSpotBundle = needsRuntime || app.mcpServers.length > 0;
1134
1166
  const { deploymentMode, cloudflareDeployRequested, hubspotUploadRequested } = resolveDeployTargets({
1135
1167
  buildOnly,
1136
- planOnly,
1137
1168
  needsRuntime,
1138
1169
  hasProjectId: Boolean(projectId),
1139
1170
  hubspotOnly,
@@ -1417,10 +1448,8 @@ export async function deployCommand({ argv, root, json, }) {
1417
1448
  // (`--yes`) preflight succeed instead of demanding a token connect says
1418
1449
  // doesn't exist (cold-stranger run 2026-07-22-001, finding #1).
1419
1450
  if (cloudflareDeployRequested && !planOnly && !cloudflareDryRun) {
1420
- const hydrated = await hydrateCloudflareTokenFromStoredOAuth({
1421
- argv,
1422
- root,
1423
- });
1451
+ const hydrated = (controlPlaneUrl ? await hydrateCloudflareTokenFromControlPlane({ argv, root }) : false) ||
1452
+ (await hydrateCloudflareTokenFromStoredOAuth({ argv, root }));
1424
1453
  if (hydrated && echo) {
1425
1454
  echo.info('Using the Cloudflare connection from `hs-x connect cloudflare`.');
1426
1455
  }
@@ -1447,7 +1476,17 @@ export async function deployCommand({ argv, root, json, }) {
1447
1476
  // Worker origin is resolvable up front, upload BEFORE the Cloudflare deploy so
1448
1477
  // the minted app id binds in a single pass. Falls back to the original
1449
1478
  // deploy-then-upload order whenever the origin can't be resolved.
1450
- const appIdKnownBeforeDeploy = readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root)));
1479
+ const appIdKnownBeforeDeploy = controlPlanePlan && controlPlaneBackedPlan && controlPlaneUrl
1480
+ ? await resolveHubSpotAppIdForDeploy({
1481
+ argv,
1482
+ root,
1483
+ controlPlaneUrl,
1484
+ userId,
1485
+ accountId: controlPlanePlan.accountId,
1486
+ projectId: controlPlanePlan.projectId,
1487
+ environment: resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production',
1488
+ })
1489
+ : (readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root))));
1451
1490
  const uploadFirstOrigin = cloudflareDeployRequested &&
1452
1491
  !cloudflareDryRun &&
1453
1492
  !planOnly &&
@@ -1596,6 +1635,10 @@ export async function deployCommand({ argv, root, json, }) {
1596
1635
  plan: controlPlanePlan,
1597
1636
  promoteWhenHealthy,
1598
1637
  ...(capturedRevision ? { revision: capturedRevision } : {}),
1638
+ ...(() => {
1639
+ const deployedWorkerUrl = cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
1640
+ return deployedWorkerUrl ? { workerUrl: deployedWorkerUrl } : {};
1641
+ })(),
1599
1642
  })
1600
1643
  : controlPlaneUrl
1601
1644
  ? await requestHostedControlPlaneDeployRecordAndMaybePromote({
@@ -1694,7 +1737,7 @@ export async function deployCommand({ argv, root, json, }) {
1694
1737
  // run-008 build #2's failure mode. Only a SUCCESS status counts.
1695
1738
  const hubspotUploadFailed = !planOnly &&
1696
1739
  hubspotUploadRequested &&
1697
- (!hubspotUploadResult || readStatus(hubspotUploadResult.buildStatus) !== 'SUCCESS');
1740
+ (!hubspotUploadResult || !hubSpotUploadSucceeded(hubspotUploadResult));
1698
1741
  const cloudflareDeployed = !planOnly && cloudflareDeployRequested && Boolean(cloudflareDeployResult);
1699
1742
  // A live worker with only the HubSpot upload failing: not a clean success,
1700
1743
  // but distinct from total failure so callers don't tear down a good worker.
@@ -1810,6 +1853,16 @@ export async function deployCommand({ argv, root, json, }) {
1810
1853
  // automation doesn't treat a serving worker as a total failure), 1 = failure.
1811
1854
  return { exitCode: plan.ok ? 0 : plan.partial ? 20 : 1 };
1812
1855
  }
1856
+ /** @internal Exported for the linked-default deploy regression suite. */
1857
+ export function resolveDeployControlPlaneUrl(input) {
1858
+ if (input.explicitControlPlaneUrl)
1859
+ return input.explicitControlPlaneUrl;
1860
+ if (input.planOnly)
1861
+ return undefined;
1862
+ if (input.linked)
1863
+ return input.platformControlPlaneUrl;
1864
+ return input.environmentControlPlaneUrl;
1865
+ }
1813
1866
  function buildDeployPlanSummary(input) {
1814
1867
  const lines = ['', 'Planned changes:'];
1815
1868
  const capCount = input.workers.reduce((c, w) => c + w.capabilities.length, 0);
@@ -2680,6 +2733,14 @@ function defaultHubSpotFieldType(type) {
2680
2733
  return 'text';
2681
2734
  }
2682
2735
  }
2736
+ /** @internal Exported for deterministic deploy-outcome regression tests. */
2737
+ export function hubSpotUploadSucceeded(upload) {
2738
+ if (readStatus(upload.buildStatus) !== 'SUCCESS')
2739
+ return false;
2740
+ if (upload.deployStatus !== undefined)
2741
+ return readStatus(upload.deployStatus) === 'SUCCESS';
2742
+ return !upload.autoDeployExpected;
2743
+ }
2683
2744
  async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, machineSecret, heartbeatEnabled, dryRun, note, }) {
2684
2745
  // Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
2685
2746
  // an unlinked deploy can't leave a Worker live and then error on the
@@ -2687,12 +2748,40 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2687
2748
  if (!controlPlaneUrl && !dryRun) {
2688
2749
  await preflightTenantStateCredentials({ argv, root });
2689
2750
  }
2751
+ if (controlPlaneUrl && !dryRun) {
2752
+ const credentials = resolveCloudflareCredentials(argv);
2753
+ if (!credentials.accountId || !credentials.apiToken) {
2754
+ throw new Error('Linked Cloudflare deploy requires an account id and API token from the connected credential lease.');
2755
+ }
2756
+ // Read-only and intentionally first: a fresh Cloudflare account otherwise
2757
+ // fails only at Worker upload, after KV/D1 provisioning has already run.
2758
+ await preflightCloudflareAnalyticsEngine({
2759
+ accountId: credentials.accountId,
2760
+ apiToken: credentials.apiToken,
2761
+ });
2762
+ // Tenant provisioning authorizes only registered projects. Register the
2763
+ // plan scope before any Worker enters that authority, not after recordDeploy.
2764
+ await ensureHostedProjectRegistration({
2765
+ controlPlaneUrl,
2766
+ userId,
2767
+ accountId: controlPlanePlan.accountId,
2768
+ projectId: controlPlanePlan.projectId,
2769
+ });
2770
+ }
2690
2771
  const hasCardBackends = workers.some((worker) => worker.capabilities.some((capability) => capability.kind === 'card-backend'));
2691
2772
  if (hasCardBackends && !dryRun) {
2692
2773
  const appConfig = await readHsxAppConfig(root);
2693
2774
  const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
2694
2775
  const authUrl = await resolveHubSpotAppAuthUrl(argv, root);
2695
- const hubspotAppId = requireHubSpotCardAppId(readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root))), authUrl);
2776
+ const hubspotAppId = requireHubSpotCardAppId(await resolveHubSpotAppIdForDeploy({
2777
+ argv,
2778
+ root,
2779
+ ...(controlPlaneUrl ? { controlPlaneUrl } : {}),
2780
+ userId,
2781
+ accountId: controlPlanePlan.accountId,
2782
+ projectId: controlPlanePlan.projectId,
2783
+ environment,
2784
+ }), authUrl);
2696
2785
  const stored = controlPlaneUrl
2697
2786
  ? await readDeployHubSpotOAuthSecret({
2698
2787
  controlPlaneUrl,
@@ -2729,8 +2818,9 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2729
2818
  ? {
2730
2819
  authority: createControlPlaneTenantProvisioningAuthority({
2731
2820
  controlPlaneUrl,
2732
- authorizationHeaders: () => controlPlaneAuthHeaders(userId),
2821
+ authorizationHeaders: () => controlPlaneAuthHeaders(userId, undefined, controlPlanePlan.accountId),
2733
2822
  http: hostedHttp,
2823
+ validateCompleteResources: (resources) => validateTenantDataPlaneResourcesForActiveAccount(argv, root, resources),
2734
2824
  }),
2735
2825
  }
2736
2826
  : {});
@@ -2952,7 +3042,13 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2952
3042
  `${resolvedHubSpotClientSecret ? '' : ' — no client secret; card verification will fail closed'}\n`);
2953
3043
  }
2954
3044
  const hubSpotScopesForRuntime = await readHsxAppScopesForDeploy(root);
2955
- const billingRuntimeToken = controlPlaneUrl && appConfig.billing && !dryRun
3045
+ // The same scoped runtime bearer authenticates both hosted billing and the
3046
+ // redacted install-lifecycle telemetry emitted by linked OAuth Workers.
3047
+ // Provision it whenever either consumer is present. Gating this solely on
3048
+ // `appConfig.billing` leaves ordinary OAuth apps with generated telemetry
3049
+ // code but no HSX_RUNTIME_TOKEN binding, making every emit a silent no-op.
3050
+ const needsRuntimeControlPlaneToken = appConfig.billing !== undefined || appConfig.auth === 'oauth';
3051
+ const billingRuntimeToken = controlPlaneUrl && needsRuntimeControlPlaneToken && !dryRun
2956
3052
  ? await requestDeployBillingRuntimeToken({
2957
3053
  controlPlaneUrl,
2958
3054
  userId,
@@ -3246,9 +3342,15 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3246
3342
  hubSpotAppId: input.hubSpotAppId,
3247
3343
  },
3248
3344
  kind: 'install-kv',
3345
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3346
+ argv: input.argv,
3347
+ root: input.root,
3348
+ kind: 'install-kv',
3349
+ resourceName,
3350
+ }),
3249
3351
  async provisionByName(installKvNamespaceName) {
3250
3352
  // ADR-022: generate and escrow the token key before creating Cloudflare
3251
- // state, but only after long ambiguous names have already failed closed.
3353
+ // state, after long-name recovery ambiguity has already been ruled out.
3252
3354
  const tokenKeySecretValue = randomBytes(32).toString('base64url');
3253
3355
  const tokenKeyCustody = await escrowTokenKey({
3254
3356
  adapter: defaultTokenKeyCustodyAdapter(),
@@ -3273,8 +3375,24 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3273
3375
  ], {
3274
3376
  cwd: input.root,
3275
3377
  env: cloudflareDeployEnv(input.argv),
3378
+ }).catch((error) => {
3379
+ if (!/already exists/i.test(String(error)))
3380
+ throw error;
3381
+ return undefined;
3276
3382
  });
3277
- const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
3383
+ let resourceId = namespaceOutput
3384
+ ? extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`)
3385
+ : undefined;
3386
+ if (!namespaceOutput) {
3387
+ const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env: cloudflareDeployEnv(input.argv) });
3388
+ try {
3389
+ const namespaces = parseWranglerJsonArray(listed.stdout);
3390
+ resourceId = namespaces.find((namespace) => namespace.title === installKvNamespaceName)?.id;
3391
+ }
3392
+ catch {
3393
+ resourceId = undefined;
3394
+ }
3395
+ }
3278
3396
  if (!resourceId) {
3279
3397
  throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3280
3398
  }
@@ -3308,23 +3426,38 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3308
3426
  };
3309
3427
  }
3310
3428
  async function ensureDeployInstallRuntimeBinding(input) {
3429
+ const preserved = await recoverCompletedLinkInstallRuntimeBinding(input);
3430
+ if (preserved)
3431
+ return preserved;
3311
3432
  const existing = await readDeployInstallRuntimeBinding(input);
3312
3433
  if (existing) {
3313
- // Control-plane metadata carries no key custody (and never key bytes)
3314
- // on a machine that never generated the key this is worker-secret-only.
3315
- return {
3316
- installKvNamespaceId: existing.installKvNamespaceId,
3317
- installKvNamespaceName: existing.installKvNamespaceName,
3318
- tokenKeySecretName: existing.tokenKeySecretName,
3319
- ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
3320
- ...(existing.tenantD1DatabaseName
3321
- ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
3322
- : {}),
3323
- ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
3324
- ...(existing.flagsKvNamespaceName
3325
- ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
3326
- : {}),
3327
- };
3434
+ const credentials = resolveCloudflareCredentials(input.argv);
3435
+ if (!credentials.accountId || !credentials.apiToken) {
3436
+ throw new Error('Could not validate the stored install KV without Cloudflare credentials.');
3437
+ }
3438
+ const valid = await validateCloudflareResourceForDeploy({
3439
+ argv: input.argv,
3440
+ root: input.root,
3441
+ kind: 'install-kv',
3442
+ resourceId: existing.installKvNamespaceId,
3443
+ });
3444
+ if (valid) {
3445
+ // Control-plane metadata carries no key custody (and never key bytes) —
3446
+ // on a machine that never generated the key this is worker-secret-only.
3447
+ return {
3448
+ installKvNamespaceId: existing.installKvNamespaceId,
3449
+ installKvNamespaceName: existing.installKvNamespaceName,
3450
+ tokenKeySecretName: existing.tokenKeySecretName,
3451
+ ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
3452
+ ...(existing.tenantD1DatabaseName
3453
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
3454
+ : {}),
3455
+ ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
3456
+ ...(existing.flagsKvNamespaceName
3457
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
3458
+ : {}),
3459
+ };
3460
+ }
3328
3461
  }
3329
3462
  if (input.dryRun) {
3330
3463
  return undefined;
@@ -3337,9 +3470,15 @@ async function ensureDeployInstallRuntimeBinding(input) {
3337
3470
  hubSpotAppId: input.hubSpotAppId,
3338
3471
  },
3339
3472
  kind: 'install-kv',
3473
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3474
+ argv: input.argv,
3475
+ root: input.root,
3476
+ kind: 'install-kv',
3477
+ resourceName,
3478
+ }),
3340
3479
  async provisionByName(installKvNamespaceName) {
3341
- // ADR-022: escrow precedes Cloudflare mutation, while the ambiguity
3342
- // guard precedes both. The control plane never receives key bytes.
3480
+ // ADR-022: escrow precedes Cloudflare mutation, while the legacy-name
3481
+ // ambiguity guard precedes both. The control plane never receives key bytes.
3343
3482
  const tokenKeySecretValue = randomBytes(32).toString('base64url');
3344
3483
  const tokenKeyCustody = await escrowTokenKey({
3345
3484
  adapter: defaultTokenKeyCustodyAdapter(),
@@ -3364,8 +3503,24 @@ async function ensureDeployInstallRuntimeBinding(input) {
3364
3503
  ], {
3365
3504
  cwd: input.root,
3366
3505
  env: cloudflareDeployEnv(input.argv),
3506
+ }).catch((error) => {
3507
+ if (!/already exists/i.test(String(error)))
3508
+ throw error;
3509
+ return undefined;
3367
3510
  });
3368
- const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
3511
+ let resourceId = namespaceOutput
3512
+ ? extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`)
3513
+ : undefined;
3514
+ if (!namespaceOutput) {
3515
+ const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env: cloudflareDeployEnv(input.argv) });
3516
+ try {
3517
+ const namespaces = parseWranglerJsonArray(listed.stdout);
3518
+ resourceId = namespaces.find((namespace) => namespace.title === installKvNamespaceName)?.id;
3519
+ }
3520
+ catch {
3521
+ resourceId = undefined;
3522
+ }
3523
+ }
3369
3524
  if (!resourceId) {
3370
3525
  throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3371
3526
  }
@@ -3393,10 +3548,106 @@ async function ensureDeployInstallRuntimeBinding(input) {
3393
3548
  tokenKeyCustody,
3394
3549
  };
3395
3550
  }
3551
+ /**
3552
+ * A completed ADR-022 link journal is the authoritative preserve-set for the
3553
+ * first linked redeploy. Rehydrate its exact INSTALL_KV and re-keyed token-key
3554
+ * custody before consulting account-scoped control-plane metadata, which may
3555
+ * not have been backfilled yet. This prevents link from silently provisioning
3556
+ * a replacement namespace or key.
3557
+ */
3558
+ async function recoverCompletedLinkInstallRuntimeBinding(input) {
3559
+ const pointer = await loadCloudflarePointer(input.root);
3560
+ if (!pointer || !pointer.ownerId.startsWith('acct_'))
3561
+ return undefined;
3562
+ const journal = await loadLinkMigrationJournal(linkMigrationJournalId({
3563
+ cloudflareAccountId: pointer.cloudflareAccountId,
3564
+ stateKvNamespaceId: pointer.stateKvNamespaceId,
3565
+ newOwnerId: pointer.ownerId,
3566
+ }));
3567
+ if (!journal || journal.status !== 'done')
3568
+ return undefined;
3569
+ const binding = journal.bindings.find((candidate) => candidate.projectId === input.projectId &&
3570
+ candidate.environment === input.environment &&
3571
+ candidate.hubSpotAppId === input.hubSpotAppId);
3572
+ if (!binding)
3573
+ return undefined;
3574
+ if (input.dryRun) {
3575
+ return {
3576
+ installKvNamespaceId: binding.installKvNamespaceId,
3577
+ installKvNamespaceName: binding.installKvNamespaceName,
3578
+ tokenKeySecretName: binding.tokenKeySecretName,
3579
+ };
3580
+ }
3581
+ const valid = await validateCloudflareResourceForDeploy({
3582
+ argv: input.argv,
3583
+ root: input.root,
3584
+ kind: 'install-kv',
3585
+ resourceId: binding.installKvNamespaceId,
3586
+ });
3587
+ if (!valid) {
3588
+ throw new Error(`The completed link journal's preserved install KV ${binding.installKvNamespaceId} is no longer present in the connected Cloudflare account.`);
3589
+ }
3590
+ const custodyMechanism = Schema.decodeUnknownSync(schemas.TokenKeyCustodyMechanism)(binding.custodyMechanism);
3591
+ const adapter = tokenKeyCustodyAdapterFor(custodyMechanism);
3592
+ const tokenKeySecretValue = await adapter.retrieve(binding.newCustodyId);
3593
+ if (!tokenKeySecretValue) {
3594
+ throw new Error(`The completed link journal's re-keyed token custody ${binding.newCustodyId} is unavailable.`);
3595
+ }
3596
+ const tokenKeyCustody = Schema.decodeUnknownSync(schemas.EscrowedTokenKeyCustody)({
3597
+ kind: 'escrowed',
3598
+ mechanism: custodyMechanism,
3599
+ custodyId: binding.newCustodyId,
3600
+ escrowedAt: journal.updatedAt,
3601
+ canary: await sealTokenKeyCanary(tokenKeySecretValue),
3602
+ });
3603
+ await putDeployInstallRuntimeBinding({
3604
+ ...input,
3605
+ installKvNamespaceId: binding.installKvNamespaceId,
3606
+ installKvNamespaceName: binding.installKvNamespaceName,
3607
+ tokenKeySecretName: binding.tokenKeySecretName,
3608
+ });
3609
+ const { pointer: currentPointer, stateStore } = await ensureTenantStateStore({
3610
+ argv: input.argv,
3611
+ root: input.root,
3612
+ });
3613
+ const existing = await stateStore.getBinding({
3614
+ projectId: input.projectId,
3615
+ environment: input.environment,
3616
+ hubSpotAppId: input.hubSpotAppId,
3617
+ });
3618
+ await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
3619
+ ...(existing ?? {}),
3620
+ schemaVersion: 2,
3621
+ ownerId: currentPointer.ownerId,
3622
+ projectId: input.projectId,
3623
+ environment: input.environment,
3624
+ hubSpotAppId: input.hubSpotAppId,
3625
+ installKvNamespaceId: binding.installKvNamespaceId,
3626
+ installKvNamespaceName: binding.installKvNamespaceName,
3627
+ tokenKeySecretName: binding.tokenKeySecretName,
3628
+ tokenKeyCustody,
3629
+ updatedAt: new Date().toISOString(),
3630
+ }));
3631
+ return {
3632
+ installKvNamespaceId: binding.installKvNamespaceId,
3633
+ installKvNamespaceName: binding.installKvNamespaceName,
3634
+ tokenKeySecretName: binding.tokenKeySecretName,
3635
+ tokenKeySecretValue,
3636
+ tokenKeyCustody,
3637
+ ...(existing?.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
3638
+ ...(existing?.tenantD1DatabaseName
3639
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
3640
+ : {}),
3641
+ ...(existing?.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
3642
+ ...(existing?.flagsKvNamespaceName
3643
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
3644
+ : {}),
3645
+ };
3646
+ }
3396
3647
  async function readDeployInstallRuntimeBinding(input) {
3397
3648
  const response = await hostedHttp({
3398
3649
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/install-runtime?environment=${encodeURIComponent(input.environment)}`, input.controlPlaneUrl),
3399
- headers: await controlPlaneAuthHeaders(input.userId),
3650
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3400
3651
  });
3401
3652
  if (response.status === 404) {
3402
3653
  return undefined;
@@ -3414,7 +3665,7 @@ async function putDeployInstallRuntimeBinding(input) {
3414
3665
  const response = await hostedHttp({
3415
3666
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/install-runtime`, input.controlPlaneUrl),
3416
3667
  method: 'PUT',
3417
- headers: await controlPlaneAuthHeaders(input.userId),
3668
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3418
3669
  body: {
3419
3670
  environment: input.environment,
3420
3671
  installKvNamespaceId: input.installKvNamespaceId,
@@ -3436,11 +3687,30 @@ async function putDeployInstallRuntimeBinding(input) {
3436
3687
  throw new Error(`Could not store scoped install runtime binding: ${message}`);
3437
3688
  }
3438
3689
  }
3690
+ async function ensureHostedProjectRegistration(input) {
3691
+ const response = await hostedHttp({
3692
+ url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects`, input.controlPlaneUrl),
3693
+ method: 'POST',
3694
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3695
+ body: { projectId: input.projectId, displayName: input.projectId },
3696
+ });
3697
+ if (response.ok || response.status === 409)
3698
+ return;
3699
+ // The in-process legacy control-plane test adapter predates the extracted
3700
+ // project router. Production exposes this route; tolerate only absence.
3701
+ if (response.status === 404 || response.status === 405)
3702
+ return;
3703
+ const body = await response.json().catch(() => undefined);
3704
+ const message = isRecord(body) && typeof body.message === 'string'
3705
+ ? body.message
3706
+ : `Control plane returned ${response.status}.`;
3707
+ throw new Error(`Could not register project before tenant provisioning: ${message}`);
3708
+ }
3439
3709
  async function requestDeployBillingRuntimeToken(input) {
3440
3710
  const response = await hostedHttp({
3441
3711
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/billing/runtime-token`, input.controlPlaneUrl),
3442
3712
  method: 'POST',
3443
- headers: await controlPlaneAuthHeaders(input.userId),
3713
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3444
3714
  body: {
3445
3715
  environment: input.environment,
3446
3716
  },
@@ -3499,6 +3769,10 @@ async function ensureTenantDataPlane(input) {
3499
3769
  hubSpotAppId: input.hubSpotAppId,
3500
3770
  });
3501
3771
  const env = cloudflareDeployEnv(input.argv);
3772
+ const cloudflareCredentials = resolveCloudflareCredentials(input.argv);
3773
+ if (!cloudflareCredentials.accountId || !cloudflareCredentials.apiToken) {
3774
+ throw new Error('Tenant data-plane provisioning requires Cloudflare credentials.');
3775
+ }
3502
3776
  // Exact local state wins; exact control-plane refs recover a linked deploy
3503
3777
  // when its tenant-state binding was lost. Name-only create/adopt remains
3504
3778
  // available for short legacy-compatible names only.
@@ -3509,6 +3783,24 @@ async function ensureTenantDataPlane(input) {
3509
3783
  kind: 'tenant-d1',
3510
3784
  ...(exactTenantD1DatabaseId ? { exactResourceId: exactTenantD1DatabaseId } : {}),
3511
3785
  ...(exactTenantD1DatabaseName ? { exactResourceName: exactTenantD1DatabaseName } : {}),
3786
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3787
+ argv: input.argv,
3788
+ root: input.root,
3789
+ kind: 'tenant-d1',
3790
+ resourceName,
3791
+ }),
3792
+ validateExactResource: (resourceId) => {
3793
+ const credentials = resolveCloudflareCredentials(input.argv);
3794
+ if (!credentials.accountId || !credentials.apiToken) {
3795
+ throw new Error('Could not validate the stored tenant D1 without Cloudflare credentials.');
3796
+ }
3797
+ return validateCloudflareResourceForDeploy({
3798
+ argv: input.argv,
3799
+ root: input.root,
3800
+ kind: 'tenant-d1',
3801
+ resourceId,
3802
+ });
3803
+ },
3512
3804
  async provisionByName(tenantD1DatabaseName) {
3513
3805
  const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
3514
3806
  if (!/already exists/i.test(String(error)))
@@ -3538,6 +3830,24 @@ async function ensureTenantDataPlane(input) {
3538
3830
  kind: 'flags-kv',
3539
3831
  ...(exactFlagsKvNamespaceId ? { exactResourceId: exactFlagsKvNamespaceId } : {}),
3540
3832
  ...(exactFlagsKvNamespaceName ? { exactResourceName: exactFlagsKvNamespaceName } : {}),
3833
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3834
+ argv: input.argv,
3835
+ root: input.root,
3836
+ kind: 'flags-kv',
3837
+ resourceName,
3838
+ }),
3839
+ validateExactResource: (resourceId) => {
3840
+ const credentials = resolveCloudflareCredentials(input.argv);
3841
+ if (!credentials.accountId || !credentials.apiToken) {
3842
+ throw new Error('Could not validate the stored flags KV without Cloudflare credentials.');
3843
+ }
3844
+ return validateCloudflareResourceForDeploy({
3845
+ argv: input.argv,
3846
+ root: input.root,
3847
+ kind: 'flags-kv',
3848
+ resourceId,
3849
+ });
3850
+ },
3541
3851
  async provisionByName(flagsKvNamespaceName) {
3542
3852
  const created = await runCloudflareCommand([
3543
3853
  'bun',
@@ -3560,7 +3870,7 @@ async function ensureTenantDataPlane(input) {
3560
3870
  if (!created) {
3561
3871
  const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
3562
3872
  try {
3563
- const namespaces = JSON.parse(listed.stdout);
3873
+ const namespaces = parseWranglerJsonArray(listed.stdout);
3564
3874
  resourceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
3565
3875
  }
3566
3876
  catch {
@@ -3607,6 +3917,7 @@ async function ensureTenantDataPlane(input) {
3607
3917
  updatedAt: new Date().toISOString(),
3608
3918
  }));
3609
3919
  return {
3920
+ cloudflareAccountId: cloudflareCredentials.accountId,
3610
3921
  tenantD1DatabaseId,
3611
3922
  tenantD1DatabaseName,
3612
3923
  flagsKvNamespaceId,
@@ -3615,6 +3926,78 @@ async function ensureTenantDataPlane(input) {
3615
3926
  syncGrantSecretValue,
3616
3927
  };
3617
3928
  }
3929
+ async function validateTenantDataPlaneResourcesForActiveAccount(argv, root, resources) {
3930
+ const credentials = resolveCloudflareCredentials(argv);
3931
+ if (!credentials.accountId || !credentials.apiToken) {
3932
+ throw new Error('Could not validate tenant resources without Cloudflare credentials.');
3933
+ }
3934
+ if (resources.cloudflareAccountId && resources.cloudflareAccountId !== credentials.accountId) {
3935
+ return false;
3936
+ }
3937
+ const [tenantD1Valid, flagsKvValid] = await Promise.all([
3938
+ validateCloudflareResourceForDeploy({
3939
+ argv,
3940
+ root,
3941
+ kind: 'tenant-d1',
3942
+ resourceId: resources.tenantD1DatabaseId,
3943
+ }),
3944
+ validateCloudflareResourceForDeploy({
3945
+ argv,
3946
+ root,
3947
+ kind: 'flags-kv',
3948
+ resourceId: resources.flagsKvNamespaceId,
3949
+ }),
3950
+ ]);
3951
+ return tenantD1Valid && flagsKvValid;
3952
+ }
3953
+ async function validateCloudflareResourceForDeploy(input) {
3954
+ const credentials = resolveCloudflareCredentials(input.argv);
3955
+ if (!credentials.accountId || !credentials.apiToken) {
3956
+ throw new Error('Could not validate Cloudflare resources without connected credentials.');
3957
+ }
3958
+ try {
3959
+ return await validateCloudflareResourceForAccount({
3960
+ accountId: credentials.accountId,
3961
+ apiToken: credentials.apiToken,
3962
+ kind: input.kind,
3963
+ resourceId: input.resourceId,
3964
+ });
3965
+ }
3966
+ catch (error) {
3967
+ if (!/\((?:401|403)\)/.test(String(error)))
3968
+ throw error;
3969
+ }
3970
+ const command = input.kind === 'tenant-d1'
3971
+ ? ['bun', 'x', 'wrangler', 'd1', 'list', '--json']
3972
+ : ['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'];
3973
+ const listed = await runCloudflareCommand(command, {
3974
+ cwd: input.root,
3975
+ env: cloudflareDeployEnv(input.argv),
3976
+ });
3977
+ try {
3978
+ const rows = parseWranglerJsonArray(listed.stdout);
3979
+ return rows.some((row) => row.id === input.resourceId || row.uuid === input.resourceId);
3980
+ }
3981
+ catch {
3982
+ throw new Error(`HSX_E_CLOUDFLARE_RESOURCE_VALIDATION_FAILED: Wrangler returned malformed ${input.kind} list output.`);
3983
+ }
3984
+ }
3985
+ async function cloudflareResourceNameExists(input) {
3986
+ const command = input.kind === 'tenant-d1'
3987
+ ? ['bun', 'x', 'wrangler', 'd1', 'list', '--json']
3988
+ : ['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'];
3989
+ const listed = await runCloudflareCommand(command, {
3990
+ cwd: input.root,
3991
+ env: cloudflareDeployEnv(input.argv),
3992
+ });
3993
+ try {
3994
+ const rows = parseWranglerJsonArray(listed.stdout);
3995
+ return rows.some((row) => row.name === input.resourceName || row.title === input.resourceName);
3996
+ }
3997
+ catch {
3998
+ throw new Error(`HSX_E_CLOUDFLARE_RESOURCE_VALIDATION_FAILED: Wrangler returned malformed ${input.kind} list output.`);
3999
+ }
4000
+ }
3618
4001
  export function renderWranglerConfig(input) {
3619
4002
  const lines = [
3620
4003
  '# Generated by hs-x. Do not edit by hand.',
@@ -3688,7 +4071,7 @@ function tomlString(value) {
3688
4071
  async function readDeployHubSpotOAuthSecret(input) {
3689
4072
  const response = await hostedHttp({
3690
4073
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/oauth-secret?environment=${encodeURIComponent(input.environment)}`, input.controlPlaneUrl),
3691
- headers: await controlPlaneAuthHeaders(input.userId),
4074
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3692
4075
  });
3693
4076
  if (response.status === 404) {
3694
4077
  return undefined;
@@ -4279,7 +4662,14 @@ function runCloudflareCommand(command, options) {
4279
4662
  resolvePromise({ stdout, stderr });
4280
4663
  }
4281
4664
  else {
4282
- rejectPromise(new Error(`Cloudflare deploy failed with exit code ${code ?? 'unknown'}.\n${stderr || stdout}`));
4665
+ rejectPromise(classifyCloudflareCommandFailure({
4666
+ ...(options.env.CLOUDFLARE_ACCOUNT_ID
4667
+ ? { accountId: options.env.CLOUDFLARE_ACCOUNT_ID }
4668
+ : {}),
4669
+ stdout,
4670
+ stderr,
4671
+ exitCode: code,
4672
+ }));
4283
4673
  }
4284
4674
  });
4285
4675
  })).pipe(Effect.withSpan('cli.deploy.cloudflare_command', {
@@ -4677,6 +5067,45 @@ async function readPersistedHubSpotAppBinding(root, expectedProjectName) {
4677
5067
  async function readPersistedHubSpotAppId(root, expectedProjectName) {
4678
5068
  return (await readPersistedHubSpotAppBinding(root, expectedProjectName))?.appId;
4679
5069
  }
5070
+ /**
5071
+ * Resolve the one HubSpot app a linked project already registered. The common
5072
+ * path remains implicit: a first combined deploy persists the app id, while a
5073
+ * checkout missing that advisory file can recover an unambiguous binding from
5074
+ * the control plane. Explicit ids remain the advanced override.
5075
+ */
5076
+ async function resolveHubSpotAppIdForDeploy(input) {
5077
+ const explicit = readOptionalHubSpotAppId(resolveFlag(input.argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID);
5078
+ const projectName = await resolveHubSpotProjectName(input.argv, input.root);
5079
+ const persisted = await readPersistedHubSpotAppId(input.root, projectName);
5080
+ const selected = explicit ?? persisted;
5081
+ if (selected || !input.controlPlaneUrl)
5082
+ return selected;
5083
+ const response = await hostedHttp({
5084
+ url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/runtime-bindings`, input.controlPlaneUrl),
5085
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
5086
+ });
5087
+ if (!response.ok)
5088
+ return undefined;
5089
+ const body = await response.json();
5090
+ const bindings = Schema.decodeUnknownSync(schemas.ListProjectRuntimeBindingsResponse)(body).bindings;
5091
+ const appIds = [
5092
+ ...new Set(bindings
5093
+ .filter((binding) => binding.environment === input.environment)
5094
+ .map((binding) => binding.hubSpotAppId)),
5095
+ ];
5096
+ if (appIds.length === 0)
5097
+ return undefined;
5098
+ if (appIds.length > 1) {
5099
+ throw new Error(`Project ${input.projectId} has multiple HubSpot apps in ${input.environment} (${appIds.join(', ')}). Pass --hubspot-app-id once to choose one.`);
5100
+ }
5101
+ const appId = appIds[0];
5102
+ await persistHubSpotAppBinding(input.root, {
5103
+ appId,
5104
+ projectName,
5105
+ developerAccountId: undefined,
5106
+ });
5107
+ return appId;
5108
+ }
4680
5109
  async function resolveHubSpotAppAuthUrl(argv, root) {
4681
5110
  const projectName = await resolveHubSpotProjectName(argv, root);
4682
5111
  const persisted = await readPersistedHubSpotAppBinding(root, projectName);
@@ -4794,7 +5223,7 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
4794
5223
  const response = await hostedHttp({
4795
5224
  url: new URL('/v1/deploys/plan', controlPlaneUrl),
4796
5225
  method: 'POST',
4797
- headers: await controlPlaneAuthHeaders(userId),
5226
+ headers: await controlPlaneAuthHeaders(userId, undefined, request.accountId),
4798
5227
  body: request,
4799
5228
  });
4800
5229
  const body = await response.json();
@@ -4805,7 +5234,7 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
4805
5234
  }
4806
5235
  return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
4807
5236
  }
4808
- async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
5237
+ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, workerUrl, revision, }) {
4809
5238
  const localAttestationAuth = revision
4810
5239
  ? { grantSecret: randomBytes(32).toString('base64url'), hubSpotAppId: 1 }
4811
5240
  : undefined;
@@ -4848,6 +5277,7 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
4848
5277
  manifestHash: plan.manifestHash,
4849
5278
  signedManifest: request.manifest,
4850
5279
  signature: `sig_${plan.deployId}`,
5280
+ ...(workerUrl ? { workerUrl } : {}),
4851
5281
  // ADR-021 R03: the real Cloudflare Worker version identity captured
4852
5282
  // back from the confirmed active deployment. Absent only when capture
4853
5283
  // was skipped/failed (best-effort) — then this deploy is history-only.
@@ -4930,13 +5360,14 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
4930
5360
  const response = await hostedHttp({
4931
5361
  url: new URL(`/v1/deploys/${encodeURIComponent(plan.deployId)}/record`, controlPlaneUrl),
4932
5362
  method: 'POST',
4933
- headers: await controlPlaneAuthHeaders(userId),
5363
+ headers: await controlPlaneAuthHeaders(userId, undefined, request.accountId),
4934
5364
  body: {
4935
5365
  accountId: request.accountId,
4936
5366
  projectId: request.projectId,
4937
5367
  manifestHash: plan.manifestHash,
4938
5368
  signedManifest: request.manifest,
4939
5369
  signature: `sig_${plan.deployId}`,
5370
+ ...(workerUrl ? { workerUrl } : {}),
4940
5371
  // ADR-021 R03: the real Cloudflare Worker version identity captured back
4941
5372
  // from the confirmed active deployment. Absent only when capture was
4942
5373
  // skipped/failed (best-effort) — then this deploy is history-only.
@@ -4956,6 +5387,7 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
4956
5387
  }
4957
5388
  const drift = await waitForHealthyControlPlaneDrift({
4958
5389
  controlPlaneUrl,
5390
+ accountId: request.accountId,
4959
5391
  projectId: request.projectId,
4960
5392
  deployId: plan.deployId,
4961
5393
  userId,
@@ -5258,7 +5690,7 @@ function discoverManageSchemaMode(definition) {
5258
5690
  }
5259
5691
  function discoverSourceDefinitions(source) {
5260
5692
  const definitions = new Map();
5261
- for (const match of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*defineSource(\.push)?\s*\(\s*\{([\s\S]*?)\}\s*\)/g)) {
5693
+ for (const match of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*defineSource(\.push)?\s*(?:<[\s\S]*?>)?\s*\(\s*\{([\s\S]*?)\}\s*\)/g)) {
5262
5694
  const variableName = match[1];
5263
5695
  const body = match[3] ?? '';
5264
5696
  if (!variableName) {
@@ -5382,6 +5814,9 @@ const deployOptions = {
5382
5814
  message: Options.text('message').pipe(Options.withAlias('m'), Options.optional), // --message / -m
5383
5815
  platformVersion: Options.text('platform-version').pipe(Options.optional), // --platform-version
5384
5816
  hubspotProjectName: Options.text('hubspot-project-name').pipe(Options.optional), // --hubspot-project-name
5817
+ cloudflareAccountId: Options.text('cloudflare-account-id').pipe(Options.optional), // --cloudflare-account-id
5818
+ cloudflareApiToken: Options.text('cloudflare-api-token').pipe(Options.optional), // --cloudflare-api-token
5819
+ apiToken: Options.text('api-token').pipe(Options.optional), // --api-token
5385
5820
  // ── boolean flags (argv.includes) ──
5386
5821
  plan: Options.boolean('plan').pipe(Options.withDefault(false)), // --plan
5387
5822
  dryRun: Options.boolean('dry-run').pipe(Options.withDefault(false)), // --dry-run
@@ -5448,6 +5883,9 @@ const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler
5448
5883
  push('--message', opts.message);
5449
5884
  push('--platform-version', opts.platformVersion);
5450
5885
  push('--hubspot-project-name', opts.hubspotProjectName);
5886
+ push('--cloudflare-account-id', opts.cloudflareAccountId);
5887
+ push('--cloudflare-api-token', opts.cloudflareApiToken);
5888
+ push('--api-token', opts.apiToken);
5451
5889
  flag('--plan', opts.plan);
5452
5890
  flag('--dry-run', opts.dryRun);
5453
5891
  flag('--build-only', opts.buildOnly);