@hs-x/cli 0.4.5 → 0.4.6

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 (34) hide show
  1. package/dist/cloudflare-account-preflight.d.ts +26 -0
  2. package/dist/cloudflare-account-preflight.d.ts.map +1 -0
  3. package/dist/cloudflare-account-preflight.js +64 -0
  4. package/dist/cloudflare-account-preflight.js.map +1 -0
  5. package/dist/cloudflare-kv.d.ts.map +1 -1
  6. package/dist/cloudflare-kv.js +31 -6
  7. package/dist/cloudflare-kv.js.map +1 -1
  8. package/dist/cloudflare-pointer.d.ts.map +1 -1
  9. package/dist/cloudflare-pointer.js +9 -3
  10. package/dist/cloudflare-pointer.js.map +1 -1
  11. package/dist/cloudflare-scoped-resource.d.ts +6 -0
  12. package/dist/cloudflare-scoped-resource.d.ts.map +1 -1
  13. package/dist/cloudflare-scoped-resource.js +13 -6
  14. package/dist/cloudflare-scoped-resource.js.map +1 -1
  15. package/dist/commands/deploy.d.ts.map +1 -1
  16. package/dist/commands/deploy.js +226 -23
  17. package/dist/commands/deploy.js.map +1 -1
  18. package/dist/constants.d.ts +1 -1
  19. package/dist/constants.js +1 -1
  20. package/dist/control-plane-tenant-provisioning.d.ts +2 -0
  21. package/dist/control-plane-tenant-provisioning.d.ts.map +1 -1
  22. package/dist/control-plane-tenant-provisioning.js +15 -2
  23. package/dist/control-plane-tenant-provisioning.js.map +1 -1
  24. package/dist/errors-registry.d.ts.map +1 -1
  25. package/dist/errors-registry.js +36 -0
  26. package/dist/errors-registry.js.map +1 -1
  27. package/dist/services/cloudflare-kv.d.ts.map +1 -1
  28. package/dist/services/cloudflare-kv.js +22 -4
  29. package/dist/services/cloudflare-kv.js.map +1 -1
  30. package/dist/tenant-state.d.ts +18 -0
  31. package/dist/tenant-state.d.ts.map +1 -1
  32. package/dist/tenant-state.js +72 -29
  33. package/dist/tenant-state.js.map +1 -1
  34. package/package.json +8 -8
@@ -15,6 +15,7 @@ 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 { classifyCloudflareCommandFailure, preflightCloudflareAnalyticsEngine, validateCloudflareResourceForAccount, } from '../cloudflare-account-preflight.js';
18
19
  import { resolveCloudflareCredentials } from '../cloudflare-kv.js';
19
20
  import { loadCloudflarePointer } from '../cloudflare-pointer.js';
20
21
  import { resolveOrProvisionCloudflareScopedResource } from '../cloudflare-scoped-resource.js';
@@ -475,6 +476,28 @@ async function hydrateCloudflareTokenFromStoredOAuth(input) {
475
476
  return false;
476
477
  }
477
478
  }
