@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.
- package/README.md +6 -5
- package/dist/account-store.d.ts +9 -0
- package/dist/account-store.d.ts.map +1 -1
- package/dist/account-store.js +11 -0
- package/dist/account-store.js.map +1 -1
- package/dist/cloudflare-account-preflight.d.ts +26 -0
- package/dist/cloudflare-account-preflight.d.ts.map +1 -0
- package/dist/cloudflare-account-preflight.js +64 -0
- package/dist/cloudflare-account-preflight.js.map +1 -0
- package/dist/cloudflare-kv.d.ts +9 -0
- package/dist/cloudflare-kv.d.ts.map +1 -1
- package/dist/cloudflare-kv.js +47 -13
- package/dist/cloudflare-kv.js.map +1 -1
- package/dist/cloudflare-oauth.js +2 -2
- package/dist/cloudflare-oauth.js.map +1 -1
- package/dist/cloudflare-pointer.d.ts.map +1 -1
- package/dist/cloudflare-pointer.js +9 -3
- package/dist/cloudflare-pointer.js.map +1 -1
- package/dist/cloudflare-scoped-resource.d.ts +23 -2
- package/dist/cloudflare-scoped-resource.d.ts.map +1 -1
- package/dist/cloudflare-scoped-resource.js +56 -9
- package/dist/cloudflare-scoped-resource.js.map +1 -1
- package/dist/commands/api.d.ts.map +1 -1
- package/dist/commands/api.js +1 -1
- package/dist/commands/api.js.map +1 -1
- package/dist/commands/connect.js +3 -3
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +2 -2
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +35 -22
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy-promote.js +3 -3
- package/dist/commands/deploy-promote.js.map +1 -1
- package/dist/commands/deploy.d.ts +17 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +485 -47
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.js +7 -9
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +41 -7
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/rollback.d.ts +23 -0
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +60 -13
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +4 -4
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +24 -9
- package/dist/commands/sync.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/control-plane-fetch.d.ts +3 -1
- package/dist/control-plane-fetch.d.ts.map +1 -1
- package/dist/control-plane-fetch.js +9 -7
- package/dist/control-plane-fetch.js.map +1 -1
- package/dist/control-plane-tenant-provisioning.d.ts +2 -0
- package/dist/control-plane-tenant-provisioning.d.ts.map +1 -1
- package/dist/control-plane-tenant-provisioning.js +15 -2
- package/dist/control-plane-tenant-provisioning.js.map +1 -1
- package/dist/deploy-targets.d.ts +0 -1
- package/dist/deploy-targets.d.ts.map +1 -1
- package/dist/deploy-targets.js +2 -5
- package/dist/deploy-targets.js.map +1 -1
- package/dist/dev/invoke.d.ts +7 -0
- package/dist/dev/invoke.d.ts.map +1 -1
- package/dist/dev/invoke.js +18 -5
- package/dist/dev/invoke.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +44 -8
- package/dist/errors-registry.js.map +1 -1
- package/dist/hubspot-developer-client.js +2 -2
- package/dist/hubspot-developer-client.js.map +1 -1
- package/dist/services/cloudflare-kv.d.ts.map +1 -1
- package/dist/services/cloudflare-kv.js +22 -4
- package/dist/services/cloudflare-kv.js.map +1 -1
- package/dist/services/control-plane.d.ts +3 -1
- package/dist/services/control-plane.d.ts.map +1 -1
- package/dist/services/control-plane.js +8 -6
- package/dist/services/control-plane.js.map +1 -1
- package/dist/services/hubspot-dev-client.js +1 -1
- package/dist/services/hubspot-dev-client.js.map +1 -1
- package/dist/tenant-state.d.ts +28 -0
- package/dist/tenant-state.d.ts.map +1 -1
- package/dist/tenant-state.js +109 -42
- package/dist/tenant-state.js.map +1 -1
- package/package.json +8 -8
package/dist/commands/deploy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 ||
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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);
|