@hs-x/cli 0.4.6 → 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 (74) 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-kv.d.ts +9 -0
  7. package/dist/cloudflare-kv.d.ts.map +1 -1
  8. package/dist/cloudflare-kv.js +16 -7
  9. package/dist/cloudflare-kv.js.map +1 -1
  10. package/dist/cloudflare-oauth.js +2 -2
  11. package/dist/cloudflare-oauth.js.map +1 -1
  12. package/dist/cloudflare-scoped-resource.d.ts +17 -2
  13. package/dist/cloudflare-scoped-resource.d.ts.map +1 -1
  14. package/dist/cloudflare-scoped-resource.js +46 -6
  15. package/dist/cloudflare-scoped-resource.js.map +1 -1
  16. package/dist/commands/api.d.ts.map +1 -1
  17. package/dist/commands/api.js +1 -1
  18. package/dist/commands/api.js.map +1 -1
  19. package/dist/commands/connect.js +3 -3
  20. package/dist/commands/connect.js.map +1 -1
  21. package/dist/commands/control-plane-read.d.ts +2 -2
  22. package/dist/commands/control-plane-read.d.ts.map +1 -1
  23. package/dist/commands/control-plane-read.js +35 -22
  24. package/dist/commands/control-plane-read.js.map +1 -1
  25. package/dist/commands/deploy-promote.js +3 -3
  26. package/dist/commands/deploy-promote.js.map +1 -1
  27. package/dist/commands/deploy.d.ts +17 -0
  28. package/dist/commands/deploy.d.ts.map +1 -1
  29. package/dist/commands/deploy.js +265 -30
  30. package/dist/commands/deploy.js.map +1 -1
  31. package/dist/commands/dev.js +7 -9
  32. package/dist/commands/dev.js.map +1 -1
  33. package/dist/commands/link.d.ts.map +1 -1
  34. package/dist/commands/link.js +41 -7
  35. package/dist/commands/link.js.map +1 -1
  36. package/dist/commands/rollback.d.ts +23 -0
  37. package/dist/commands/rollback.d.ts.map +1 -1
  38. package/dist/commands/rollback.js +60 -13
  39. package/dist/commands/rollback.js.map +1 -1
  40. package/dist/commands/status.d.ts.map +1 -1
  41. package/dist/commands/status.js +4 -4
  42. package/dist/commands/status.js.map +1 -1
  43. package/dist/commands/sync.d.ts.map +1 -1
  44. package/dist/commands/sync.js +24 -9
  45. package/dist/commands/sync.js.map +1 -1
  46. package/dist/constants.d.ts +1 -1
  47. package/dist/constants.js +1 -1
  48. package/dist/control-plane-fetch.d.ts +3 -1
  49. package/dist/control-plane-fetch.d.ts.map +1 -1
  50. package/dist/control-plane-fetch.js +9 -7
  51. package/dist/control-plane-fetch.js.map +1 -1
  52. package/dist/deploy-targets.d.ts +0 -1
  53. package/dist/deploy-targets.d.ts.map +1 -1
  54. package/dist/deploy-targets.js +2 -5
  55. package/dist/deploy-targets.js.map +1 -1
  56. package/dist/dev/invoke.d.ts +7 -0
  57. package/dist/dev/invoke.d.ts.map +1 -1
  58. package/dist/dev/invoke.js +18 -5
  59. package/dist/dev/invoke.js.map +1 -1
  60. package/dist/errors-registry.js +8 -8
  61. package/dist/errors-registry.js.map +1 -1
  62. package/dist/hubspot-developer-client.js +2 -2
  63. package/dist/hubspot-developer-client.js.map +1 -1
  64. package/dist/services/control-plane.d.ts +3 -1
  65. package/dist/services/control-plane.d.ts.map +1 -1
  66. package/dist/services/control-plane.js +8 -6
  67. package/dist/services/control-plane.js.map +1 -1
  68. package/dist/services/hubspot-dev-client.js +1 -1
  69. package/dist/services/hubspot-dev-client.js.map +1 -1
  70. package/dist/tenant-state.d.ts +12 -2
  71. package/dist/tenant-state.d.ts.map +1 -1
  72. package/dist/tenant-state.js +42 -18
  73. package/dist/tenant-state.js.map +1 -1
  74. package/package.json +7 -7