479
+ /**
480
+ * Prefer the account-scoped Cloudflare connection held by the control plane.
481
+ * This keeps an explicit `--account-id` deploy from being redirected by a
482
+ * stale local `.hs-x/cloudflare.json` pointer or an unrelated stored OAuth
483
+ * connection on a machine that works across several HS-X accounts.
484
+ */
485
+ async function hydrateCloudflareTokenFromControlPlane(input) {
486
+ // A command-line credential is an intentional one-run override. Ambient
487
+ // env credentials are not: on a multi-account machine they commonly point
488
+ // at a different Cloudflare account, so a linked deploy must replace them
489
+ // with the selected HS-X account's leased credential pair.
490
+ if (resolveFlag(input.argv, '--cloudflare-api-token') || resolveFlag(input.argv, '--api-token')) {
491
+ return false;
492
+ }
493
+ const { leaseCloudflareViaSession } = await import('../tenant-state.js');
494
+ const lease = await leaseCloudflareViaSession(input.argv, input.root);
495
+ if (!lease)
496
+ return false;
497
+ process.env.CLOUDFLARE_API_TOKEN = lease.apiToken;
498
+ process.env.CLOUDFLARE_ACCOUNT_ID = lease.cloudflareAccountId;
499
+ return true;
500
+ }
478
501
  async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, deployId, userId, timeoutMs, workerUrl, }) {
479
502
  const startedAt = Date.now();
480
503
  let lastState = 'unknown';
@@ -1417,10 +1440,8 @@ export async function deployCommand({ argv, root, json, }) {
1417
1440
  // (`--yes`) preflight succeed instead of demanding a token connect says
1418
1441
  // doesn't exist (cold-stranger run 2026-07-22-001, finding #1).
1419
1442
  if (cloudflareDeployRequested && !planOnly && !cloudflareDryRun) {
1420
- const hydrated = await hydrateCloudflareTokenFromStoredOAuth({
1421
- argv,
1422
- root,
1423
- });
1443
+ const hydrated = (controlPlaneUrl ? await hydrateCloudflareTokenFromControlPlane({ argv, root }) : false) ||
1444
+ (await hydrateCloudflareTokenFromStoredOAuth({ argv, root }));
1424
1445
  if (hydrated && echo) {
1425
1446
  echo.info('Using the Cloudflare connection from `hs-x connect cloudflare`.');
1426
1447
  }
@@ -2687,6 +2708,26 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2687
2708
  if (!controlPlaneUrl && !dryRun) {
2688
2709
  await preflightTenantStateCredentials({ argv, root });
2689
2710
  }
2711
+ if (controlPlaneUrl && !dryRun) {
2712
+ const credentials = resolveCloudflareCredentials(argv);
2713
+ if (!credentials.accountId || !credentials.apiToken) {
2714
+ throw new Error('Linked Cloudflare deploy requires an account id and API token from the connected credential lease.');
2715
+ }
2716
+ // Read-only and intentionally first: a fresh Cloudflare account otherwise
2717
+ // fails only at Worker upload, after KV/D1 provisioning has already run.
2718
+ await preflightCloudflareAnalyticsEngine({
2719
+ accountId: credentials.accountId,
2720
+ apiToken: credentials.apiToken,
2721
+ });
2722
+ // Tenant provisioning authorizes only registered projects. Register the
2723
+ // plan scope before any Worker enters that authority, not after recordDeploy.
2724
+ await ensureHostedProjectRegistration({
2725
+ controlPlaneUrl,
2726
+ userId,
2727
+ accountId: controlPlanePlan.accountId,
2728
+ projectId: controlPlanePlan.projectId,
2729
+ });
2730
+ }
2690
2731
  const hasCardBackends = workers.some((worker) => worker.capabilities.some((capability) => capability.kind === 'card-backend'));
2691
2732
  if (hasCardBackends && !dryRun) {
2692
2733
  const appConfig = await readHsxAppConfig(root);
@@ -2731,6 +2772,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
2731
2772
  controlPlaneUrl,
2732
2773
  authorizationHeaders: () => controlPlaneAuthHeaders(userId),
2733
2774
  http: hostedHttp,
2775
+ validateCompleteResources: (resources) => validateTenantDataPlaneResourcesForActiveAccount(argv, root, resources),
2734
2776
  }),
2735
2777
  }
2736
2778
  : {});
@@ -2952,7 +2994,13 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowC
2952
2994
  `${resolvedHubSpotClientSecret ? '' : ' — no client secret; card verification will fail closed'}\n`);
2953
2995
  }
2954
2996
  const hubSpotScopesForRuntime = await readHsxAppScopesForDeploy(root);
2955
- const billingRuntimeToken = controlPlaneUrl && appConfig.billing && !dryRun
2997
+ // The same scoped runtime bearer authenticates both hosted billing and the
2998
+ // redacted install-lifecycle telemetry emitted by linked OAuth Workers.
2999
+ // Provision it whenever either consumer is present. Gating this solely on
3000
+ // `appConfig.billing` leaves ordinary OAuth apps with generated telemetry
3001
+ // code but no HSX_RUNTIME_TOKEN binding, making every emit a silent no-op.
3002
+ const needsRuntimeControlPlaneToken = appConfig.billing !== undefined || appConfig.auth === 'oauth';
3003
+ const billingRuntimeToken = controlPlaneUrl && needsRuntimeControlPlaneToken && !dryRun
2956
3004
  ? await requestDeployBillingRuntimeToken({
2957
3005
  controlPlaneUrl,
2958
3006
  userId,
@@ -3273,8 +3321,24 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3273
3321
  ], {
3274
3322
  cwd: input.root,
3275
3323
  env: cloudflareDeployEnv(input.argv),
3324
+ }).catch((error) => {
3325
+ if (!/already exists/i.test(String(error)))
3326
+ throw error;
3327
+ return undefined;
3276
3328
  });
