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