@@ -16,9 +16,9 @@ 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
18
  import { classifyCloudflareCommandFailure, preflightCloudflareAnalyticsEngine, validateCloudflareResourceForAccount, } from '../cloudflare-account-preflight.js';
19
- import { resolveCloudflareCredentials } from '../cloudflare-kv.js';
19
+ import { applyLeasedCloudflareCredentials, resolveCloudflareCredentials, } from '../cloudflare-kv.js';
20
20
  import { loadCloudflarePointer } from '../cloudflare-pointer.js';
21
- import { resolveOrProvisionCloudflareScopedResource } from '../cloudflare-scoped-resource.js';
21
+ import { parseWranglerJsonArray, resolveOrProvisionCloudflareScopedResource, } from '../cloudflare-scoped-resource.js';
22
22
  import { DEFAULT_CONTROL_PLANE_URL, formatConfigUrl, loadCliConfig, resolveControlPlaneUrl, } from '../config.js';
23
23
  import { CLI_VERSION } from '../constants.js';
24
24
  import { controlPlaneAuthHeaders } from '../control-plane-fetch.js';
@@ -31,6 +31,7 @@ import { DirectHubSpotAccountResolutionError, createDeployHubSpotDeveloperClient
31
31
  import { HubSpotProjectArchiveSourceError, fingerprintHubSpotArchiveSource, writeLocalHubSpotProjectArchive, } from '../hubspot-project-archive.js';
32
32
  import { deployHubSpotProjectBuild, readHubSpotSubbuildStatuses, waitForHubSpotAutoDeploy, waitForHubSpotBuildStatus, } from '../hubspot-project-lifecycle.js';
33
33
  import { detectLocalProjectMode, readHubSpotProjectDescriptor, validateHubSpotProject, } from '../hubspot-project.js';
34
+ import { linkMigrationJournalId, loadLinkMigrationJournal } from '../link-migration-journal.js';
34
35
  import { hydrateAncestorEnv } from '../load-env.js';
35
36
  import { LocalProjectPickerCancelled, resolveLocalProject } from '../local-project-picker.js';
36
37
  import { getMachineId, getMachineSecret, previewMachineId } from '../machine-id.js';
@@ -42,7 +43,7 @@ import { createReporter } from '../reporter/index.js';
42
43
  import { Cwd } from '../services/cwd.js';
43
44
  import { createTenantDataPlaneProvisioningCoordinator, } from '../tenant-data-plane-provisioning.js';
44
45
  import { ensureTenantStateStore, preflightTenantStateCredentials, } from '../tenant-state.js';
45
- import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
46
+ import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, sealTokenKeyCanary, tokenKeyCustodyAdapterFor, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
46
47
  import { deployPromoteCommand, requestControlPlaneDeployPromotion } from './deploy-promote.js';
47
48
  import { withTransientDeployRetry } from './deploy-retry.js';
48
49
  async function hostedHttp(input) {
@@ -494,11 +495,13 @@ async function hydrateCloudflareTokenFromControlPlane(input) {
494
495
  const lease = await leaseCloudflareViaSession(input.argv, input.root);
495
496
  if (!lease)
496
497
  return false;
497
- process.env.CLOUDFLARE_API_TOKEN = lease.apiToken;
498
- process.env.CLOUDFLARE_ACCOUNT_ID = lease.cloudflareAccountId;
498
+ applyLeasedCloudflareCredentials({
499
+ apiToken: lease.apiToken,
500
+ accountId: lease.cloudflareAccountId,
501
+ });
499
502
  return true;
500
503
  }
501
- async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, deployId, userId, timeoutMs, workerUrl, }) {
504
+ async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, accountId, projectId, deployId, userId, timeoutMs, workerUrl, }) {
502
505
  const startedAt = Date.now();
503
506
  let lastState = 'unknown';
504
507
  let lastReason;
@@ -508,7 +511,7 @@ async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, dep
508
511
  }