3277
- const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
3329
+ let resourceId = namespaceOutput
3330
+ ? extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`)
3331
+ : undefined;
3332
+ if (!namespaceOutput) {
3333
+ const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env: cloudflareDeployEnv(input.argv) });
3334
+ try {
3335
+ const namespaces = JSON.parse(listed.stdout);
3336
+ resourceId = namespaces.find((namespace) => namespace.title === installKvNamespaceName)?.id;
3337
+ }
3338
+ catch {
3339
+ resourceId = undefined;
3340
+ }
3341
+ }
3278
3342
  if (!resourceId) {
3279
3343
  throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3280
3344
  }
@@ -3310,21 +3374,33 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
3310
3374
  async function ensureDeployInstallRuntimeBinding(input) {
3311
3375
  const existing = await readDeployInstallRuntimeBinding(input);
3312
3376
  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
- };
3377
+ const credentials = resolveCloudflareCredentials(input.argv);
3378
+ if (!credentials.accountId || !credentials.apiToken) {
3379
+ throw new Error('Could not validate the stored install KV without Cloudflare credentials.');
3380
+ }
3381
+ const valid = await validateCloudflareResourceForDeploy({
3382
+ argv: input.argv,
3383
+ root: input.root,
3384
+ kind: 'install-kv',
3385
+ resourceId: existing.installKvNamespaceId,
3386
+ });
3387
+ if (valid) {
3388
+ // Control-plane metadata carries no key custody (and never key bytes) —
3389
+ // on a machine that never generated the key this is worker-secret-only.
3390
+ return {
3391
+ installKvNamespaceId: existing.installKvNamespaceId,
3392
+ installKvNamespaceName: existing.installKvNamespaceName,
3393
+ tokenKeySecretName: existing.tokenKeySecretName,
3394
+ ...(existing.tenantD1DatabaseId ? { tenantD1DatabaseId: existing.tenantD1DatabaseId } : {}),
3395
+ ...(existing.tenantD1DatabaseName
3396
+ ? { tenantD1DatabaseName: existing.tenantD1DatabaseName }
3397
+ : {}),
3398
+ ...(existing.flagsKvNamespaceId ? { flagsKvNamespaceId: existing.flagsKvNamespaceId } : {}),
3399
+ ...(existing.flagsKvNamespaceName
3400
+ ? { flagsKvNamespaceName: existing.flagsKvNamespaceName }
3401
+ : {}),
3402
+ };
3403
+ }
3328
3404
  }
3329
3405
  if (input.dryRun) {
3330
3406
  return undefined;
@@ -3364,8 +3440,24 @@ async function ensureDeployInstallRuntimeBinding(input) {
3364
3440
  ], {
3365
3441
  cwd: input.root,
3366
3442
  env: cloudflareDeployEnv(input.argv),
3443
+ }).catch((error) => {
3444
+ if (!/already exists/i.test(String(error)))
3445
+ throw error;
3446
+ return undefined;
3367
3447
  });
3368
- const resourceId = extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`);
3448
+ let resourceId = namespaceOutput
3449
+ ? extractKvNamespaceId(`${namespaceOutput.stdout}\n${namespaceOutput.stderr}`)
3450
+ : undefined;
3451
+ if (!namespaceOutput) {
3452
+ const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env: cloudflareDeployEnv(input.argv) });
3453
+ try {
3454
+ const namespaces = JSON.parse(listed.stdout);
3455
+ resourceId = namespaces.find((namespace) => namespace.title === installKvNamespaceName)?.id;
3456
+ }
3457
+ catch {
3458
+ resourceId = undefined;
3459
+ }
3460
+ }
3369
3461
  if (!resourceId) {
3370
3462
  throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
3371
3463
  }
@@ -3436,6 +3528,25 @@ async function putDeployInstallRuntimeBinding(input) {
3436
3528
  throw new Error(`Could not store scoped install runtime binding: ${message}`);
3437
3529
  }
3438
3530
  }
3531
+ async function ensureHostedProjectRegistration(input) {
3532
+ const response = await hostedHttp({
3533
+ url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects`, input.controlPlaneUrl),
3534
+ method: 'POST',
3535
+ headers: await controlPlaneAuthHeaders(input.userId),
3536
+ body: { projectId: input.projectId, displayName: input.projectId },
3537
+ });
3538
+ if (response.ok || response.status === 409)
3539
+ return;
3540
+ // The in-process legacy control-plane test adapter predates the extracted
3541
+ // project router. Production exposes this route; tolerate only absence.
3542
+ if (response.status === 404 || response.status === 405)
3543
+ return;
3544
+ const body = await response.json().catch(() => undefined);
3545
+ const message = isRecord(body) && typeof body.message === 'string'
3546
+ ? body.message
3547
+ : `Control plane returned ${response.status}.`;
3548
+ throw new Error(`Could not register project before tenant provisioning: ${message}`);
3549
+ }
3439
3550
  async function requestDeployBillingRuntimeToken(input) {
3440
3551
  const response = await hostedHttp({
3441
3552
  url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/projects/${encodeURIComponent(input.projectId)}/billing/runtime-token`, input.controlPlaneUrl),
@@ -3499,6 +3610,10 @@ async function ensureTenantDataPlane(input) {
3499
3610
  hubSpotAppId: input.hubSpotAppId,
3500
3611
  });
3501
3612
  const env = cloudflareDeployEnv(input.argv);
3613
+ const cloudflareCredentials = resolveCloudflareCredentials(input.argv);
3614
+ if (!cloudflareCredentials.accountId || !cloudflareCredentials.apiToken) {
3615
+ throw new Error('Tenant data-plane provisioning requires Cloudflare credentials.');
3616
+ }
3502
3617
  // Exact local state wins; exact control-plane refs recover a linked deploy
3503
3618
  // when its tenant-state binding was lost. Name-only create/adopt remains
3504
3619
  // available for short legacy-compatible names only.
@@ -3509,6 +3624,18 @@ async function ensureTenantDataPlane(input) {
3509
3624
  kind: 'tenant-d1',
3510
3625
  ...(exactTenantD1DatabaseId ? { exactResourceId: exactTenantD1DatabaseId } : {}),
3511
3626
  ...(exactTenantD1DatabaseName ? { exactResourceName: exactTenantD1DatabaseName } : {}),
3627
+ validateExactResource: (resourceId) => {
3628
+ const credentials = resolveCloudflareCredentials(input.argv);
3629
+ if (!credentials.accountId || !credentials.apiToken) {
3630
+ throw new Error('Could not validate the stored tenant D1 without Cloudflare credentials.');
3631
+ }
3632
+ return validateCloudflareResourceForDeploy({
3633
+ argv: input.argv,
3634
+ root: input.root,
3635
+ kind: 'tenant-d1',
3636
+ resourceId,
3637
+ });
3638
+ },
3512
3639
  async provisionByName(tenantD1DatabaseName) {
3513
3640
  const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
3514
3641
  if (!/already exists/i.test(String(error)))
@@ -3538,6 +3665,18 @@ async function ensureTenantDataPlane(input) {
3538
3665
  kind: 'flags-kv',
3539
3666
  ...(exactFlagsKvNamespaceId ? { exactResourceId: exactFlagsKvNamespaceId } : {}),
3540
3667
  ...(exactFlagsKvNamespaceName ? { exactResourceName: exactFlagsKvNamespaceName } : {}),
3668
+ validateExactResource: (resourceId) => {
3669
+ const credentials = resolveCloudflareCredentials(input.argv);
3670
+ if (!credentials.accountId || !credentials.apiToken) {
3671
+ throw new Error('Could not validate the stored flags KV without Cloudflare credentials.');
3672
+ }
3673
+ return validateCloudflareResourceForDeploy({
3674
+ argv: input.argv,
3675
+ root: input.root,
3676
+ kind: 'flags-kv',
3677
+ resourceId,
3678
+ });
3679
+ },
3541
3680
  async provisionByName(flagsKvNamespaceName) {
3542
3681
  const created = await runCloudflareCommand([
3543
3682
  'bun',
@@ -3607,6 +3746,7 @@ async function ensureTenantDataPlane(input) {
3607
3746
  updatedAt: new Date().toISOString(),
3608
3747
  }));
3609
3748
  return {
3749
+ cloudflareAccountId: cloudflareCredentials.accountId,
3610
3750
  tenantD1DatabaseId,
3611
3751
  tenantD1DatabaseName,
3612
3752
  flagsKvNamespaceId,
@@ -3615,6 +3755,62 @@ async function ensureTenantDataPlane(input) {
3615
3755
  syncGrantSecretValue,
3616
3756
  };
3617
3757
  }
3758
+ async function validateTenantDataPlaneResourcesForActiveAccount(argv, root, resources) {
3759
+ const credentials = resolveCloudflareCredentials(argv);
3760
+ if (!credentials.accountId || !credentials.apiToken) {
3761
+ throw new Error('Could not validate tenant resources without Cloudflare credentials.');
3762
+ }
3763
+ if (resources.cloudflareAccountId && resources.cloudflareAccountId !== credentials.accountId) {
3764
+ return false;
3765
+ }
3766
+ const [tenantD1Valid, flagsKvValid] = await Promise.all([
3767
+ validateCloudflareResourceForDeploy({
3768
+ argv,
3769
+ root,
3770
+ kind: 'tenant-d1',
3771
+ resourceId: resources.tenantD1DatabaseId,
3772
+ }),
3773
+ validateCloudflareResourceForDeploy({
3774
+ argv,
3775
+ root,
3776
+ kind: 'flags-kv',
3777
+ resourceId: resources.flagsKvNamespaceId,
3778
+ }),
3779
+ ]);
3780
+ return tenantD1Valid && flagsKvValid;
3781
+ }
3782
+ async function validateCloudflareResourceForDeploy(input) {
3783
+ const credentials = resolveCloudflareCredentials(input.argv);
3784
+ if (!credentials.accountId || !credentials.apiToken) {
3785
+ throw new Error('Could not validate Cloudflare resources without connected credentials.');
3786
+ }
3787
+ try {
3788
+ return await validateCloudflareResourceForAccount({
3789
+ accountId: credentials.accountId,
3790
+ apiToken: credentials.apiToken,
3791
+ kind: input.kind,
3792
+ resourceId: input.resourceId,
3793
+ });
3794
+ }
3795
+ catch (error) {
3796
+ if (!/\((?:401|403)\)/.test(String(error)))
3797
+ throw error;
3798
+ }
3799
+ const command = input.kind === 'tenant-d1'
3800
+ ? ['bun', 'x', 'wrangler', 'd1', 'list', '--json']
3801
+ : ['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'];
3802
+ const listed = await runCloudflareCommand(command, {
3803
+ cwd: input.root,
3804
+ env: cloudflareDeployEnv(input.argv),
3805
+ });
3806
+ try {
3807
+ const rows = JSON.parse(listed.stdout);
3808
+ return rows.some((row) => row.id === input.resourceId || row.uuid === input.resourceId);
3809
+ }
3810
+ catch {
3811
+ throw new Error(`HSX_E_CLOUDFLARE_RESOURCE_VALIDATION_FAILED: Wrangler returned malformed ${input.kind} list output.`);
3812
+ }
3813
+ }
3618
3814
  export function renderWranglerConfig(input) {
3619
3815
  const lines = [
3620
3816
  '# Generated by hs-x. Do not edit by hand.',
@@ -4279,7 +4475,14 @@ function runCloudflareCommand(command, options) {
4279
4475
  resolvePromise({ stdout, stderr });
4280
4476
  }
4281
4477
  else {
4282
- rejectPromise(new Error(`Cloudflare deploy failed with exit code ${code ?? 'unknown'}.\n${stderr || stdout}`));
4478
+ rejectPromise(classifyCloudflareCommandFailure({
4479
+ ...(options.env.CLOUDFLARE_ACCOUNT_ID
4480
+ ? { accountId: options.env.CLOUDFLARE_ACCOUNT_ID }
4481
+ : {}),
4482
+ stdout,
4483
+ stderr,
4484
+ exitCode: code,
4485
+ }));
4283
4486
  }
4284
4487
  });
4285
4488
  })).pipe(Effect.withSpan('cli.deploy.cloudflare_command', {