509
512
  const response = await hostedHttp({
510
513
  url: new URL(`/v1/projects/${encodeURIComponent(projectId)}/drift`, controlPlaneUrl),
511
- headers: await controlPlaneAuthHeaders(userId),
514
+ headers: await controlPlaneAuthHeaders(userId, undefined, accountId),
512
515
  });
513
516
  const body = await response.json().catch(() => undefined);
514
517
  if (response.ok) {
@@ -976,7 +979,7 @@ async function executeHubSpotDirectDeployCommand({ argv, root, json, }) {
976
979
  ? (readStatus(uploaded.deployStatus) ?? 'UNKNOWN')
977
980
  : undefined;
978
981
  const deployUnverified = uploaded.autoDeployExpected && deployState === undefined;
979
- const ok = buildState === 'SUCCESS' && !deployUnverified && (!deployState || deployState === 'SUCCESS');
982
+ const ok = hubSpotUploadSucceeded(uploaded);
980
983
  const result = {
981
984
  schema_version: 1,
982
985
  ok,
@@ -1101,8 +1104,14 @@ export async function deployCommand({ argv, root, json, }) {
1101
1104
  const noHeartbeat = argv.includes('--no-heartbeat');
1102
1105
  const noRecord = argv.includes('--no-record');
1103
1106
  const explicitControlPlaneUrl = resolveFlag(argv, '--control-plane-url');
1104
- const controlPlaneUrl = explicitControlPlaneUrl ?? (planOnly ? undefined : process.env.HSX_CONTROL_PLANE_URL);
1105
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
+ });
1106
1115
  const userId = resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user';
1107
1116
  const resolvedAcct = await resolveAccountIdOrPrompt({
1108
1117
  argv,
@@ -1156,7 +1165,6 @@ export async function deployCommand({ argv, root, json, }) {
1156
1165
  const needsHubSpotBundle = needsRuntime || app.mcpServers.length > 0;
1157
1166
  const { deploymentMode, cloudflareDeployRequested, hubspotUploadRequested } = resolveDeployTargets({
1158
1167
  buildOnly,
1159
- planOnly,
1160
1168
  needsRuntime,
1161
1169
  hasProjectId: Boolean(projectId),
1162
1170
  hubspotOnly,
@@ -1468,7 +1476,17 @@ export async function deployCommand({ argv, root, json, }) {
1468
1476
  // Worker origin is resolvable up front, upload BEFORE the Cloudflare deploy so
1469
1477
  // the minted app id binds in a single pass. Falls back to the original
1470
1478
  // deploy-then-upload order whenever the origin can't be resolved.
1471
- 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))));
1472
1490
  const uploadFirstOrigin = cloudflareDeployRequested &&
1473
1491
  !cloudflareDryRun &&
1474
1492
  !planOnly &&
@@ -1617,6 +1635,10 @@ export async function deployCommand({ argv, root, json, }) {
1617
1635
  plan: controlPlanePlan,
1618
1636
  promoteWhenHealthy,
1619
1637
  ...(capturedRevision ? { revision: capturedRevision } : {}),
1638
+ ...(() => {
1639
+ const deployedWorkerUrl = cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
1640
+ return deployedWorkerUrl ? { workerUrl: deployedWorkerUrl } : {};
1641
+ })(),
1620
1642
  })
1621
1643
  : controlPlaneUrl
1622
1644
  ? await requestHostedControlPlaneDeployRecordAndMaybePromote({
@@ -1715,7 +1737,7 @@ export async function deployCommand({ argv, root, json, }) {
1715
1737
  // run-008 build #2's failure mode. Only a SUCCESS status counts.
1716
1738
  const hubspotUploadFailed = !planOnly &&
1717
1739
  hubspotUploadRequested &&
1718
- (!hubspotUploadResult || readStatus(hubspotUploadResult.buildStatus) !== 'SUCCESS');
1740
+ (!hubspotUploadResult || !hubSpotUploadSucceeded(hubspotUploadResult));
1719
1741
  const cloudflareDeployed = !planOnly && cloudflareDeployRequested && Boolean(cloudflareDeployResult);
1720
1742
  // A live worker with only the HubSpot upload failing: not a clean success,
1721
1743
  // but distinct from total failure so callers don't tear down a good worker.
@@ -1831,6 +1853,16 @@ export async function deployCommand({ argv, root, json, }) {
1831
1853
  // automation doesn't treat a serving worker as a total failure), 1 = failure.
1832
1854
  return { exitCode: plan.ok ? 0 : plan.partial ? 20 : 1 };
1833
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
+ }
1834
1866
  function buildDeployPlanSummary(input) {
1835
1867
  const lines = ['', 'Planned changes:'];
1836
1868
  const capCount = input.workers.reduce((c, w) => c + w.capabilities.length, 0);
@@ -2701,6 +2733,14 @@ function defaultHubSpotFieldType(type) {
2701
2733
  return 'text';
2702
2734
  }
2703
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
+ }
2704
2744
  async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, machineSecret, heartbeatEnabled, dryRun, note, }) {
2705
2745
  // Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
2706
2746
  // an unlinked deploy can't leave a Worker live and then error on the
@@ -2733,7 +2773,15 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2733
2773
  const appConfig = await readHsxAppConfig(root);
2734
2774
  const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
2735
2775
  const authUrl = await resolveHubSpotAppAuthUrl(argv, root);
2736
- 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);
2737
2785
  const stored = controlPlaneUrl
2738
2786
  ? await readDeployHubSpotOAuthSecret({
2739
2787
  controlPlaneUrl,
@@ -2770,7 +2818,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2770
2818
  ? {
2771
2819
  authority: createControlPlaneTenantProvisioningAuthority({
2772
2820
  controlPlaneUrl,
2773
- authorizationHeaders: () => controlPlaneAuthHeaders(userId),
2821
+ authorizationHeaders: () => controlPlaneAuthHeaders(userId, undefined, controlPlanePlan.accountId),
2774
2822
  http: hostedHttp,
2775
2823
  validateCompleteResources: (resources) => validateTenantDataPlaneResourcesForActiveAccount(argv, root, resources),
2776
2824
  }),
@@ -3294,9 +3342,15 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3294
3342
  hubSpotAppId: input.hubSpotAppId,
3295
3343
  },
3296
3344
  kind: 'install-kv',
3345
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3346
+ argv: input.argv,
3347
+ root: input.root,
3348
+ kind: 'install-kv',
3349
+ resourceName,
3350
+ }),
3297
3351
  async provisionByName(installKvNamespaceName) {
3298
3352
  // ADR-022: generate and escrow the token key before creating Cloudflare
3299
- // state, but only after long ambiguous names have already failed closed.
3353
+ // state, after long-name recovery ambiguity has already been ruled out.
3300
3354
  const tokenKeySecretValue = randomBytes(32).toString('base64url');
3301
3355
  const tokenKeyCustody = await escrowTokenKey({
3302
3356
  adapter: defaultTokenKeyCustodyAdapter(),
@@ -3332,7 +3386,7 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3332
3386
  if (!namespaceOutput) {
3333
3387
  const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env: cloudflareDeployEnv(input.argv) });
3334
3388
  try {
3335
- const namespaces = JSON.parse(listed.stdout);
3389
+ const namespaces = parseWranglerJsonArray(listed.stdout);
3336
3390
  resourceId = namespaces.find((namespace) => namespace.title === installKvNamespaceName)?.id;
3337
3391
  }
3338
3392
  catch {
@@ -3372,6 +3426,9 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3372
3426
  };
3373
3427
  }
3374
3428
  async function ensureDeployInstallRuntimeBinding(input) {
3429
+ const preserved = await recoverCompletedLinkInstallRuntimeBinding(input);
3430
+ if (preserved)
3431
+ return preserved;
3375
3432
  const existing = await readDeployInstallRuntimeBinding(input);
3376
3433
  if (existing) {
3377
3434
  const credentials = resolveCloudflareCredentials(input.argv);
@@ -3413,9 +3470,15 @@ async function ensureDeployInstallRuntimeBinding(input) {
3413
3470
  hubSpotAppId: input.hubSpotAppId,
3414
3471
  },
3415
3472
  kind: 'install-kv',
3473
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3474
+ argv: input.argv,
3475
+ root: input.root,
3476
+ kind: 'install-kv',
3477
+ resourceName,
3478
+ }),
3416
3479
  async provisionByName(installKvNamespaceName) {
3417
- // ADR-022: escrow precedes Cloudflare mutation, while the ambiguity
3418
- // 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.
3419
3482
  const tokenKeySecretValue = randomBytes(32).toString('base64url');
3420
3483
  const tokenKeyCustody = await escrowTokenKey({
3421
3484
  adapter: defaultTokenKeyCustodyAdapter(),
@@ -3451,7 +3514,7 @@ async function ensureDeployInstallRuntimeBinding(input) {
3451
3514
  if (!namespaceOutput) {
3452
3515
  const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env: cloudflareDeployEnv(input.argv) });
3453
3516
  try {
3454
- const namespaces = JSON.parse(listed.stdout);
3517
+ const namespaces = parseWranglerJsonArray(listed.stdout);
3455
3518
  resourceId = namespaces.find((namespace) => namespace.title === installKvNamespaceName)?.id;
3456
3519
  }
3457
3520
  catch {
@@ -3485,10 +3548,106 @@ async function ensureDeployInstallRuntimeBinding(input) {
3485
3548
  tokenKeyCustody,
3486
3549
  };
3487
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
+ }
3488
3647
  async function readDeployInstallRuntimeBinding(input) {
3489
3648
  const response = await hostedHttp({
3490
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),
3491
- headers: await controlPlaneAuthHeaders(input.userId),
3650
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3492
3651
  });
3493
3652
  if (response.status === 404) {
3494
3653
  return undefined;
@@ -3506,7 +3665,7 @@ async function putDeployInstallRuntimeBinding(input) {
3506
3665
  const response = await hostedHttp({
3507
3666
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/hubspot-apps/${encodeURIComponent(input.hubSpotAppId)}/install-runtime`, input.controlPlaneUrl),
3508
3667
  method: 'PUT',
3509
- headers: await controlPlaneAuthHeaders(input.userId),
3668
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3510
3669
  body: {
3511
3670
  environment: input.environment,
3512
3671
  installKvNamespaceId: input.installKvNamespaceId,
@@ -3532,7 +3691,7 @@ async function ensureHostedProjectRegistration(input) {
3532
3691
  const response = await hostedHttp({
3533
3692
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects`, input.controlPlaneUrl),
3534
3693
  method: 'POST',
3535
- headers: await controlPlaneAuthHeaders(input.userId),
3694
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3536
3695
  body: { projectId: input.projectId, displayName: input.projectId },
3537
3696
  });
3538
3697
  if (response.ok || response.status === 409)
@@ -3551,7 +3710,7 @@ async function requestDeployBillingRuntimeToken(input) {
3551
3710
  const response = await hostedHttp({
3552
3711
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/billing/runtime-token`, input.controlPlaneUrl),
3553
3712
  method: 'POST',
3554
- headers: await controlPlaneAuthHeaders(input.userId),
3713
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3555
3714
  body: {
3556
3715
  environment: input.environment,
3557
3716
  },
@@ -3624,6 +3783,12 @@ async function ensureTenantDataPlane(input) {
3624
3783
  kind: 'tenant-d1',
3625
3784
  ...(exactTenantD1DatabaseId ? { exactResourceId: exactTenantD1DatabaseId } : {}),
3626
3785
  ...(exactTenantD1DatabaseName ? { exactResourceName: exactTenantD1DatabaseName } : {}),
3786
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3787
+ argv: input.argv,
3788
+ root: input.root,
3789
+ kind: 'tenant-d1',
3790
+ resourceName,
3791
+ }),
3627
3792
  validateExactResource: (resourceId) => {
3628
3793
  const credentials = resolveCloudflareCredentials(input.argv);
3629
3794
  if (!credentials.accountId || !credentials.apiToken) {
@@ -3665,6 +3830,12 @@ async function ensureTenantDataPlane(input) {
3665
3830
  kind: 'flags-kv',
3666
3831
  ...(exactFlagsKvNamespaceId ? { exactResourceId: exactFlagsKvNamespaceId } : {}),
3667
3832
  ...(exactFlagsKvNamespaceName ? { exactResourceName: exactFlagsKvNamespaceName } : {}),
3833
+ legacyResourceNameExists: (resourceName) => cloudflareResourceNameExists({
3834
+ argv: input.argv,
3835
+ root: input.root,
3836
+ kind: 'flags-kv',
3837
+ resourceName,
3838
+ }),
3668
3839
  validateExactResource: (resourceId) => {
3669
3840
  const credentials = resolveCloudflareCredentials(input.argv);
3670
3841
  if (!credentials.accountId || !credentials.apiToken) {
@@ -3699,7 +3870,7 @@ async function ensureTenantDataPlane(input) {
3699
3870
  if (!created) {
3700
3871
  const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
3701
3872
  try {
3702
- const namespaces = JSON.parse(listed.stdout);
3873
+ const namespaces = parseWranglerJsonArray(listed.stdout);
3703
3874
  resourceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
3704
3875
  }
3705
3876
  catch {
@@ -3804,13 +3975,29 @@ async function validateCloudflareResourceForDeploy(input) {
3804
3975
  env: cloudflareDeployEnv(input.argv),
3805
3976
  });
3806
3977
  try {
3807
- const rows = JSON.parse(listed.stdout);
3978
+ const rows = parseWranglerJsonArray(listed.stdout);
3808
3979
  return rows.some((row) => row.id === input.resourceId || row.uuid === input.resourceId);
3809
3980
  }
3810
3981
  catch {
3811
3982
  throw new Error(`HSX_E_CLOUDFLARE_RESOURCE_VALIDATION_FAILED: Wrangler returned malformed ${input.kind} list output.`);
3812
3983
  }
3813
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
+ }
3814
4001
  export function renderWranglerConfig(input) {
3815
4002
  const lines = [
3816
4003
  '# Generated by hs-x. Do not edit by hand.',
@@ -3884,7 +4071,7 @@ function tomlString(value) {
3884
4071
  async function readDeployHubSpotOAuthSecret(input) {
3885
4072
  const response = await hostedHttp({
3886
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),
3887
- headers: await controlPlaneAuthHeaders(input.userId),
4074
+ headers: await controlPlaneAuthHeaders(input.userId, undefined, input.accountId),
3888
4075
  });
3889
4076
  if (response.status === 404) {
3890
4077
  return undefined;
@@ -4880,6 +5067,45 @@ async function readPersistedHubSpotAppBinding(root, expectedProjectName) {
4880
5067
  async function readPersistedHubSpotAppId(root, expectedProjectName) {
4881
5068
  return (await readPersistedHubSpotAppBinding(root, expectedProjectName))?.appId;
4882
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
+ }
4883
5109
  async function resolveHubSpotAppAuthUrl(argv, root) {
4884
5110
  const projectName = await resolveHubSpotProjectName(argv, root);
4885
5111
  const persisted = await readPersistedHubSpotAppBinding(root, projectName);
@@ -4997,7 +5223,7 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
4997
5223
  const response = await hostedHttp({
4998
5224
  url: new URL('/v1/deploys/plan', controlPlaneUrl),
4999
5225
  method: 'POST',
5000
- headers: await controlPlaneAuthHeaders(userId),
5226
+ headers: await controlPlaneAuthHeaders(userId, undefined, request.accountId),
5001
5227
  body: request,
5002
5228
  });
5003
5229
  const body = await response.json();
@@ -5008,7 +5234,7 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
5008
5234
  }
5009
5235
  return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
5010
5236
  }
5011
- async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
5237
+ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, workerUrl, revision, }) {
5012
5238
  const localAttestationAuth = revision
5013
5239
  ? { grantSecret: randomBytes(32).toString('base64url'), hubSpotAppId: 1 }
5014
5240
  : undefined;
@@ -5051,6 +5277,7 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
5051
5277
  manifestHash: plan.manifestHash,
5052
5278
  signedManifest: request.manifest,
5053
5279
  signature: `sig_${plan.deployId}`,
5280
+ ...(workerUrl ? { workerUrl } : {}),
5054
5281
  // ADR-021 R03: the real Cloudflare Worker version identity captured
5055
5282
  // back from the confirmed active deployment. Absent only when capture
5056
5283
  // was skipped/failed (best-effort) — then this deploy is history-only.
@@ -5133,13 +5360,14 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
5133
5360
  const response = await hostedHttp({
5134
5361
  url: new URL(`/v1/deploys/${encodeURIComponent(plan.deployId)}/record`, controlPlaneUrl),
5135
5362
  method: 'POST',
5136
- headers: await controlPlaneAuthHeaders(userId),
5363
+ headers: await controlPlaneAuthHeaders(userId, undefined, request.accountId),
5137
5364
  body: {
5138
5365
  accountId: request.accountId,
5139
5366
  projectId: request.projectId,
5140
5367
  manifestHash: plan.manifestHash,
5141
5368
  signedManifest: request.manifest,
5142
5369
  signature: `sig_${plan.deployId}`,
5370
+ ...(workerUrl ? { workerUrl } : {}),
5143
5371
  // ADR-021 R03: the real Cloudflare Worker version identity captured back
5144
5372
  // from the confirmed active deployment. Absent only when capture was
5145
5373
  // skipped/failed (best-effort) — then this deploy is history-only.
@@ -5159,6 +5387,7 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
5159
5387
  }
5160
5388
  const drift = await waitForHealthyControlPlaneDrift({
5161
5389
  controlPlaneUrl,
5390
+ accountId: request.accountId,
5162
5391
  projectId: request.projectId,
5163
5392
  deployId: plan.deployId,
5164
5393
  userId,
@@ -5461,7 +5690,7 @@ function discoverManageSchemaMode(definition) {
5461
5690
  }
5462
5691
  function discoverSourceDefinitions(source) {
5463
5692
  const definitions = new Map();
5464
- 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)) {
5465
5694
  const variableName = match[1];
5466
5695
  const body = match[3] ?? '';
5467
5696
  if (!variableName) {
@@ -5585,6 +5814,9 @@ const deployOptions = {
5585
5814
  message: Options.text('message').pipe(Options.withAlias('m'), Options.optional), // --message / -m
5586
5815
  platformVersion: Options.text('platform-version').pipe(Options.optional), // --platform-version
5587
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
5588
5820
  // ── boolean flags (argv.includes) ──
5589
5821
  plan: Options.boolean('plan').pipe(Options.withDefault(false)), // --plan
5590
5822
  dryRun: Options.boolean('dry-run').pipe(Options.withDefault(false)), // --dry-run
@@ -5651,6 +5883,9 @@ const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler
5651
5883
  push('--message', opts.message);
5652
5884
  push('--platform-version', opts.platformVersion);
5653
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);
5654
5889
  flag('--plan', opts.plan);
5655
5890
  flag('--dry-run', opts.dryRun);
5656
5891
  flag('--build-only', opts.buildOnly);