@hs-x/cli 0.2.6 → 0.3.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/LICENSE +202 -0
- package/README.md +25 -8
- package/dist/audit-interactive.d.ts +29 -0
- package/dist/audit-interactive.d.ts.map +1 -0
- package/dist/audit-interactive.js +156 -0
- package/dist/audit-interactive.js.map +1 -0
- package/dist/bin/hs-x.js.map +1 -1
- package/dist/checkpoint-interactive.d.ts +24 -0
- package/dist/checkpoint-interactive.d.ts.map +1 -0
- package/dist/checkpoint-interactive.js +65 -0
- package/dist/checkpoint-interactive.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +266 -39
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kit.d.ts +1 -1
- package/dist/cli/kit.d.ts.map +1 -1
- package/dist/cli/kit.js +19 -3
- package/dist/cli/kit.js.map +1 -1
- package/dist/cloudflare-auth.d.ts +8 -0
- package/dist/cloudflare-auth.d.ts.map +1 -1
- package/dist/cloudflare-auth.js +12 -2
- package/dist/cloudflare-auth.js.map +1 -1
- package/dist/cloudflare-oauth-store.d.ts +9 -1
- package/dist/cloudflare-oauth-store.d.ts.map +1 -1
- package/dist/cloudflare-oauth-store.js +23 -4
- package/dist/cloudflare-oauth-store.js.map +1 -1
- package/dist/cloudflare-oauth.d.ts +1 -0
- package/dist/cloudflare-oauth.d.ts.map +1 -1
- package/dist/cloudflare-oauth.js +6 -2
- package/dist/cloudflare-oauth.js.map +1 -1
- package/dist/cloudflare-worker-versions.d.ts +184 -0
- package/dist/cloudflare-worker-versions.d.ts.map +1 -0
- package/dist/cloudflare-worker-versions.js +360 -0
- package/dist/cloudflare-worker-versions.js.map +1 -0
- package/dist/commands/account.d.ts +28 -13
- package/dist/commands/account.d.ts.map +1 -1
- package/dist/commands/account.js +219 -69
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/api.d.ts +1 -1
- package/dist/commands/api.d.ts.map +1 -1
- package/dist/commands/api.js +6 -10
- package/dist/commands/api.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +13 -7
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +239 -125
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +72 -11
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +1035 -58
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy-promote.d.ts +25 -3
- package/dist/commands/deploy-promote.d.ts.map +1 -1
- package/dist/commands/deploy-promote.js +314 -38
- package/dist/commands/deploy-promote.js.map +1 -1
- package/dist/commands/deploy.d.ts +67 -4
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +1333 -255
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts +5 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +502 -107
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +15 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +131 -20
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/flags.d.ts +108 -6
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/flags.js +996 -75
- package/dist/commands/flags.js.map +1 -1
- package/dist/commands/help-command.d.ts +14 -0
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +144 -44
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/history.js +14 -10
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +69 -12
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +2 -3
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +201 -101
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/login.d.ts +2 -2
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +22 -5
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/migrate.d.ts +23 -3
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +500 -73
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/rollback.d.ts +42 -2
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +422 -58
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/secrets.d.ts +76 -3
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +328 -41
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/status.d.ts +13 -2
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +154 -17
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unlink.d.ts +2 -2
- package/dist/commands/unlink.d.ts.map +1 -1
- package/dist/commands/unlink.js +2 -2
- package/dist/commands/unlink.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +4 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/validate.d.ts +12 -3
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +70 -8
- 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-fetch.d.ts.map +1 -1
- package/dist/control-plane-fetch.js +5 -4
- package/dist/control-plane-fetch.js.map +1 -1
- package/dist/deploy-revision-capture.d.ts +48 -0
- package/dist/deploy-revision-capture.d.ts.map +1 -0
- package/dist/deploy-revision-capture.js +125 -0
- package/dist/deploy-revision-capture.js.map +1 -0
- package/dist/dev/compat-shim.d.ts.map +1 -1
- package/dist/dev/compat-shim.js +10 -35
- package/dist/dev/compat-shim.js.map +1 -1
- package/dist/dev/event-bus.d.ts +5 -2
- package/dist/dev/event-bus.d.ts.map +1 -1
- package/dist/dev/event-bus.js +4 -1
- package/dist/dev/event-bus.js.map +1 -1
- package/dist/dev/hubspot-logs-poller.d.ts +35 -0
- package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
- package/dist/dev/hubspot-logs-poller.js +94 -0
- package/dist/dev/hubspot-logs-poller.js.map +1 -0
- package/dist/dev/invoke.d.ts +9 -1
- package/dist/dev/invoke.d.ts.map +1 -1
- package/dist/dev/invoke.js +69 -20
- package/dist/dev/invoke.js.map +1 -1
- package/dist/dev/keyboard.d.ts +31 -0
- package/dist/dev/keyboard.d.ts.map +1 -0
- package/dist/dev/keyboard.js +143 -0
- package/dist/dev/keyboard.js.map +1 -0
- package/dist/dev/log-server.d.ts +7 -0
- package/dist/dev/log-server.d.ts.map +1 -1
- package/dist/dev/log-server.js +68 -31
- package/dist/dev/log-server.js.map +1 -1
- package/dist/dev/migrated-card.d.ts +17 -0
- package/dist/dev/migrated-card.d.ts.map +1 -0
- package/dist/dev/migrated-card.js +114 -0
- package/dist/dev/migrated-card.js.map +1 -0
- package/dist/dev/stream-filter.d.ts +40 -0
- package/dist/dev/stream-filter.d.ts.map +1 -0
- package/dist/dev/stream-filter.js +93 -0
- package/dist/dev/stream-filter.js.map +1 -0
- package/dist/dev/stream-renderer.d.ts +34 -0
- package/dist/dev/stream-renderer.d.ts.map +1 -1
- package/dist/dev/stream-renderer.js +222 -8
- package/dist/dev/stream-renderer.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +280 -36
- package/dist/errors-registry.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +8 -0
- package/dist/help.js.map +1 -1
- package/dist/hubspot-app-logs.d.ts +76 -0
- package/dist/hubspot-app-logs.d.ts.map +1 -0
- package/dist/hubspot-app-logs.js +323 -0
- package/dist/hubspot-app-logs.js.map +1 -0
- package/dist/hubspot-auth.d.ts +8 -2
- package/dist/hubspot-auth.d.ts.map +1 -1
- package/dist/hubspot-auth.js +36 -12
- package/dist/hubspot-auth.js.map +1 -1
- package/dist/hubspot-developer-client.js +119 -11
- package/dist/hubspot-developer-client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init/templates.d.ts +10 -0
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +157 -14
- package/dist/init/templates.js.map +1 -1
- package/dist/link-migration-journal.d.ts +131 -0
- package/dist/link-migration-journal.d.ts.map +1 -0
- package/dist/link-migration-journal.js +395 -0
- package/dist/link-migration-journal.js.map +1 -0
- package/dist/link-preflight.d.ts +78 -0
- package/dist/link-preflight.d.ts.map +1 -0
- package/dist/link-preflight.js +231 -0
- package/dist/link-preflight.js.map +1 -0
- package/dist/local-project-picker.d.ts +34 -0
- package/dist/local-project-picker.d.ts.map +1 -0
- package/dist/local-project-picker.js +121 -0
- package/dist/local-project-picker.js.map +1 -0
- package/dist/logs-interactive.d.ts +37 -0
- package/dist/logs-interactive.d.ts.map +1 -0
- package/dist/logs-interactive.js +110 -0
- package/dist/logs-interactive.js.map +1 -0
- package/dist/output-context.d.ts +13 -0
- package/dist/output-context.d.ts.map +1 -0
- package/dist/output-context.js +46 -0
- package/dist/output-context.js.map +1 -0
- package/dist/project-context.d.ts +55 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +115 -0
- package/dist/project-context.js.map +1 -0
- package/dist/project-picker.d.ts +65 -0
- package/dist/project-picker.d.ts.map +1 -0
- package/dist/project-picker.js +227 -0
- package/dist/project-picker.js.map +1 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +81 -13
- package/dist/prompt.js.map +1 -1
- package/dist/reporter/human.d.ts +10 -2
- package/dist/reporter/human.d.ts.map +1 -1
- package/dist/reporter/human.js +82 -14
- package/dist/reporter/human.js.map +1 -1
- package/dist/reporter/index.d.ts.map +1 -1
- package/dist/reporter/index.js +7 -5
- package/dist/reporter/index.js.map +1 -1
- package/dist/reporter/json.d.ts +2 -1
- package/dist/reporter/json.d.ts.map +1 -1
- package/dist/reporter/json.js +10 -7
- package/dist/reporter/json.js.map +1 -1
- package/dist/reporter/style.d.ts +19 -0
- package/dist/reporter/style.d.ts.map +1 -1
- package/dist/reporter/style.js +126 -6
- package/dist/reporter/style.js.map +1 -1
- package/dist/reporter/types.d.ts +34 -4
- package/dist/reporter/types.d.ts.map +1 -1
- package/dist/reporter/types.js.map +1 -1
- package/dist/rollback-traffic.d.ts +85 -0
- package/dist/rollback-traffic.d.ts.map +1 -0
- package/dist/rollback-traffic.js +204 -0
- package/dist/rollback-traffic.js.map +1 -0
- package/dist/services/account-store.d.ts +3 -1
- package/dist/services/account-store.d.ts.map +1 -1
- package/dist/services/account-store.js +2 -1
- package/dist/services/account-store.js.map +1 -1
- package/dist/services/app-paths.d.ts.map +1 -1
- package/dist/services/app-paths.js +3 -1
- package/dist/services/app-paths.js.map +1 -1
- package/dist/services/cloudflare-auth.d.ts +1 -1
- package/dist/services/cloudflare-auth.d.ts.map +1 -1
- package/dist/services/cloudflare-auth.js +2 -2
- package/dist/services/cloudflare-auth.js.map +1 -1
- package/dist/services/cloudflare-kv.d.ts.map +1 -1
- package/dist/services/cloudflare-kv.js.map +1 -1
- package/dist/services/command-history.d.ts.map +1 -1
- package/dist/services/command-history.js.map +1 -1
- package/dist/services/config.d.ts.map +1 -1
- package/dist/services/config.js +1 -1
- package/dist/services/config.js.map +1 -1
- package/dist/services/control-plane.d.ts.map +1 -1
- package/dist/services/control-plane.js +3 -3
- package/dist/services/control-plane.js.map +1 -1
- package/dist/services/env-loader.d.ts.map +1 -1
- package/dist/services/env-loader.js +1 -3
- package/dist/services/env-loader.js.map +1 -1
- package/dist/services/http.d.ts.map +1 -1
- package/dist/services/http.js.map +1 -1
- package/dist/services/hubspot-dev-client.d.ts.map +1 -1
- package/dist/services/hubspot-dev-client.js +30 -0
- package/dist/services/hubspot-dev-client.js.map +1 -1
- package/dist/services/live.d.ts +2 -2
- package/dist/services/live.d.ts.map +1 -1
- package/dist/services/live.js +1 -1
- package/dist/services/live.js.map +1 -1
- package/dist/services/machine-id.d.ts.map +1 -1
- package/dist/services/machine-id.js.map +1 -1
- package/dist/services/reporter.d.ts +8 -5
- package/dist/services/reporter.d.ts.map +1 -1
- package/dist/services/reporter.js +6 -4
- package/dist/services/reporter.js.map +1 -1
- package/dist/services/tenant-state.d.ts.map +1 -1
- package/dist/services/tenant-state.js.map +1 -1
- package/dist/state-store.d.ts +4 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +15 -0
- package/dist/state-store.js.map +1 -1
- package/dist/status-drilldown.d.ts +24 -0
- package/dist/status-drilldown.d.ts.map +1 -0
- package/dist/status-drilldown.js +50 -0
- package/dist/status-drilldown.js.map +1 -0
- package/dist/tenant-state.d.ts.map +1 -1
- package/dist/tenant-state.js +3 -2
- package/dist/tenant-state.js.map +1 -1
- package/dist/token-key-custody.d.ts +91 -0
- package/dist/token-key-custody.d.ts.map +1 -0
- package/dist/token-key-custody.js +375 -0
- package/dist/token-key-custody.js.map +1 -0
- package/package.json +13 -14
package/dist/commands/deploy.js
CHANGED
|
@@ -5,30 +5,34 @@ import { createServer } from 'node:http';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
|
-
import { cloudflareResourceName, generateHubSpotRuntimeProject, generateProjectArtifacts, planPortalSchemaManagement, renderAlchemyProgram, renderCloudflareWorkerEntrypoint, } from '@hs-x/codegen';
|
|
9
|
-
import { loadCreateControlPlane } from '../control-plane-loader.js';
|
|
10
|
-
import { HttpClientRequest, Schema, schemas } from '@hs-x/types';
|
|
11
|
-
import { validateProject } from '@hs-x/validator';
|
|
12
8
|
import * as Command from '@effect/cli/Command';
|
|
13
9
|
import * as Options from '@effect/cli/Options';
|
|
10
|
+
import { cloudflareResourceName, generateHubSpotRuntimeProject, generateProjectArtifacts, planPortalSchemaManagement, planWorkerCronTriggers, renderAlchemyProgram, renderCloudflareWorkerEntrypoint, stampMigratedRunServerlessShimOrigin, } from '@hs-x/codegen';
|
|
11
|
+
import { HttpClientRequest, Schema, schemas } from '@hs-x/types';
|
|
12
|
+
import { validateProject } from '@hs-x/validator';
|
|
14
13
|
import { Effect, Option } from 'effect';
|
|
14
|
+
import { isLinked } from '../account-store.js';
|
|
15
15
|
import { exitWith } from '../cli-error.js';
|
|
16
|
-
import { readProjectBinding, writeProjectBinding } from '../project-binding.js';
|
|
17
16
|
import { accountIdOption, controlPlaneUrlOption, cwdOption, deployIdOption, forceOption, jsonOption, projectIdOption, runHandler, runQuarantined, userIdOption, yesOption, } from '../cli/kit.js';
|
|
18
|
-
import { Cwd } from '../services/cwd.js';
|
|
19
|
-
import { isLinked } from '../account-store.js';
|
|
20
17
|
import { resolveCloudflareCredentials } from '../cloudflare-kv.js';
|
|
18
|
+
import { loadCloudflarePointer } from '../cloudflare-pointer.js';
|
|
21
19
|
import { DEFAULT_CONTROL_PLANE_URL, formatConfigUrl, loadCliConfig, resolveControlPlaneUrl, } from '../config.js';
|
|
22
20
|
import { CLI_VERSION } from '../constants.js';
|
|
23
21
|
import { controlPlaneAuthHeaders } from '../control-plane-fetch.js';
|
|
22
|
+
import { loadCreateControlPlane } from '../control-plane-loader.js';
|
|
23
|
+
import { captureDeployRevision } from '../deploy-revision-capture.js';
|
|
24
24
|
import { executeCliHttp } from '../effect-http.js';
|
|
25
25
|
import { createDeployHubSpotDeveloperClient } from '../hubspot-developer-client.js';
|
|
26
26
|
import { hydrateAncestorEnv } from '../load-env.js';
|
|
27
|
+
import { LocalProjectPickerCancelled, resolveLocalProject } from '../local-project-picker.js';
|
|
27
28
|
import { getMachineId } from '../machine-id.js';
|
|
28
29
|
import { cachePath } from '../paths.js';
|
|
29
|
-
import {
|
|
30
|
+
import { readProjectBinding, writeProjectBinding } from '../project-binding.js';
|
|
31
|
+
import { isInteractive, promptConfirm, promptMultiSelect, promptPassword, promptSelect, promptText, } from '../prompt.js';
|
|
30
32
|
import { createReporter } from '../reporter/index.js';
|
|
33
|
+
import { Cwd } from '../services/cwd.js';
|
|
31
34
|
import { ensureTenantStateStore, preflightTenantStateCredentials, } from '../tenant-state.js';
|
|
35
|
+
import { defaultTokenKeyCustodyAdapter, escrowTokenKey, resolveEscrowedTokenKey, tokenKeyCustodyIdFor, } from '../token-key-custody.js';
|
|
32
36
|
import { deployPromoteCommand, requestControlPlaneDeployPromotion } from './deploy-promote.js';
|
|
33
37
|
async function hostedHttp(input) {
|
|
34
38
|
const headers = { ...(input.headers ?? {}) };
|
|
@@ -41,17 +45,27 @@ async function hostedHttp(input) {
|
|
|
41
45
|
}
|
|
42
46
|
async function resolveAccountIdOrPrompt(input) {
|
|
43
47
|
const binding = input.root ? await readProjectBinding(input.root) : undefined;
|
|
44
|
-
const fromFlag = resolveFlag(input.argv, '--account-id') ?? process.env.HSX_ACCOUNT_ID;
|
|
45
|
-
if (fromFlag && fromFlag.length > 0) {
|
|
46
|
-
return {
|
|
47
|
-
accountId: fromFlag,
|
|
48
|
-
fromPrompt: false,
|
|
49
|
-
...(binding && binding.accountId !== fromFlag ? { overrodeBinding: binding.accountId } : {}),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
48
|
const { loadStore } = await import('../account-store.js');
|
|
53
49
|
const store = await loadStore();
|
|
54
50
|
const ids = Object.keys(store.accounts);
|
|
51
|
+
const explicitFlag = resolveFlag(input.argv, '--account-id');
|
|
52
|
+
const fromFlag = explicitFlag ?? process.env.HSX_ACCOUNT_ID;
|
|
53
|
+
if (fromFlag && fromFlag.length > 0) {
|
|
54
|
+
// An explicit --account-id is always honored (it errors clearly downstream
|
|
55
|
+
// if unknown — the user asked for it). A stale HSX_ACCOUNT_ID from the
|
|
56
|
+
// ambient env, however, must not override a valid binding with a phantom
|
|
57
|
+
// account: if it names no known account, ignore it and fall through to the
|
|
58
|
+
// binding/default rather than dead-ending at "Account not found".
|
|
59
|
+
if (explicitFlag !== undefined || ids.includes(fromFlag)) {
|
|
60
|
+
return {
|
|
61
|
+
accountId: fromFlag,
|
|
62
|
+
fromPrompt: false,
|
|
63
|
+
...(binding && binding.accountId !== fromFlag
|
|
64
|
+
? { overrodeBinding: binding.accountId }
|
|
65
|
+
: {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
55
69
|
// A recorded binding wins over the global default (you may work across
|
|
56
70
|
// projects bound to different accounts). Fall through if it's gone stale.
|
|
57
71
|
if (binding && ids.includes(binding.accountId)) {
|
|
@@ -60,14 +74,18 @@ async function resolveAccountIdOrPrompt(input) {
|
|
|
60
74
|
if (ids.length === 0)
|
|
61
75
|
return { fromPrompt: false };
|
|
62
76
|
const defaultId = store.defaultAccountId;
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
77
|
+
// A configured default — or a single account — is an answer, not a question:
|
|
78
|
+
// use it silently, exactly like non-interactive runs. `--account-id` and
|
|
79
|
+
// `hs-x accounts switch` override; the picker remains only for the genuinely
|
|
80
|
+
// ambiguous case (multiple accounts, no default).
|
|
81
|
+
if (defaultId && ids.includes(defaultId)) {
|
|
82
|
+
return { accountId: defaultId, fromPrompt: false };
|
|
83
|
+
}
|
|
84
|
+
const soleAccount = ids.length === 1 ? ids[0] : undefined;
|
|
85
|
+
if (soleAccount) {
|
|
86
|
+
return { accountId: soleAccount, fromPrompt: false };
|
|
87
|
+
}
|
|
88
|
+
if (input.json || !isInteractive()) {
|
|
71
89
|
return { fromPrompt: false };
|
|
72
90
|
}
|
|
73
91
|
const picked = await promptSelect({
|
|
@@ -101,8 +119,14 @@ function missingAccountIdError(input) {
|
|
|
101
119
|
}, null, 2)}\n`);
|
|
102
120
|
}
|
|
103
121
|
else {
|
|
104
|
-
const reporter = createReporter({
|
|
105
|
-
|
|
122
|
+
const reporter = createReporter({
|
|
123
|
+
command: input.command,
|
|
124
|
+
argv: input.argv,
|
|
125
|
+
});
|
|
126
|
+
reporter.error(code, message, {
|
|
127
|
+
hint,
|
|
128
|
+
docs_url: `https://hs-x.dev/errors/${code}`,
|
|
129
|
+
});
|
|
106
130
|
reporter.done(undefined, 10);
|
|
107
131
|
}
|
|
108
132
|
return { exitCode: 10 };
|
|
@@ -117,35 +141,91 @@ async function promptMissingText(message, current, validate) {
|
|
|
117
141
|
return answer === undefined ? undefined : answer;
|
|
118
142
|
}
|
|
119
143
|
/**
|
|
120
|
-
*
|
|
121
|
-
* the
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* worker, to avoid contending for stdin
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* a Worker that 500s on /oauth-start (the run-006 papercut).
|
|
144
|
+
* Resolve the HubSpot credentials required by the runtime. OAuth installs need
|
|
145
|
+
* the client id + secret for `/oauth-start`; every card backend needs the
|
|
146
|
+
* client secret to verify HubSpot's v3 request signature, regardless of whether
|
|
147
|
+
* the app uses OAuth or static auth. When interactive, prompt for anything
|
|
148
|
+
* required and missing (only on the first worker, to avoid contending for stdin
|
|
149
|
+
* across concurrent worker deploys), then persist answers to the process env so
|
|
150
|
+
* sibling workers reuse them.
|
|
128
151
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
152
|
+
* OAuth-only bootstrap deploys retain the historical warning-only behavior
|
|
153
|
+
* because the Auth page may not exist until after upload. Card backends fail
|
|
154
|
+
* closed before the Worker deploy: shipping one without the HMAC key guarantees
|
|
155
|
+
* that every live `hubspot.fetch` returns 401.
|
|
133
156
|
*/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
157
|
+
/** @internal Exported for the deploy credential-gate regression suite. */
|
|
158
|
+
export async function resolveHubSpotRuntimeCredentials(input) {
|
|
159
|
+
if (input.dryRun) {
|
|
160
|
+
return {
|
|
161
|
+
clientId: input.hubSpotClientId,
|
|
162
|
+
clientSecret: input.hubSpotClientSecret,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const clientId = input.auth === 'oauth' && input.allowPrompt
|
|
166
|
+
? await promptMissingText('HubSpot app client id (HSX_HUBSPOT_CLIENT_ID)', input.hubSpotClientId)
|
|
167
|
+
: input.hubSpotClientId;
|
|
168
|
+
const clientSecret = (input.auth === 'oauth' || input.requiresCardSignatureSecret) && input.allowPrompt
|
|
169
|
+
? (input.hubSpotClientSecret ??
|
|
170
|
+
(await promptPassword({
|
|
171
|
+
message: `HubSpot app client secret — copy it from the app’s Auth tab (HSX_HUBSPOT_CLIENT_SECRET)${input.authUrl ? `\n${input.authUrl}` : ''}`,
|
|
172
|
+
})))
|
|
173
|
+
: input.hubSpotClientSecret;
|
|
143
174
|
if (clientId)
|
|
144
175
|
process.env.HSX_HUBSPOT_CLIENT_ID = clientId;
|
|
145
176
|
if (clientSecret)
|
|
146
177
|
process.env.HSX_HUBSPOT_CLIENT_SECRET = clientSecret;
|
|
178
|
+
if (input.requiresCardSignatureSecret && !clientSecret) {
|
|
179
|
+
throw new Error(`A HubSpot client secret is required to deploy card backends: HubSpot v3 signatures cannot be verified without it. Copy the secret from the app Auth tab${input.authUrl ? ` (${input.authUrl})` : ''}, then redeploy with --hubspot-client-secret or HSX_HUBSPOT_CLIENT_SECRET (linked projects may store it with \`hs-x secrets hubspot-oauth set\`).`);
|
|
180
|
+
}
|
|
147
181
|
return { clientId, clientSecret };
|
|
148
182
|
}
|
|
183
|
+
/** @internal Exported for the deploy credential-gate regression suite. */
|
|
184
|
+
export function requireHubSpotCardAppId(hubSpotAppId, authUrl) {
|
|
185
|
+
if (hubSpotAppId)
|
|
186
|
+
return hubSpotAppId;
|
|
187
|
+
throw new Error(`A HubSpot app ID is required to deploy card backends. Upload the HubSpot project first so HubSpot can create the app, or pass --hubspot-app-id / HSX_HUBSPOT_APP_ID.${authUrl ? ` App credentials: ${authUrl}` : ''}`);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* HubSpot cannot reveal an app secret after creation, but its token endpoint
|
|
191
|
+
* authenticates the client before rejecting a deliberately invalid auth code.
|
|
192
|
+
* Use that distinction to catch a copied secret from the wrong app before a
|
|
193
|
+
* Worker ships and turns every card request into a signature 401.
|
|
194
|
+
*
|
|
195
|
+
* No credential or response body is logged. `invalid_grant` (the expected
|
|
196
|
+
* dummy-code response) proves the client pair was accepted; `invalid_client`
|
|
197
|
+
* / BAD_CLIENT_* proves it was not.
|
|
198
|
+
*
|
|
199
|
+
* @internal Exported for the deploy credential-gate regression suite.
|
|
200
|
+
*/
|
|
201
|
+
export async function validateHubSpotClientCredentials(input) {
|
|
202
|
+
if (!input.clientId || !input.clientSecret)
|
|
203
|
+
return;
|
|
204
|
+
const request = input.fetchImpl ?? fetch;
|
|
205
|
+
let response;
|
|
206
|
+
try {
|
|
207
|
+
response = await request('https://api.hubapi.com/oauth/2026-03/token', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
210
|
+
body: new URLSearchParams({
|
|
211
|
+
grant_type: 'authorization_code',
|
|
212
|
+
client_id: input.clientId,
|
|
213
|
+
client_secret: input.clientSecret,
|
|
214
|
+
redirect_uri: 'https://hs-x.dev/hubspot-credential-preflight',
|
|
215
|
+
code: 'hsx-invalid-code-preflight',
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
throw new Error(`Could not validate the HubSpot app credentials, so the card-backend deploy was stopped. Check your network and retry.${input.authUrl ? ` App credentials: ${input.authUrl}` : ''}`, { cause: error });
|
|
221
|
+
}
|
|
222
|
+
const body = (await response.json().catch(() => undefined));
|
|
223
|
+
const error = isRecord(body) && typeof body.error === 'string' ? body.error : undefined;
|
|
224
|
+
const status = isRecord(body) && typeof body.status === 'string' ? body.status : undefined;
|
|
225
|
+
if (error === 'invalid_client' || status === 'BAD_CLIENT_SECRET' || status === 'BAD_CLIENT_ID') {
|
|
226
|
+
throw new Error(`The stored HubSpot client ID and secret do not match this app. Copy the client secret from the app Auth tab and store it again before deploying.${input.authUrl ? ` ${input.authUrl}` : ''}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
149
229
|
function missingProjectIdError(input) {
|
|
150
230
|
const code = 'HSX_E_INPUT_MISSING_PROJECT_ID';
|
|
151
231
|
const message = 'Missing --project-id.';
|
|
@@ -159,8 +239,14 @@ function missingProjectIdError(input) {
|
|
|
159
239
|
}, null, 2)}\n`);
|
|
160
240
|
}
|
|
161
241
|
else {
|
|
162
|
-
const reporter = createReporter({
|
|
163
|
-
|
|
242
|
+
const reporter = createReporter({
|
|
243
|
+
command: input.command,
|
|
244
|
+
argv: input.argv,
|
|
245
|
+
});
|
|
246
|
+
reporter.error(code, message, {
|
|
247
|
+
hint,
|
|
248
|
+
docs_url: `https://hs-x.dev/errors/${code}`,
|
|
249
|
+
});
|
|
164
250
|
reporter.done(undefined, 10);
|
|
165
251
|
}
|
|
166
252
|
return { exitCode: 10 };
|
|
@@ -180,7 +266,10 @@ function inputValidationError(input) {
|
|
|
180
266
|
}, null, 2)}\n`);
|
|
181
267
|
}
|
|
182
268
|
else {
|
|
183
|
-
const reporter = createReporter({
|
|
269
|
+
const reporter = createReporter({
|
|
270
|
+
command: input.command,
|
|
271
|
+
argv: input.argv,
|
|
272
|
+
});
|
|
184
273
|
reporter.error(input.code, input.message, {
|
|
185
274
|
hint: input.hint,
|
|
186
275
|
docs_url: `https://hs-x.dev/errors/${input.code}`,
|
|
@@ -240,8 +329,9 @@ async function promptMissingCloudflareCredentials(input) {
|
|
|
240
329
|
const reporter = createReporter({ command: 'deploy', argv: input.argv });
|
|
241
330
|
if (!initial.apiToken) {
|
|
242
331
|
const { CLOUDFLARE_TOKEN_TEMPLATE_URL, CloudflareAuthError, verifyCloudflareToken } = await import('../cloudflare-auth.js');
|
|
243
|
-
reporter.info('A Cloudflare
|
|
244
|
-
reporter.info(`
|
|
332
|
+
reporter.info('A Cloudflare credential is needed to deploy this Worker.');
|
|
333
|
+
reporter.info('Easiest: `hs-x connect cloudflare` signs in via your browser and stores it for every future deploy.');
|
|
334
|
+
reporter.info(`Or paste an API token (HS-X template): ${CLOUDFLARE_TOKEN_TEMPLATE_URL}`);
|
|
245
335
|
for (;;) {
|
|
246
336
|
const token = await promptText({
|
|
247
337
|
message: 'Paste your Cloudflare API token',
|
|
@@ -263,7 +353,10 @@ async function promptMissingCloudflareCredentials(input) {
|
|
|
263
353
|
catch (error) {
|
|
264
354
|
if (error instanceof CloudflareAuthError) {
|
|
265
355
|
reporter.warn(error.code, error.message);
|
|
266
|
-
const retry = await promptConfirm({
|
|
356
|
+
const retry = await promptConfirm({
|
|
357
|
+
message: 'Try again?',
|
|
358
|
+
default: true,
|
|
359
|
+
});
|
|
267
360
|
if (retry === undefined || !retry)
|
|
268
361
|
return false;
|
|
269
362
|
continue;
|
|
@@ -284,6 +377,72 @@ async function promptMissingCloudflareCredentials(input) {
|
|
|
284
377
|
}
|
|
285
378
|
return true;
|
|
286
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Bridge `hs-x connect cloudflare` to `hs-x deploy`. `connect` stores an OAuth
|
|
382
|
+
* refresh token (`cloudflare-oauth.json`) keyed by Cloudflare account id, but
|
|
383
|
+
* the deploy credential resolvers only read env/flags — so a newcomer who ran
|
|
384
|
+
* the documented "easiest" path (`connect cloudflare`) and then `deploy` was
|
|
385
|
+
* told to paste an API token that `connect` itself says doesn't exist
|
|
386
|
+
* (cold-stranger run 2026-07-22-001, finding #1). When no API token is present
|
|
387
|
+
* but a stored OAuth connection exists for the account, mint a short-lived
|
|
388
|
+
* access token from it and export it to the env every downstream consumer reads
|
|
389
|
+
* (preflight, tenant-state KV, Alchemy, wrangler). Best-effort: on any failure
|
|
390
|
+
* (no connection, expired refresh token, OAuth not configured) we return false
|
|
391
|
+
* and fall through to the existing prompt / fail-fast preflight.
|
|
392
|
+
*/
|
|
393
|
+
async function hydrateCloudflareTokenFromStoredOAuth(input) {
|
|
394
|
+
if (resolveCloudflareCredentials(input.argv).apiToken)
|
|
395
|
+
return false;
|
|
396
|
+
const { getCloudflareOAuthCredential, listCloudflareOAuthAccountIds } = await import('../cloudflare-oauth-store.js');
|
|
397
|
+
let cloudflareAccountId = resolveCloudflareCredentials(input.argv).accountId ??
|
|
398
|
+
(await loadCloudflarePointer(input.root))?.cloudflareAccountId;
|
|
399
|
+
if (!cloudflareAccountId) {
|
|
400
|
+
// First deploy: no pointer and no env account id yet. If the user has
|
|
401
|
+
// connected exactly one Cloudflare account, that's unambiguously the one.
|
|
402
|
+
const connected = await listCloudflareOAuthAccountIds();
|
|
403
|
+
if (connected.length === 1)
|
|
404
|
+
cloudflareAccountId = connected[0];
|
|
405
|
+
}
|
|
406
|
+
if (!cloudflareAccountId)
|
|
407
|
+
return false;
|
|
408
|
+
const applyToken = (token) => {
|
|
409
|
+
process.env.CLOUDFLARE_API_TOKEN = token;
|
|
410
|
+
if (!resolveCloudflareCredentials(input.argv).accountId) {
|
|
411
|
+
process.env.CLOUDFLARE_ACCOUNT_ID = cloudflareAccountId;
|
|
412
|
+
}
|
|
413
|
+
return true;
|
|
414
|
+
};
|
|
415
|
+
try {
|
|
416
|
+
const credential = await getCloudflareOAuthCredential(cloudflareAccountId);
|
|
417
|
+
if (!credential)
|
|
418
|
+
return false;
|
|
419
|
+
// Cloudflare OAuth issues no refresh token, so the stored access token is
|
|
420
|
+
// normally the only credential. Use it directly while it's still valid
|
|
421
|
+
// (60s safety margin); a lapsed token falls through so the deploy's
|
|
422
|
+
// fail-fast points the user back at `hs-x connect cloudflare`.
|
|
423
|
+
if (credential.accessToken) {
|
|
424
|
+
const stillValid = !credential.accessTokenExpiresAt ||
|
|
425
|
+
new Date(credential.accessTokenExpiresAt).getTime() > Date.now() + 60_000;
|
|
426
|
+
if (stillValid)
|
|
427
|
+
return applyToken(credential.accessToken);
|
|
428
|
+
}
|
|
429
|
+
if (credential.refreshToken) {
|
|
430
|
+
const { loadCloudflareOAuthConfig, refreshTokens } = await import('../cloudflare-oauth.js');
|
|
431
|
+
const config = await loadCloudflareOAuthConfig({
|
|
432
|
+
controlPlaneUrl: resolveControlPlaneUrl(input.argv),
|
|
433
|
+
});
|
|
434
|
+
const tokens = await refreshTokens({
|
|
435
|
+
config,
|
|
436
|
+
refreshToken: credential.refreshToken,
|
|
437
|
+
});
|
|
438
|
+
return applyToken(tokens.accessToken);
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
287
446
|
async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, deployId, userId, timeoutMs, workerUrl, }) {
|
|
288
447
|
const startedAt = Date.now();
|
|
289
448
|
let lastState = 'unknown';
|
|
@@ -323,18 +482,95 @@ async function waitForHealthyControlPlaneDrift({ controlPlaneUrl, projectId, dep
|
|
|
323
482
|
function isRecord(value) {
|
|
324
483
|
return typeof value === 'object' && value !== null;
|
|
325
484
|
}
|
|
485
|
+
/** First line of an error message — the right size for a step-failure detail. */
|
|
486
|
+
function firstErrorLine(error) {
|
|
487
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
488
|
+
return message.split('\n')[0] ?? message;
|
|
489
|
+
}
|
|
490
|
+
function elapsedSince(startedAt) {
|
|
491
|
+
const ms = Date.now() - startedAt;
|
|
492
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
493
|
+
}
|
|
494
|
+
function createDeployProgress(input) {
|
|
495
|
+
const reporter = createReporter({
|
|
496
|
+
command: 'deploy',
|
|
497
|
+
argv: input.argv,
|
|
498
|
+
startedAt: input.startedAt,
|
|
499
|
+
});
|
|
500
|
+
const tally = { ok: 0, warn: 0, fail: 0 };
|
|
501
|
+
return {
|
|
502
|
+
reporter,
|
|
503
|
+
step(label) {
|
|
504
|
+
const handle = reporter.step(label);
|
|
505
|
+
return {
|
|
506
|
+
ok: (detail) => {
|
|
507
|
+
tally.ok += 1;
|
|
508
|
+
handle.ok(detail);
|
|
509
|
+
},
|
|
510
|
+
fail: (detail) => {
|
|
511
|
+
tally.fail += 1;
|
|
512
|
+
handle.fail(detail);
|
|
513
|
+
},
|
|
514
|
+
warn: (detail) => {
|
|
515
|
+
tally.warn += 1;
|
|
516
|
+
handle.warn(detail);
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
},
|
|
520
|
+
counts: () => ({ ...tally }),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/** ADR-007 source summaries for the human transcript (`Sources: …`). */
|
|
524
|
+
function workerSourceSummaries(workers) {
|
|
525
|
+
return workers.flatMap((worker) => worker.capabilities.flatMap((capability) => capability.kind === 'sync' && capability.source
|
|
526
|
+
? [
|
|
527
|
+
`${capability.source.name} (${capability.source.kind}${capability.source.kind === 'push'
|
|
528
|
+
? `, ${capability.source.webhookPath}`
|
|
529
|
+
: `, ${capability.schedule ?? 'manual'}`})`,
|
|
530
|
+
]
|
|
531
|
+
: []));
|
|
532
|
+
}
|
|
533
|
+
/** Per-sync manageSchema modes for the human transcript. */
|
|
534
|
+
function workerSyncSchemaModes(workers) {
|
|
535
|
+
return workers.flatMap((worker) => worker.capabilities.flatMap((capability) => capability.kind === 'sync' ? [`${capability.id}: ${capability.manageSchema ?? false}`] : []));
|
|
536
|
+
}
|
|
326
537
|
export async function deployCommand({ argv, root, json, }) {
|
|
327
538
|
// Stamp the whole invocation up front: the human summary reporter is created
|
|
328
539
|
// late (render time), so without this "Deployed … in 1ms" measured only the
|
|
329
540
|
// summary step rather than the deploy itself.
|
|
330
541
|
const commandStartedAt = Date.now();
|
|
331
542
|
if (argv[1] === 'promote') {
|
|
332
|
-
return deployPromoteCommand({ argv, json });
|
|
543
|
+
return deployPromoteCommand({ argv, json, root });
|
|
544
|
+
}
|
|
545
|
+
if (!json && isInteractive()) {
|
|
546
|
+
try {
|
|
547
|
+
const selected = await resolveLocalProject({
|
|
548
|
+
root,
|
|
549
|
+
allowPrompt: true,
|
|
550
|
+
message: 'Which local project should be deployed?',
|
|
551
|
+
prompts: { select: promptSelect },
|
|
552
|
+
});
|
|
553
|
+
if (selected && selected.root !== root) {
|
|
554
|
+
return await deployCommand({ argv, root: selected.root, json });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (error instanceof LocalProjectPickerCancelled)
|
|
559
|
+
return cancelledResult(argv, 'deploy');
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
333
562
|
}
|
|
334
563
|
// bun only auto-loads .env/.env.local from the CWD; when deploying from a
|
|
335
564
|
// nested app dir, pull in a repo-root .env.local (CLOUDFLARE_ACCOUNT_ID, etc.)
|
|
336
565
|
// so the deploy doesn't fail for vars that are set at the root.
|
|
337
566
|
hydrateAncestorEnv(root);
|
|
567
|
+
// One shared streaming reporter for the whole run (human/plain/ndjson).
|
|
568
|
+
// `echo` additionally gates the inline info/warn stream to human/plain: the
|
|
569
|
+
// ndjson stream keeps emitting those lines from the final summary (its
|
|
570
|
+
// pre-existing event order), so wiring steps stays purely additive there.
|
|
571
|
+
const progress = json ? undefined : createDeployProgress({ argv, startedAt: commandStartedAt });
|
|
572
|
+
const echo = progress && progress.reporter.mode !== 'ndjson' ? progress.reporter : undefined;
|
|
573
|
+
progress?.reporter.header(basename(root));
|
|
338
574
|
const planOnly = argv.includes('--plan') || argv.includes('--dry-run');
|
|
339
575
|
const buildOnly = argv.includes('--build-only');
|
|
340
576
|
const hubspotOnly = argv.includes('--hubspot-only');
|
|
@@ -365,11 +601,14 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
365
601
|
});
|
|
366
602
|
const accountId = resolvedAcct.accountId;
|
|
367
603
|
if (accountId) {
|
|
604
|
+
// Normalize the ambient env to the RESOLVED account so downstream helpers
|
|
605
|
+
// that independently read `HSX_ACCOUNT_ID` (the HubSpot/Cloudflare credential
|
|
606
|
+
// leases, tenant-state writes) don't fall back to a stale/phantom value and
|
|
607
|
+
// 404. Mirrors how this command normalizes HSX_HUBSPOT_CLIENT_ID below.
|
|
608
|
+
process.env.HSX_ACCOUNT_ID = accountId;
|
|
368
609
|
if (resolvedAcct.overrodeBinding) {
|
|
369
610
|
// One-off --account-id override: warn but leave the recorded binding intact.
|
|
370
|
-
|
|
371
|
-
createReporter({ command: 'deploy', argv }).warn('HSX_W_DEPLOY_ACCOUNT_OVERRIDE', `This project is bound to ${resolvedAcct.overrodeBinding}; deploying under ${accountId} for this run only (binding unchanged).`);
|
|
372
|
-
}
|
|
611
|
+
progress?.reporter.warn('HSX_W_DEPLOY_ACCOUNT_OVERRIDE', `This project is bound to ${resolvedAcct.overrodeBinding}; deploying under ${accountId} for this run only (binding unchanged).`);
|
|
373
612
|
}
|
|
374
613
|
else {
|
|
375
614
|
// Sticky binding: remember which HS-X account this project deploys under.
|
|
@@ -378,10 +617,31 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
378
617
|
}
|
|
379
618
|
const explicitProjectId = resolveFlag(argv, '--project-id') ?? process.env.HSX_PROJECT_ID;
|
|
380
619
|
let projectId = explicitProjectId;
|
|
620
|
+
const validateStep = progress?.step('Validating project');
|
|
381
621
|
const validation = await validateProject({ root });
|
|
382
622
|
const workers = await discoverWorkerManifests(root, { noManageSchema });
|
|
383
623
|
const app = await readHsxAppConfig(root);
|
|
384
624
|
const configHeartbeat = await readHsxConfigHeartbeat(root);
|
|
625
|
+
if (validateStep) {
|
|
626
|
+
const capCount = workers.reduce((count, worker) => count + worker.capabilities.length, 0);
|
|
627
|
+
if (validation.ok) {
|
|
628
|
+
validateStep.ok(`${workers.length} workers, ${capCount} capabilities`);
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
validateStep.fail(`${validation.diagnostics.length} diagnostic${validation.diagnostics.length === 1 ? '' : 's'}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (echo) {
|
|
635
|
+
for (const diagnostic of validation.diagnostics)
|
|
636
|
+
echo.info(formatDiagnostic(diagnostic));
|
|
637
|
+
const sources = workerSourceSummaries(workers);
|
|
638
|
+
if (sources.length > 0)
|
|
639
|
+
echo.info(`Sources: ${sources.join(', ')}`);
|
|
640
|
+
const syncSchemaModes = workerSyncSchemaModes(workers);
|
|
641
|
+
if (syncSchemaModes.length > 0) {
|
|
642
|
+
echo.info(`Portal schema management: ${syncSchemaModes.join(', ')}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
385
645
|
if (!projectId) {
|
|
386
646
|
projectId = await readHsxConfigProjectId(root);
|
|
387
647
|
}
|
|
@@ -389,6 +649,15 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
389
649
|
const cloudflareDeployRequested = !buildOnly &&
|
|
390
650
|
(explicitCloudflareDeployRequested || (!planOnly && needsRuntime && !explicitHubspotUpload));
|
|
391
651
|
const hubspotUploadRequested = !buildOnly && (explicitHubspotUpload || (!planOnly && !cloudflareDryRun && Boolean(projectId)));
|
|
652
|
+
if (echo) {
|
|
653
|
+
echo.info(`Runtime: ${needsRuntime ? 'Cloudflare Worker required' : 'HubSpot-only compatible'}`);
|
|
654
|
+
if (hubspotOnly && needsRuntime) {
|
|
655
|
+
echo.warn('HSX_W_DEPLOY_HUBSPOT_ONLY_INCOMPATIBLE', 'Cannot use --hubspot-only because at least one capability has a runtime handler.');
|
|
656
|
+
}
|
|
657
|
+
if (hubspotUploadRequested && !hubspotOnly && !cloudflareDeployRequested) {
|
|
658
|
+
echo.warn('HSX_W_DEPLOY_HUBSPOT_UPLOAD_WITHOUT_RUNTIME_DEPLOY', 'HubSpot upload requested without --cloudflare-deploy; runtime endpoints may still point at an older Worker.');
|
|
659
|
+
}
|
|
660
|
+
}
|
|
392
661
|
// Hosted control-plane deploys record by default: shipping code without
|
|
393
662
|
// recording the revision guarantees `unknown_code` drift on the next
|
|
394
663
|
// attestation (the worker attests a deploy id the control plane has no
|
|
@@ -403,10 +672,38 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
403
672
|
!cloudflareDryRun &&
|
|
404
673
|
Boolean(controlPlaneUrl) &&
|
|
405
674
|
(cloudflareDeployRequested || hubspotUploadRequested));
|
|
406
|
-
const
|
|
675
|
+
const portalSchemaRequested = Boolean(resolveFlag(argv, '--portal-schema-fixture')) ||
|
|
676
|
+
argv.includes('--portal-schema-live') ||
|
|
677
|
+
argv.includes('--local-hubspot-schema');
|
|
678
|
+
const portalSchemaStep = portalSchemaRequested
|
|
679
|
+
? progress?.step('Reading portal schema')
|
|
680
|
+
: undefined;
|
|
681
|
+
let portalSchemaRead;
|
|
682
|
+
try {
|
|
683
|
+
portalSchemaRead = await readPortalSchema(argv, workers);
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
portalSchemaStep?.fail(firstErrorLine(error));
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
407
689
|
const portalSchemaPlan = portalSchemaRead
|
|
408
|
-
? planPortalSchemaManagement({
|
|
690
|
+
? planPortalSchemaManagement({
|
|
691
|
+
workers,
|
|
692
|
+
observed: portalSchemaRead.observed,
|
|
693
|
+
})
|
|
409
694
|
: undefined;
|
|
695
|
+
if (portalSchemaStep) {
|
|
696
|
+
if (portalSchemaRead) {
|
|
697
|
+
const objectCount = portalSchemaRead.observed.objects.length;
|
|
698
|
+
portalSchemaStep.ok(`${objectCount} object type${objectCount === 1 ? '' : 's'} (${portalSchemaRead.source})`);
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
portalSchemaStep.ok('skipped');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (echo && portalSchemaPlan) {
|
|
705
|
+
echo.info(renderPortalSchemaPlan(portalSchemaPlan).trimEnd());
|
|
706
|
+
}
|
|
410
707
|
const willChangeAnything = !planOnly &&
|
|
411
708
|
(applySchema ||
|
|
412
709
|
shouldRecordControlPlane ||
|
|
@@ -445,9 +742,7 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
445
742
|
shouldRecordControlPlane,
|
|
446
743
|
promoteWhenHealthy,
|
|
447
744
|
});
|
|
448
|
-
|
|
449
|
-
reporter.header(basename(root));
|
|
450
|
-
reporter.block(summary);
|
|
745
|
+
progress?.reporter.block(summary);
|
|
451
746
|
const confirm = await promptConfirm({
|
|
452
747
|
message: 'Apply this plan?',
|
|
453
748
|
default: false,
|
|
@@ -456,12 +751,21 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
456
751
|
return cancelledResult(argv, 'deploy');
|
|
457
752
|
}
|
|
458
753
|
}
|
|
754
|
+
// `--apply-schema` writes property definitions to the live portal, but
|
|
755
|
+
// `--plan`/`--dry-run` promise to change nothing. Honoring both would let a
|
|
756
|
+
// "dry run" mutate production (cold-stranger run 2026-07-22-001, finding #2):
|
|
757
|
+
// the confirm prompt above is already suppressed under `planOnly`, so without
|
|
758
|
+
// this guard the apply ran silently. Skip the apply and say so.
|
|
759
|
+
if (applySchema && planOnly && echo) {
|
|
760
|
+
echo.warn('HSX_W_DEPLOY_APPLY_SCHEMA_DRY_RUN', '--apply-schema ignored because --plan/--dry-run was passed; no portal changes were made.');
|
|
761
|
+
}
|
|
459
762
|
// The schema apply is the first phase that touches the portal. A failure here
|
|
460
763
|
// used to throw a raw HubSpot error out of the command — no HSX_E code, no
|
|
461
764
|
// phase/PLAN output (cold-stranger run #3, finding A). Render the curated
|
|
462
765
|
// failure WITH the plan that was being applied instead.
|
|
463
766
|
let portalSchemaApply;
|
|
464
|
-
if (applySchema) {
|
|
767
|
+
if (applySchema && !planOnly) {
|
|
768
|
+
const applyStep = progress?.step('Applying portal schema');
|
|
465
769
|
try {
|
|
466
770
|
portalSchemaApply = await applyPortalSchemaPlan({
|
|
467
771
|
argv,
|
|
@@ -470,6 +774,7 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
470
774
|
});
|
|
471
775
|
}
|
|
472
776
|
catch (error) {
|
|
777
|
+
applyStep?.fail(error instanceof PortalSchemaApplyError ? error.action : firstErrorLine(error));
|
|
473
778
|
return portalSchemaApplyFailure({
|
|
474
779
|
argv,
|
|
475
780
|
json,
|
|
@@ -478,16 +783,29 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
478
783
|
workers,
|
|
479
784
|
portalSchemaPlan,
|
|
480
785
|
error,
|
|
786
|
+
...(progress ? { progress } : {}),
|
|
481
787
|
});
|
|
482
788
|
}
|
|
789
|
+
applyStep?.ok(`${portalSchemaApply.applied.length} applied${portalSchemaApply.skipped.length > 0 ? `, ${portalSchemaApply.skipped.length} skipped` : ''}`);
|
|
790
|
+
if (echo)
|
|
791
|
+
echo.info(renderPortalSchemaApply(portalSchemaApply).trimEnd());
|
|
483
792
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
793
|
+
const artifactsStep = progress?.step('Generating project artifacts');
|
|
794
|
+
let manifest;
|
|
795
|
+
try {
|
|
796
|
+
manifest = await generateProjectArtifacts({
|
|
797
|
+
root,
|
|
798
|
+
workers,
|
|
799
|
+
appObjects: app.appObjects,
|
|
800
|
+
appObjectAssociations: app.appObjectAssociations,
|
|
801
|
+
appEvents: app.appEvents,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
artifactsStep?.fail(firstErrorLine(error));
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
artifactsStep?.ok('.hs-x/manifest.json + refs stubs');
|
|
491
809
|
const shouldCreateControlPlaneRequest = !hubspotOnly && Boolean(accountId);
|
|
492
810
|
let controlPlaneRequest;
|
|
493
811
|
let resolvedProjectId = projectId;
|
|
@@ -511,6 +829,7 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
511
829
|
projectId: resolvedProjectId,
|
|
512
830
|
manifest,
|
|
513
831
|
...(app.billing ? { billing: app.billing } : {}),
|
|
832
|
+
...(app.alerts ? { alerts: app.alerts } : {}),
|
|
514
833
|
});
|
|
515
834
|
}
|
|
516
835
|
catch (error) {
|
|
@@ -526,24 +845,53 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
526
845
|
});
|
|
527
846
|
}
|
|
528
847
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
848
|
+
if (echo && controlPlaneRequest) {
|
|
849
|
+
echo.info(`Control plane: ready to POST /v1/deploys/plan for ${controlPlaneRequest.projectId}`);
|
|
850
|
+
}
|
|
851
|
+
const controlPlanePlanStep = controlPlaneRequest && (localControlPlane || controlPlaneUrl)
|
|
852
|
+
? progress?.step(localControlPlane
|
|
853
|
+
? 'Planning deploy (local control plane)'
|
|
854
|
+
: 'Planning deploy (control plane)')
|
|
855
|
+
: undefined;
|
|
856
|
+
let controlPlanePlan;
|
|
857
|
+
try {
|
|
858
|
+
controlPlanePlan =
|
|
859
|
+
localControlPlane && controlPlaneRequest
|
|
860
|
+
? await requestLocalControlPlaneDeployPlan(controlPlaneRequest)
|
|
861
|
+
: controlPlaneUrl && controlPlaneRequest
|
|
862
|
+
? await requestHostedControlPlaneDeployPlan({
|
|
863
|
+
request: controlPlaneRequest,
|
|
864
|
+
controlPlaneUrl,
|
|
865
|
+
userId,
|
|
866
|
+
})
|
|
867
|
+
: !buildOnly && !hubspotOnly && projectId
|
|
868
|
+
? createUnlinkedDeployPlan({
|
|
869
|
+
projectId,
|
|
870
|
+
machineId,
|
|
871
|
+
manifest,
|
|
872
|
+
workers,
|
|
873
|
+
})
|
|
874
|
+
: undefined;
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
controlPlanePlanStep?.fail(firstErrorLine(error));
|
|
878
|
+
throw error;
|
|
879
|
+
}
|
|
545
880
|
const controlPlaneBackedPlan = Boolean(controlPlanePlan && controlPlaneRequest && (localControlPlane || controlPlaneUrl));
|
|
546
881
|
const unlinkedDeployPlan = Boolean(controlPlanePlan && !controlPlaneBackedPlan);
|
|
882
|
+
if (controlPlanePlan) {
|
|
883
|
+
controlPlanePlanStep?.ok(controlPlanePlan.deployId);
|
|
884
|
+
if (echo) {
|
|
885
|
+
if (unlinkedDeployPlan)
|
|
886
|
+
echo.info(`Unlinked deploy id: ${controlPlanePlan.deployId}`);
|
|
887
|
+
if (controlPlanePlan.sharedCloudflareQuota) {
|
|
888
|
+
echo.info(`Shared Cloudflare quota: ${controlPlanePlan.sharedCloudflareQuota.visibleSiblingAccountIds.join(', ')}`);
|
|
889
|
+
}
|
|
890
|
+
if (controlPlanePlan.leases.length > 0) {
|
|
891
|
+
echo.info(`Cloudflare credential leases issued by the control plane: ${controlPlanePlan.leases.length}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
547
895
|
if (controlPlanePlan) {
|
|
548
896
|
await writeFile(join(root, '.hs-x', 'alchemy.run.ts'), renderAlchemyProgram({
|
|
549
897
|
hsXAccountId: controlPlanePlan.accountId,
|
|
@@ -552,6 +900,20 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
552
900
|
workers,
|
|
553
901
|
}));
|
|
554
902
|
}
|
|
903
|
+
// Bridge a prior `hs-x connect cloudflare` to this deploy: if there's no API
|
|
904
|
+
// token in env/flags but a stored OAuth connection exists, mint a token from
|
|
905
|
+
// it now so BOTH the interactive prompt below and the non-interactive
|
|
906
|
+
// (`--yes`) preflight succeed instead of demanding a token connect says
|
|
907
|
+
// doesn't exist (cold-stranger run 2026-07-22-001, finding #1).
|
|
908
|
+
if (cloudflareDeployRequested && !planOnly && !cloudflareDryRun) {
|
|
909
|
+
const hydrated = await hydrateCloudflareTokenFromStoredOAuth({
|
|
910
|
+
argv,
|
|
911
|
+
root,
|
|
912
|
+
});
|
|
913
|
+
if (hydrated && echo) {
|
|
914
|
+
echo.info('Using the Cloudflare connection from `hs-x connect cloudflare`.');
|
|
915
|
+
}
|
|
916
|
+
}
|
|
555
917
|
// Missing Cloudflare credentials on a real CF deploy: in a TTY, prompt for
|
|
556
918
|
// the token (and account id) now — first deploy on a fresh machine is the
|
|
557
919
|
// common case. Non-TTY / --json / --yes keep the fail-fast preflight error.
|
|
@@ -567,74 +929,243 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
567
929
|
if (!proceed)
|
|
568
930
|
return cancelledResult(argv, 'deploy');
|
|
569
931
|
}
|
|
570
|
-
|
|
571
|
-
|
|
932
|
+
// Upload-first (fixes the first-deploy chicken-and-egg): the HubSpot app id
|
|
933
|
+
// does not exist until the upload creates it, so a plain deploy binds no
|
|
934
|
+
// HSX_APP_ID and the install + card v3 verification fail until a manual
|
|
935
|
+
// second deploy. When this is that first deploy (no app id known yet) and the
|
|
936
|
+
// Worker origin is resolvable up front, upload BEFORE the Cloudflare deploy so
|
|
937
|
+
// the minted app id binds in a single pass. Falls back to the original
|
|
938
|
+
// deploy-then-upload order whenever the origin can't be resolved.
|
|
939
|
+
const appIdKnownBeforeDeploy = readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root)));
|
|
940
|
+
const uploadFirstOrigin = cloudflareDeployRequested &&
|
|
941
|
+
!cloudflareDryRun &&
|
|
942
|
+
!planOnly &&
|
|
943
|
+
hubspotUploadRequested &&
|
|
944
|
+
validation.ok &&
|
|
945
|
+
controlPlanePlan &&
|
|
946
|
+
needsRuntime &&
|
|
947
|
+
!hubspotOnly &&
|
|
948
|
+
!appIdKnownBeforeDeploy
|
|
949
|
+
? await resolveUploadFirstRuntimeOrigin({ argv, root, controlPlanePlan })
|
|
950
|
+
: undefined;
|
|
951
|
+
let hubspotUploadResult;
|
|
952
|
+
// One step per HubSpot upload, wherever it runs in the ordering (upload-first
|
|
953
|
+
// vs deploy-then-upload). A FAILED build resolves the step as a failure so
|
|
954
|
+
// the failing phase is visible in the transcript, not just the exit code.
|
|
955
|
+
const runHubSpotUploadPhase = async () => {
|
|
956
|
+
const uploadStartedAt = Date.now();
|
|
957
|
+
const uploadStep = progress?.step('Uploading to HubSpot');
|
|
958
|
+
let result;
|
|
959
|
+
try {
|
|
960
|
+
result = await executeHubSpotOnlyUpload({
|
|
961
|
+
argv,
|
|
962
|
+
root,
|
|
963
|
+
local: localHubspotUpload,
|
|
964
|
+
uploadOnly: hubspotUploadOnly,
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
uploadStep?.fail(firstErrorLine(error));
|
|
969
|
+
throw error;
|
|
970
|
+
}
|
|
971
|
+
const buildState = readStatus(result.buildStatus) ?? 'UNKNOWN';
|
|
972
|
+
const detail = `build #${result.buildId} ${buildState} (${elapsedSince(uploadStartedAt)})`;
|
|
973
|
+
if (buildState === 'SUCCESS') {
|
|
974
|
+
uploadStep?.ok(detail);
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
uploadStep?.fail(detail);
|
|
978
|
+
}
|
|
979
|
+
if (echo)
|
|
980
|
+
streamHubSpotUploadOutcome(echo, result);
|
|
981
|
+
return result;
|
|
982
|
+
};
|
|
983
|
+
if (uploadFirstOrigin && controlPlanePlan) {
|
|
984
|
+
const bundleStep = progress?.step('Generating HubSpot bundle');
|
|
985
|
+
try {
|
|
986
|
+
await ensureHubSpotRuntimeProjectArtifacts({
|
|
987
|
+
argv,
|
|
988
|
+
root,
|
|
989
|
+
app,
|
|
990
|
+
workers,
|
|
991
|
+
cloudflareDeployResult: undefined,
|
|
992
|
+
needsRuntime,
|
|
993
|
+
runtimeOrigin: uploadFirstOrigin,
|
|
994
|
+
...(controlPlanePlan ? { controlPlanePlan } : {}),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
bundleStep?.fail(firstErrorLine(error));
|
|
999
|
+
throw error;
|
|
1000
|
+
}
|
|
1001
|
+
bundleStep?.ok(uploadFirstOrigin);
|
|
1002
|
+
hubspotUploadResult = await runHubSpotUploadPhase();
|
|
1003
|
+
}
|
|
1004
|
+
const willDeployCloudflare = cloudflareDeployRequested && !planOnly && validation.ok && Boolean(controlPlanePlan);
|
|
1005
|
+
const cloudflareStep = willDeployCloudflare
|
|
1006
|
+
? progress?.step(cloudflareDryRun ? 'Deploying to Cloudflare (dry-run)' : 'Deploying to Cloudflare')
|
|
1007
|
+
: undefined;
|
|
1008
|
+
const cloudflareStartedAt = Date.now();
|
|
1009
|
+
let cloudflareDeployResult;
|
|
1010
|
+
try {
|
|
1011
|
+
cloudflareDeployResult =
|
|
1012
|
+
willDeployCloudflare && controlPlanePlan
|
|
1013
|
+
? await executeCloudflareDeploy({
|
|
1014
|
+
argv,
|
|
1015
|
+
root,
|
|
1016
|
+
workers,
|
|
1017
|
+
controlPlanePlan,
|
|
1018
|
+
userId,
|
|
1019
|
+
machineId,
|
|
1020
|
+
heartbeatEnabled: noHeartbeat
|
|
1021
|
+
? false
|
|
1022
|
+
: controlPlaneBackedPlan
|
|
1023
|
+
? configHeartbeat !== false
|
|
1024
|
+
: heartbeatFlag || configHeartbeat === true,
|
|
1025
|
+
...(controlPlaneBackedPlan && controlPlaneUrl ? { controlPlaneUrl } : {}),
|
|
1026
|
+
dryRun: cloudflareDryRun,
|
|
1027
|
+
...(progress ? { note: (line) => progress.reporter.rawErr(line) } : {}),
|
|
1028
|
+
})
|
|
1029
|
+
: undefined;
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
cloudflareStep?.fail(firstErrorLine(error));
|
|
1033
|
+
throw error;
|
|
1034
|
+
}
|
|
1035
|
+
if (cloudflareDeployResult && cloudflareStep) {
|
|
1036
|
+
const firstUrl = cloudflareDeployResult.workers.find((worker) => worker.url)?.url;
|
|
1037
|
+
const workerCount = cloudflareDeployResult.workers.length;
|
|
1038
|
+
cloudflareStep.ok(`${workerCount} worker${workerCount === 1 ? '' : 's'}${firstUrl ? ` at ${firstUrl}` : ''} (${elapsedSince(cloudflareStartedAt)})`);
|
|
1039
|
+
}
|
|
1040
|
+
if (echo && cloudflareDeployResult) {
|
|
1041
|
+
for (const worker of cloudflareDeployResult.workers) {
|
|
1042
|
+
if (worker.unauthenticatedInvoke) {
|
|
1043
|
+
echo.warn('HSX_W_DEPLOY_UNAUTHENTICATED_INVOKE', `${worker.workerName}: workflow-action invoke endpoints accept unsigned requests until the app's OAuth client secret is configured (redeploy with HSX_HUBSPOT_CLIENT_SECRET / --hubspot-client-secret after the OAuth install).`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// ADR-021 R03: after the Worker is live, read back Cloudflare's confirmed
|
|
1048
|
+
// active deployment and capture the real version identity (verbatim id +
|
|
1049
|
+
// binding fingerprint) to attach to the deploy record. This runs BEFORE the
|
|
1050
|
+
// control-plane record below, so the control plane never stores a revision
|
|
1051
|
+
// Cloudflare has not confirmed. Best-effort + pure read: a read-back failure
|
|
1052
|
+
// records without a revision, and a retry re-reads the same active
|
|
1053
|
+
// deployment, so it converges to the same version id with no duplicate.
|
|
1054
|
+
const willCaptureRevision = shouldRecordControlPlane &&
|
|
1055
|
+
Boolean(controlPlanePlan) &&
|
|
1056
|
+
Boolean(cloudflareDeployResult) &&
|
|
1057
|
+
!cloudflareDryRun;
|
|
1058
|
+
const revisionStep = willCaptureRevision
|
|
1059
|
+
? progress?.step('Capturing Worker revision')
|
|
1060
|
+
: undefined;
|
|
1061
|
+
const capturedRevision = willCaptureRevision && controlPlanePlan
|
|
1062
|
+
? await captureDeployRevisionForDeploy({
|
|
572
1063
|
argv,
|
|
573
1064
|
root,
|
|
574
|
-
workers,
|
|
575
1065
|
controlPlanePlan,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
heartbeatEnabled: noHeartbeat
|
|
579
|
-
? false
|
|
580
|
-
: controlPlaneBackedPlan
|
|
581
|
-
? configHeartbeat !== false
|
|
582
|
-
: heartbeatFlag || configHeartbeat === true,
|
|
583
|
-
...(controlPlaneBackedPlan && controlPlaneUrl ? { controlPlaneUrl } : {}),
|
|
584
|
-
dryRun: cloudflareDryRun,
|
|
1066
|
+
json,
|
|
1067
|
+
...(progress ? { reporter: progress.reporter } : {}),
|
|
585
1068
|
})
|
|
586
1069
|
: undefined;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
promotionTimeoutMs: Number(resolveFlag(argv, '--promotion-timeout-ms') ?? '60000'),
|
|
602
|
-
...(() => {
|
|
603
|
-
const deployedWorkerUrl = cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
604
|
-
return deployedWorkerUrl ? { workerUrl: deployedWorkerUrl } : {};
|
|
605
|
-
})(),
|
|
606
|
-
})
|
|
607
|
-
: undefined
|
|
1070
|
+
if (revisionStep) {
|
|
1071
|
+
if (capturedRevision) {
|
|
1072
|
+
revisionStep.ok(`version ${capturedRevision.versionId}`);
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
revisionStep.warn('recorded without a rollback-capable revision');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
const willRecordControlPlane = shouldRecordControlPlane &&
|
|
1079
|
+
Boolean(controlPlaneRequest) &&
|
|
1080
|
+
Boolean(controlPlanePlan) &&
|
|
1081
|
+
(localControlPlane || Boolean(controlPlaneUrl));
|
|
1082
|
+
const recordStep = willRecordControlPlane
|
|
1083
|
+
? progress?.step(promoteWhenHealthy ? 'Recording deploy + promoting when healthy' : 'Recording deploy')
|
|
608
1084
|
: undefined;
|
|
1085
|
+
const recordStartedAt = Date.now();
|
|
1086
|
+
let localRecordResult;
|
|
1087
|
+
try {
|
|
1088
|
+
localRecordResult =
|
|
1089
|
+
shouldRecordControlPlane && controlPlaneRequest && controlPlanePlan
|
|
1090
|
+
? localControlPlane
|
|
1091
|
+
? await requestLocalControlPlaneDeployRecordAndMaybePromote({
|
|
1092
|
+
request: controlPlaneRequest,
|
|
1093
|
+
plan: controlPlanePlan,
|
|
1094
|
+
promoteWhenHealthy,
|
|
1095
|
+
...(capturedRevision ? { revision: capturedRevision } : {}),
|
|
1096
|
+
})
|
|
1097
|
+
: controlPlaneUrl
|
|
1098
|
+
? await requestHostedControlPlaneDeployRecordAndMaybePromote({
|
|
1099
|
+
request: controlPlaneRequest,
|
|
1100
|
+
plan: controlPlanePlan,
|
|
1101
|
+
controlPlaneUrl,
|
|
1102
|
+
userId,
|
|
1103
|
+
promoteWhenHealthy,
|
|
1104
|
+
promotionTimeoutMs: Number(resolveFlag(argv, '--promotion-timeout-ms') ?? '60000'),
|
|
1105
|
+
...(capturedRevision ? { revision: capturedRevision } : {}),
|
|
1106
|
+
...(() => {
|
|
1107
|
+
const deployedWorkerUrl = cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
1108
|
+
return deployedWorkerUrl ? { workerUrl: deployedWorkerUrl } : {};
|
|
1109
|
+
})(),
|
|
1110
|
+
})
|
|
1111
|
+
: undefined
|
|
1112
|
+
: undefined;
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
recordStep?.fail(firstErrorLine(error));
|
|
1116
|
+
throw error;
|
|
1117
|
+
}
|
|
1118
|
+
if (recordStep) {
|
|
1119
|
+
if (localRecordResult) {
|
|
1120
|
+
recordStep.ok(`${localRecordResult.record.status}${localRecordResult.promotion
|
|
1121
|
+
? ` · promoted ${localRecordResult.promotion.promoted.deployId}`
|
|
1122
|
+
: ''} (${elapsedSince(recordStartedAt)})`);
|
|
1123
|
+
}
|
|
1124
|
+
else {
|
|
1125
|
+
recordStep.warn('skipped');
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
609
1128
|
const controlPlaneRecord = localRecordResult?.record;
|
|
610
1129
|
const controlPlaneDrift = localRecordResult?.drift;
|
|
611
1130
|
const controlPlanePromotion = localRecordResult?.promotion;
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1131
|
+
if (echo && controlPlaneRecord?.configDrift) {
|
|
1132
|
+
const configDrift = controlPlaneRecord.configDrift;
|
|
1133
|
+
echo.warn('HSX_W_DEPLOY_CONFIG_DRIFT', `Worker binding/config changed since the previously recorded deploy (${configDrift.previousDeployId}): fingerprint ${configDrift.previousFingerprint} -> ${configDrift.currentFingerprint}. This is expected if you intentionally changed bindings; investigate if you did not.`);
|
|
1134
|
+
}
|
|
1135
|
+
// Default order (skipped when we already uploaded first above): generate the
|
|
1136
|
+
// HubSpot bundle from the deployed Worker URL, then upload.
|
|
1137
|
+
if (!uploadFirstOrigin && hubspotUploadRequested && validation.ok) {
|
|
1138
|
+
const bundleStep = needsRuntime ? progress?.step('Generating HubSpot bundle') : undefined;
|
|
1139
|
+
try {
|
|
1140
|
+
await ensureHubSpotRuntimeProjectArtifacts({
|
|
1141
|
+
argv,
|
|
1142
|
+
root,
|
|
1143
|
+
app,
|
|
1144
|
+
workers,
|
|
1145
|
+
cloudflareDeployResult,
|
|
1146
|
+
needsRuntime,
|
|
1147
|
+
...(controlPlanePlan ? { controlPlanePlan } : {}),
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
catch (error) {
|
|
1151
|
+
bundleStep?.fail(firstErrorLine(error));
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
bundleStep?.ok();
|
|
1155
|
+
}
|
|
1156
|
+
if (!uploadFirstOrigin) {
|
|
1157
|
+
hubspotUploadResult =
|
|
1158
|
+
hubspotUploadRequested &&
|
|
1159
|
+
!planOnly &&
|
|
1160
|
+
validation.ok &&
|
|
1161
|
+
!(hubspotOnly && needsRuntime) &&
|
|
1162
|
+
(!needsRuntime ||
|
|
1163
|
+
hubspotOnly ||
|
|
1164
|
+
cloudflareDeployRequested ||
|
|
1165
|
+
resolveFlag(argv, '--runtime-origin'))
|
|
1166
|
+
? await runHubSpotUploadPhase()
|
|
1167
|
+
: undefined;
|
|
622
1168
|
}
|
|
623
|
-
const hubspotUploadResult = hubspotUploadRequested &&
|
|
624
|
-
!planOnly &&
|
|
625
|
-
validation.ok &&
|
|
626
|
-
!(hubspotOnly && needsRuntime) &&
|
|
627
|
-
(!needsRuntime ||
|
|
628
|
-
hubspotOnly ||
|
|
629
|
-
cloudflareDeployRequested ||
|
|
630
|
-
resolveFlag(argv, '--runtime-origin'))
|
|
631
|
-
? await executeHubSpotOnlyUpload({
|
|
632
|
-
argv,
|
|
633
|
-
root,
|
|
634
|
-
local: localHubspotUpload,
|
|
635
|
-
uploadOnly: hubspotUploadOnly,
|
|
636
|
-
})
|
|
637
|
-
: undefined;
|
|
638
1169
|
// Everything that gates a healthy deploy *except* the HubSpot upload, so we
|
|
639
1170
|
// can tell a total failure apart from "the Cloudflare worker is live but the
|
|
640
1171
|
// HubSpot upload failed". The CF deploy runs first and stands on its own, so
|
|
@@ -697,10 +1228,10 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
697
1228
|
if (json) {
|
|
698
1229
|
write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
699
1230
|
}
|
|
700
|
-
else {
|
|
1231
|
+
else if (progress) {
|
|
701
1232
|
renderDeployHuman({
|
|
702
|
-
|
|
703
|
-
|
|
1233
|
+
reporter: progress.reporter,
|
|
1234
|
+
counts: progress.counts(),
|
|
704
1235
|
plan,
|
|
705
1236
|
root,
|
|
706
1237
|
planOnly,
|
|
@@ -816,7 +1347,10 @@ async function emitAnonymousDeployEvent(input) {
|
|
|
816
1347
|
await fetch(endpoint, {
|
|
817
1348
|
method: 'POST',
|
|
818
1349
|
signal: controller.signal,
|
|
819
|
-
headers: {
|
|
1350
|
+
headers: {
|
|
1351
|
+
'content-type': 'application/json',
|
|
1352
|
+
accept: 'application/json',
|
|
1353
|
+
},
|
|
820
1354
|
body: JSON.stringify({
|
|
821
1355
|
schema_version: 1,
|
|
822
1356
|
event: 'deploy.succeeded',
|
|
@@ -872,28 +1406,168 @@ async function recordTenantDeployState(input) {
|
|
|
872
1406
|
updatedAt: now,
|
|
873
1407
|
}));
|
|
874
1408
|
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Final render for the streaming deploy transcript. The phases themselves were
|
|
1411
|
+
* already narrated as steps while they ran; human/plain mode closes with a
|
|
1412
|
+
* railed key/value fact summary + the done footer, while ndjson keeps emitting
|
|
1413
|
+
* the full legacy info/warn event block (its consumers see the same events as
|
|
1414
|
+
* before, with the step events purely additive).
|
|
1415
|
+
*/
|
|
875
1416
|
function renderDeployHuman(input) {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
.
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
:
|
|
1417
|
+
if (input.reporter.mode === 'ndjson') {
|
|
1418
|
+
renderDeployNdjsonSummary(input);
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const reporter = input.reporter;
|
|
1422
|
+
reporter.rows(buildDeploySummaryRows(input));
|
|
1423
|
+
const firstWorkerUrl = input.cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
1424
|
+
if (input.plan.partial) {
|
|
1425
|
+
// The worker deployed; only the HubSpot upload failed. Say so loudly so the
|
|
1426
|
+
// live worker isn't mistaken for a total failure despite the non-zero exit.
|
|
1427
|
+
reporter.warn('HSX_W_DEPLOY_PARTIAL', `Cloudflare worker is live${firstWorkerUrl ? ` at ${firstWorkerUrl}` : ''}, but the HubSpot upload failed (see above). Exit code 20.`);
|
|
1428
|
+
}
|
|
1429
|
+
reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1, input.counts);
|
|
1430
|
+
}
|
|
1431
|
+
function deployDoneMessage(input, firstWorkerUrl) {
|
|
1432
|
+
return input.planOnly
|
|
1433
|
+
? 'Planned'
|
|
1434
|
+
: input.plan.partial
|
|
1435
|
+
? `Deployed to Cloudflare${firstWorkerUrl ? ` (${firstWorkerUrl})` : ''}; HubSpot upload failed`
|
|
1436
|
+
: input.cloudflareDeployResult
|
|
1437
|
+
? input.linked
|
|
1438
|
+
? `Deployed${firstWorkerUrl ? ` to ${firstWorkerUrl}` : ''}`
|
|
1439
|
+
: `Deployed (unlinked)${firstWorkerUrl ? ` to ${firstWorkerUrl}` : ''}; machine_id ${input.machineId}. Run \`hs-x link\` to claim this history.`
|
|
1440
|
+
: input.hubspotUploadResult
|
|
1441
|
+
? input.linked
|
|
1442
|
+
? 'Deployed to HubSpot'
|
|
1443
|
+
: `Deployed (unlinked); machine_id ${input.machineId}. Run \`hs-x link\` to claim this history.`
|
|
1444
|
+
: 'Built artifacts';
|
|
1445
|
+
}
|
|
1446
|
+
/** The railed key/value facts of what this deploy produced (human/plain). */
|
|
1447
|
+
function buildDeploySummaryRows(input) {
|
|
1448
|
+
const rows = [];
|
|
1449
|
+
if (input.controlPlanePlan) {
|
|
1450
|
+
rows.push({
|
|
1451
|
+
key: 'deploy',
|
|
1452
|
+
value: input.controlPlanePlan.deployId,
|
|
1453
|
+
status: 'ok',
|
|
1454
|
+
detail: input.unlinkedDeployPlan ? 'unlinked' : 'control plane',
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
for (const worker of input.cloudflareDeployResult?.workers ?? []) {
|
|
1458
|
+
rows.push({
|
|
1459
|
+
key: 'worker',
|
|
1460
|
+
value: worker.workerName,
|
|
1461
|
+
...(worker.dryRun
|
|
1462
|
+
? { detail: 'dry-run' }
|
|
1463
|
+
: {
|
|
1464
|
+
status: 'ok',
|
|
1465
|
+
...(worker.url ? { detail: worker.url } : {}),
|
|
1466
|
+
}),
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
if (input.controlPlaneRecord) {
|
|
1470
|
+
rows.push({
|
|
1471
|
+
key: 'record',
|
|
1472
|
+
value: input.controlPlaneRecord.status,
|
|
1473
|
+
status: 'ok',
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
if (input.controlPlanePromotion) {
|
|
1477
|
+
rows.push({
|
|
1478
|
+
key: 'promoted',
|
|
1479
|
+
value: input.controlPlanePromotion.promoted.deployId,
|
|
1480
|
+
status: 'ok',
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
if (input.hubspotUploadResult) {
|
|
1484
|
+
const upload = input.hubspotUploadResult;
|
|
1485
|
+
const buildState = readStatus(upload.buildStatus) ?? 'UNKNOWN';
|
|
1486
|
+
rows.push({
|
|
1487
|
+
key: 'hubspot build',
|
|
1488
|
+
value: `#${upload.buildId} ${buildState}`,
|
|
1489
|
+
status: buildState === 'SUCCESS' ? 'ok' : 'fail',
|
|
1490
|
+
});
|
|
1491
|
+
if (upload.deployId !== undefined) {
|
|
1492
|
+
rows.push({
|
|
1493
|
+
key: 'hubspot deploy',
|
|
1494
|
+
value: `#${upload.deployId}`,
|
|
1495
|
+
status: 'ok',
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
else if (buildState === 'SUCCESS' &&
|
|
1499
|
+
upload.deploySkipped &&
|
|
1500
|
+
upload.deployStatus !== undefined) {
|
|
1501
|
+
const autoStatus = readStatus(upload.deployStatus) ?? 'UNKNOWN';
|
|
1502
|
+
rows.push({
|
|
1503
|
+
key: 'hubspot deploy',
|
|
1504
|
+
value: `auto-deploy ${autoStatus}`,
|
|
1505
|
+
status: autoStatus === 'SUCCESS' ? 'ok' : 'warn',
|
|
1506
|
+
detail: `build #${upload.buildId}`,
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
else if (buildState === 'SUCCESS' && upload.deploySkipped) {
|
|
1510
|
+
rows.push({
|
|
1511
|
+
key: 'hubspot deploy',
|
|
1512
|
+
value: 'skipped',
|
|
1513
|
+
detail: 'upload-only or auto-deploy handles it',
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
const authUrl = hubSpotAppAuthUrl(upload);
|
|
1517
|
+
if (!input.linked && authUrl) {
|
|
1518
|
+
rows.push({
|
|
1519
|
+
key: 'app auth',
|
|
1520
|
+
value: authUrl,
|
|
1521
|
+
hint: 'grab the client secret / configure install OAuth here',
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (!input.linked && input.cloudflareDeployResult) {
|
|
1526
|
+
rows.push({ key: 'history', value: machineDashboardUrl(input.machineId) });
|
|
1527
|
+
}
|
|
1528
|
+
return rows;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Streamed right after the HubSpot upload step resolves (human/plain): the
|
|
1532
|
+
* per-component build results and the auto-deploy verdict, so a failed build is
|
|
1533
|
+
* diagnosable from the terminal, not the HubSpot UI (run-008 build #2 failed
|
|
1534
|
+
* silently here).
|
|
1535
|
+
*/
|
|
1536
|
+
function streamHubSpotUploadOutcome(reporter, upload) {
|
|
1537
|
+
for (const subbuild of readSubbuildStatuses(upload.buildStatus)) {
|
|
1538
|
+
if (subbuild.status === 'SUCCESS') {
|
|
1539
|
+
reporter.info(` [ok] ${subbuild.name}${subbuild.type ? ` (${subbuild.type})` : ''}`);
|
|
1540
|
+
}
|
|
1541
|
+
else {
|
|
1542
|
+
reporter.error('HSX_E_DEPLOY_HUBSPOT_BUILD_COMPONENT', `${subbuild.name}${subbuild.type ? ` (${subbuild.type})` : ''}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const buildState = readStatus(upload.buildStatus) ?? 'UNKNOWN';
|
|
1546
|
+
if (upload.deployId === undefined &&
|
|
1547
|
+
buildState === 'SUCCESS' &&
|
|
1548
|
+
upload.deploySkipped &&
|
|
1549
|
+
upload.deployStatus !== undefined) {
|
|
1550
|
+
const autoStatus = readStatus(upload.deployStatus) ?? 'UNKNOWN';
|
|
1551
|
+
if (autoStatus !== 'SUCCESS') {
|
|
1552
|
+
reporter.warn('HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED', `HubSpot auto-deploy finished ${autoStatus} — the build is green but the app did NOT ship. Check the deploy log in the HubSpot UI (project ${upload.projectName}).`);
|
|
1553
|
+
for (const subbuild of readSubbuildStatuses(upload.deployStatus)) {
|
|
1554
|
+
if (subbuild.status !== 'SUCCESS') {
|
|
1555
|
+
reporter.info(` [x] ${subbuild.name}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Legacy single-shot summary for --json-stream: the same info/warn event block
|
|
1563
|
+
* the pre-streaming renderer emitted (steps stream additively alongside it).
|
|
1564
|
+
*/
|
|
1565
|
+
function renderDeployNdjsonSummary(input) {
|
|
1566
|
+
const reporter = input.reporter;
|
|
1567
|
+
const sources = workerSourceSummaries(input.workers);
|
|
894
1568
|
if (sources.length > 0)
|
|
895
1569
|
reporter.info(`Sources: ${sources.join(', ')}`);
|
|
896
|
-
const syncSchemaModes = input.workers
|
|
1570
|
+
const syncSchemaModes = workerSyncSchemaModes(input.workers);
|
|
897
1571
|
if (syncSchemaModes.length > 0) {
|
|
898
1572
|
reporter.info(`Portal schema management: ${syncSchemaModes.join(', ')}`);
|
|
899
1573
|
}
|
|
@@ -934,6 +1608,10 @@ function renderDeployHuman(input) {
|
|
|
934
1608
|
}
|
|
935
1609
|
if (input.controlPlaneRecord) {
|
|
936
1610
|
reporter.info(`Local control plane record status: ${input.controlPlaneRecord.status}`);
|
|
1611
|
+
const configDrift = input.controlPlaneRecord.configDrift;
|
|
1612
|
+
if (configDrift) {
|
|
1613
|
+
reporter.warn('HSX_W_DEPLOY_CONFIG_DRIFT', `Worker binding/config changed since the previously recorded deploy (${configDrift.previousDeployId}): fingerprint ${configDrift.previousFingerprint} -> ${configDrift.currentFingerprint}. This is expected if you intentionally changed bindings; investigate if you did not.`);
|
|
1614
|
+
}
|
|
937
1615
|
}
|
|
938
1616
|
if (input.controlPlanePromotion) {
|
|
939
1617
|
reporter.info(`Promoted deploy: ${input.controlPlanePromotion.promoted.deployId}`);
|
|
@@ -977,7 +1655,7 @@ function renderDeployHuman(input) {
|
|
|
977
1655
|
reporter.info(`HubSpot deploy: auto-deploy SUCCESS (build #${upload.buildId})`);
|
|
978
1656
|
}
|
|
979
1657
|
else {
|
|
980
|
-
reporter.warn('HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED', `HubSpot auto-deploy finished ${autoStatus} — the build is green but the app did NOT ship.
|
|
1658
|
+
reporter.warn('HSX_W_DEPLOY_HUBSPOT_AUTODEPLOY_FAILED', `HubSpot auto-deploy finished ${autoStatus} — the build is green but the app did NOT ship (project ${upload.projectName}). Run \`hs-x logs --source hubspot\` for HubSpot's side of the story.`);
|
|
981
1659
|
for (const subbuild of readSubbuildStatuses(upload.deployStatus)) {
|
|
982
1660
|
if (subbuild.status !== 'SUCCESS') {
|
|
983
1661
|
reporter.info(` [x] ${subbuild.name}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
@@ -996,24 +1674,9 @@ function renderDeployHuman(input) {
|
|
|
996
1674
|
reporter.info('Generated .hs-x/manifest.json and refs stubs.');
|
|
997
1675
|
const firstWorkerUrl = input.cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
998
1676
|
if (input.plan.partial) {
|
|
999
|
-
// The worker deployed; only the HubSpot upload failed. Say so loudly so the
|
|
1000
|
-
// live worker isn't mistaken for a total failure despite the non-zero exit.
|
|
1001
1677
|
reporter.warn('HSX_W_DEPLOY_PARTIAL', `Cloudflare worker is live${firstWorkerUrl ? ` at ${firstWorkerUrl}` : ''}, but the HubSpot upload failed (see above). Exit code 20.`);
|
|
1002
1678
|
}
|
|
1003
|
-
|
|
1004
|
-
? 'Planned'
|
|
1005
|
-
: input.plan.partial
|
|
1006
|
-
? `Deployed to Cloudflare${firstWorkerUrl ? ` (${firstWorkerUrl})` : ''}; HubSpot upload failed`
|
|
1007
|
-
: input.cloudflareDeployResult
|
|
1008
|
-
? input.linked
|
|
1009
|
-
? `Deployed${firstWorkerUrl ? ` to ${firstWorkerUrl}` : ''}`
|
|
1010
|
-
: `Deployed (unlinked)${firstWorkerUrl ? ` to ${firstWorkerUrl}` : ''}; machine_id ${input.machineId}. Run \`hs-x link\` to claim this history.`
|
|
1011
|
-
: input.hubspotUploadResult
|
|
1012
|
-
? input.linked
|
|
1013
|
-
? 'Deployed to HubSpot'
|
|
1014
|
-
: `Deployed (unlinked); machine_id ${input.machineId}. Run \`hs-x link\` to claim this history.`
|
|
1015
|
-
: 'Built artifacts';
|
|
1016
|
-
reporter.done(doneMessage, input.plan.ok ? 0 : input.plan.partial ? 20 : 1);
|
|
1679
|
+
reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1);
|
|
1017
1680
|
}
|
|
1018
1681
|
function machineDashboardUrl(machineId) {
|
|
1019
1682
|
const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
|
|
@@ -1026,10 +1689,17 @@ function machineDashboardUrl(machineId) {
|
|
|
1026
1689
|
* returns undefined when either is unknown (e.g. a control-plane lease deploy).
|
|
1027
1690
|
*/
|
|
1028
1691
|
function hubSpotAppAuthUrl(result) {
|
|
1029
|
-
|
|
1692
|
+
return buildHubSpotAppAuthUrl({
|
|
1693
|
+
developerAccountId: result.developerAccountId,
|
|
1694
|
+
projectName: result.projectName,
|
|
1695
|
+
appUid: result.appUid,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
function buildHubSpotAppAuthUrl(input) {
|
|
1699
|
+
if (!input.developerAccountId || !input.appUid)
|
|
1030
1700
|
return undefined;
|
|
1031
1701
|
const base = process.env.HSX_HUBSPOT_APP_BASE_URL ?? 'https://app.hubspot.com';
|
|
1032
|
-
return `${base.replace(/\/$/, '')}/developer-projects/${encodeURIComponent(
|
|
1702
|
+
return `${base.replace(/\/$/, '')}/developer-projects/${encodeURIComponent(input.developerAccountId)}/project/${encodeURIComponent(input.projectName)}/component/${encodeURIComponent(input.appUid)}/auth`;
|
|
1033
1703
|
}
|
|
1034
1704
|
function formatDiagnostic(d) {
|
|
1035
1705
|
if (d && typeof d === 'object') {
|
|
@@ -1050,7 +1720,10 @@ async function readPortalSchema(argv, workers) {
|
|
|
1050
1720
|
if (!isRecord(value) || !Array.isArray(value.objects)) {
|
|
1051
1721
|
throw new Error('--portal-schema-fixture must point to JSON with an objects array.');
|
|
1052
1722
|
}
|
|
1053
|
-
return {
|
|
1723
|
+
return {
|
|
1724
|
+
observed: value,
|
|
1725
|
+
source: 'fixture',
|
|
1726
|
+
};
|
|
1054
1727
|
}
|
|
1055
1728
|
if (!live && !local) {
|
|
1056
1729
|
return undefined;
|
|
@@ -1075,7 +1748,9 @@ async function readLivePortalSchema(argv, workers, options) {
|
|
|
1075
1748
|
const objects = [];
|
|
1076
1749
|
for (const objectType of targets) {
|
|
1077
1750
|
try {
|
|
1078
|
-
const properties = await client.schemaManagement.listProperties({
|
|
1751
|
+
const properties = await client.schemaManagement.listProperties({
|
|
1752
|
+
objectType,
|
|
1753
|
+
});
|
|
1079
1754
|
objects.push({
|
|
1080
1755
|
name: objectType,
|
|
1081
1756
|
properties: properties.results.map(toObservedPortalProperty),
|
|
@@ -1143,7 +1818,12 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
|
|
|
1143
1818
|
await run();
|
|
1144
1819
|
}
|
|
1145
1820
|
catch (cause) {
|
|
1146
|
-
throw new PortalSchemaApplyError({
|
|
1821
|
+
throw new PortalSchemaApplyError({
|
|
1822
|
+
action: label,
|
|
1823
|
+
objectType,
|
|
1824
|
+
applied: [...applied],
|
|
1825
|
+
cause,
|
|
1826
|
+
});
|
|
1147
1827
|
}
|
|
1148
1828
|
applied.push(label);
|
|
1149
1829
|
};
|
|
@@ -1356,26 +2036,44 @@ function portalSchemaApplyFailure(input) {
|
|
|
1356
2036
|
}, null, 2)}\n`);
|
|
1357
2037
|
return { exitCode };
|
|
1358
2038
|
}
|
|
1359
|
-
const reporter =
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
if (input.
|
|
2039
|
+
const reporter = input.progress?.reporter ??
|
|
2040
|
+
createReporter({
|
|
2041
|
+
command: 'deploy',
|
|
2042
|
+
argv: input.argv,
|
|
2043
|
+
startedAt: input.startedAt,
|
|
2044
|
+
});
|
|
2045
|
+
if (!input.progress) {
|
|
2046
|
+
// Standalone render (no streamed transcript preceded this failure):
|
|
2047
|
+
// reconstruct the header + validation context around the error.
|
|
2048
|
+
reporter.header(basename(input.root));
|
|
2049
|
+
const capCount = input.workers.reduce((count, worker) => count + worker.capabilities.length, 0);
|
|
2050
|
+
reporter
|
|
2051
|
+
.step('Validating project')
|
|
2052
|
+
.ok(`${input.workers.length} workers, ${capCount} capabilities`);
|
|
2053
|
+
if (input.portalSchemaPlan) {
|
|
2054
|
+
reporter.info(renderPortalSchemaPlan(input.portalSchemaPlan).trimEnd());
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
else if (reporter.mode === 'ndjson' && input.portalSchemaPlan) {
|
|
2058
|
+
// The streamed transcript gates the plan info to human/plain; keep the
|
|
2059
|
+
// ndjson event stream carrying it on the failure path.
|
|
1366
2060
|
reporter.info(renderPortalSchemaPlan(input.portalSchemaPlan).trimEnd());
|
|
1367
2061
|
}
|
|
1368
2062
|
if (applyError && applyError.applied.length > 0) {
|
|
1369
2063
|
for (const done of applyError.applied)
|
|
1370
2064
|
reporter.info(`APPLIED ${done}`);
|
|
1371
2065
|
}
|
|
1372
|
-
|
|
2066
|
+
// With a streamed transcript the caller already resolved the phase step with
|
|
2067
|
+
// `.fail(...)`; only the standalone render reconstructs it here.
|
|
2068
|
+
if (!input.progress) {
|
|
2069
|
+
reporter.step('Applying portal schema').fail(applyError?.action);
|
|
2070
|
+
}
|
|
1373
2071
|
reporter.error(code, message, {
|
|
1374
2072
|
hint,
|
|
1375
2073
|
cause: hubSpotDetail,
|
|
1376
2074
|
docs_url: `https://hs-x.dev/errors/${code}`,
|
|
1377
2075
|
});
|
|
1378
|
-
reporter.done(undefined, exitCode);
|
|
2076
|
+
reporter.done(undefined, exitCode, input.progress?.counts());
|
|
1379
2077
|
return { exitCode };
|
|
1380
2078
|
}
|
|
1381
2079
|
function renderPortalSchemaPlan(plan) {
|
|
@@ -1430,13 +2128,48 @@ function defaultHubSpotFieldType(type) {
|
|
|
1430
2128
|
return 'text';
|
|
1431
2129
|
}
|
|
1432
2130
|
}
|
|
1433
|
-
async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, }) {
|
|
2131
|
+
async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
|
|
1434
2132
|
// Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
|
|
1435
2133
|
// an unlinked deploy can't leave a Worker live and then error on the
|
|
1436
2134
|
// tenant-state write for a missing account id.
|
|
1437
2135
|
if (!controlPlaneUrl && !dryRun) {
|
|
1438
2136
|
await preflightTenantStateCredentials({ argv, root });
|
|
1439
2137
|
}
|
|
2138
|
+
const hasCardBackends = workers.some((worker) => worker.capabilities.some((capability) => capability.kind === 'card-backend'));
|
|
2139
|
+
if (hasCardBackends && !dryRun) {
|
|
2140
|
+
const appConfig = await readHsxAppConfig(root);
|
|
2141
|
+
const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
|
|
2142
|
+
const authUrl = await resolveHubSpotAppAuthUrl(argv, root);
|
|
2143
|
+
const hubspotAppId = requireHubSpotCardAppId(readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root))), authUrl);
|
|
2144
|
+
const stored = controlPlaneUrl
|
|
2145
|
+
? await readDeployHubSpotOAuthSecret({
|
|
2146
|
+
controlPlaneUrl,
|
|
2147
|
+
userId,
|
|
2148
|
+
accountId: controlPlanePlan.accountId,
|
|
2149
|
+
projectId: controlPlanePlan.projectId,
|
|
2150
|
+
environment,
|
|
2151
|
+
hubSpotAppId: hubspotAppId,
|
|
2152
|
+
})
|
|
2153
|
+
: undefined;
|
|
2154
|
+
const credentials = await resolveHubSpotRuntimeCredentials({
|
|
2155
|
+
auth: appConfig.auth,
|
|
2156
|
+
requiresCardSignatureSecret: true,
|
|
2157
|
+
dryRun: false,
|
|
2158
|
+
allowPrompt: !argv.includes('--json') && !argv.includes('--yes') && isInteractive(),
|
|
2159
|
+
hubSpotClientId: resolveFlag(argv, '--hubspot-client-id') ??
|
|
2160
|
+
stored?.clientId ??
|
|
2161
|
+
process.env.HSX_HUBSPOT_CLIENT_ID,
|
|
2162
|
+
hubSpotClientSecret: resolveFlag(argv, '--hubspot-client-secret') ??
|
|
2163
|
+
stored?.clientSecret ??
|
|
2164
|
+
process.env.HSX_HUBSPOT_CLIENT_SECRET,
|
|
2165
|
+
...(authUrl ? { authUrl } : {}),
|
|
2166
|
+
});
|
|
2167
|
+
await validateHubSpotClientCredentials({
|
|
2168
|
+
clientId: credentials.clientId,
|
|
2169
|
+
clientSecret: credentials.clientSecret,
|
|
2170
|
+
...(authUrl ? { authUrl } : {}),
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
1440
2173
|
const sourcePaths = await discoverWorkerSourcePaths(root);
|
|
1441
2174
|
const concurrency = Math.max(1, Math.min(4, workers.length));
|
|
1442
2175
|
const results = await Effect.runPromise(Effect.forEach(workers.map((worker, index) => ({ worker, index })), ({ worker, index }) => Effect.promise(() => executeCloudflareWorkerDeploy({
|
|
@@ -1444,6 +2177,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
|
|
|
1444
2177
|
root,
|
|
1445
2178
|
worker,
|
|
1446
2179
|
index,
|
|
2180
|
+
allowCredentialPrompt: !hasCardBackends && index === 0,
|
|
1447
2181
|
sourcePaths,
|
|
1448
2182
|
controlPlanePlan,
|
|
1449
2183
|
userId,
|
|
@@ -1451,13 +2185,18 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
|
|
|
1451
2185
|
heartbeatEnabled,
|
|
1452
2186
|
dryRun,
|
|
1453
2187
|
...(controlPlaneUrl ? { controlPlaneUrl } : {}),
|
|
2188
|
+
...(note ? { note } : {}),
|
|
1454
2189
|
})), { concurrency }));
|
|
1455
2190
|
return {
|
|
1456
2191
|
deployId: controlPlanePlan.deployId,
|
|
1457
2192
|
workers: results,
|
|
1458
2193
|
};
|
|
1459
2194
|
}
|
|
1460
|
-
async function executeCloudflareWorkerDeploy({ argv, root, worker, index, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, }) {
|
|
2195
|
+
async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowCredentialPrompt, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
|
|
2196
|
+
const emitNote = note ??
|
|
2197
|
+
((line) => {
|
|
2198
|
+
process.stderr.write(line);
|
|
2199
|
+
});
|
|
1461
2200
|
const workerSourcePath = sourcePaths.get(worker.name);
|
|
1462
2201
|
if (!workerSourcePath) {
|
|
1463
2202
|
throw new Error(`Could not find source file for worker ${worker.name}.`);
|
|
@@ -1468,12 +2207,24 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1468
2207
|
}
|
|
1469
2208
|
const entrypointPath = join(root, '.hs-x', 'cloudflare', `${fileSlug(worker.name)}.entry.ts`);
|
|
1470
2209
|
await mkdir(dirname(entrypointPath), { recursive: true });
|
|
2210
|
+
// ADR-023 S04: translate this worker's declared sync schedules into a
|
|
2211
|
+
// deduplicated Cloudflare cron-trigger plan. The same plan feeds both the
|
|
2212
|
+
// wrangler `[triggers] crons` list and the generated `scheduled` handler's
|
|
2213
|
+
// baked dispatch map, so the fired `event.cron` always matches a sync.
|
|
2214
|
+
const cronPlan = planWorkerCronTriggers(worker);
|
|
1471
2215
|
const appConfig = await readHsxAppConfig(root);
|
|
1472
2216
|
const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
|
|
1473
2217
|
const deployCliConfig = loadCliConfig(argv);
|
|
1474
2218
|
const runtimeControlPlaneUrl = formatConfigUrl(deployCliConfig.runtimeControlPlaneUrl ?? new URL(DEFAULT_CONTROL_PLANE_URL));
|
|
1475
2219
|
await writeFile(entrypointPath, renderCloudflareWorkerEntrypoint({
|
|
1476
2220
|
workerImportPath: relativeImportPath(dirname(entrypointPath), workerSourcePath),
|
|
2221
|
+
requireActiveCardInstall: appConfig.auth === 'oauth',
|
|
2222
|
+
...(cronPlan.dispatch.length > 0 ? { syncCron: cronPlan } : {}),
|
|
2223
|
+
...(appConfig.appEventBatching !== undefined
|
|
2224
|
+
? { appEventBatching: appConfig.appEventBatching }
|
|
2225
|
+
: {}),
|
|
2226
|
+
...(appConfig.rateLimits !== undefined ? { rateLimits: appConfig.rateLimits } : {}),
|
|
2227
|
+
...(appConfig.redaction !== undefined ? { redaction: appConfig.redaction } : {}),
|
|
1477
2228
|
...(controlPlaneUrl && appConfig.billing
|
|
1478
2229
|
? {
|
|
1479
2230
|
billing: {
|
|
@@ -1514,7 +2265,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1514
2265
|
// Resolution: flag → env → the binding persisted by the last successful
|
|
1515
2266
|
// upload (.hs-x/hubspot.json). Users should never have to replay an app id
|
|
1516
2267
|
// the CLI already learned (owner directive, run-008).
|
|
1517
|
-
const hubspotAppId = readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root));
|
|
2268
|
+
const hubspotAppId = readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root)));
|
|
1518
2269
|
const installRuntimeBinding = hubspotAppId
|
|
1519
2270
|
? controlPlaneUrl
|
|
1520
2271
|
? await ensureDeployInstallRuntimeBinding({
|
|
@@ -1553,6 +2304,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1553
2304
|
install: installRuntimeBinding,
|
|
1554
2305
|
controlPlaneUrl,
|
|
1555
2306
|
userId,
|
|
2307
|
+
...(note ? { note } : {}),
|
|
1556
2308
|
})
|
|
1557
2309
|
: undefined;
|
|
1558
2310
|
const hubSpotOAuthSecret = hubspotAppId && controlPlaneUrl
|
|
@@ -1565,25 +2317,45 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1565
2317
|
hubSpotAppId: hubspotAppId,
|
|
1566
2318
|
})
|
|
1567
2319
|
: undefined;
|
|
1568
|
-
const hubSpotClientId =
|
|
1569
|
-
|
|
2320
|
+
const hubSpotClientId = resolveFlag(argv, '--hubspot-client-id') ??
|
|
2321
|
+
hubSpotOAuthSecret?.clientId ??
|
|
1570
2322
|
process.env.HSX_HUBSPOT_CLIENT_ID;
|
|
1571
2323
|
// The client secret comes from the control plane when linked; unlinked it can
|
|
1572
2324
|
// be supplied directly so the deployed Worker can complete the install OAuth
|
|
1573
2325
|
// token exchange (otherwise /oauth-start 500s with "missing HSX_HUBSPOT_CLIENT_ID").
|
|
1574
|
-
const hubSpotClientSecret =
|
|
1575
|
-
|
|
2326
|
+
const hubSpotClientSecret = resolveFlag(argv, '--hubspot-client-secret') ??
|
|
2327
|
+
hubSpotOAuthSecret?.clientSecret ??
|
|
1576
2328
|
process.env.HSX_HUBSPOT_CLIENT_SECRET;
|
|
1577
|
-
|
|
1578
|
-
//
|
|
1579
|
-
|
|
2329
|
+
const hasCardBackends = worker.capabilities.some((capability) => capability.kind === 'card-backend');
|
|
2330
|
+
// Interactively fill credentials required by OAuth and card backends. A card
|
|
2331
|
+
// backend is never deployed without the v3-signature HMAC key.
|
|
2332
|
+
const { clientId: resolvedHubSpotClientId, clientSecret: resolvedHubSpotClientSecret } = await resolveHubSpotRuntimeCredentials({
|
|
1580
2333
|
auth: appConfig.auth,
|
|
1581
|
-
|
|
2334
|
+
requiresCardSignatureSecret: hasCardBackends,
|
|
1582
2335
|
dryRun,
|
|
1583
|
-
allowPrompt:
|
|
2336
|
+
allowPrompt: allowCredentialPrompt,
|
|
1584
2337
|
hubSpotClientId,
|
|
1585
2338
|
hubSpotClientSecret,
|
|
1586
2339
|
});
|
|
2340
|
+
// Surface the OAuth client id + where it came from. This binding is the HMAC
|
|
2341
|
+
// key for card v3-signature verification; a wrong one fails silently at
|
|
2342
|
+
// runtime (401 invalid_hubspot_signature) hours later, so make it visible at
|
|
2343
|
+
// deploy time — if it looks wrong, `hs-x secrets hubspot-oauth set` corrects
|
|
2344
|
+
// the control-plane copy. Once per deploy (index 0), stderr to keep --json clean.
|
|
2345
|
+
if ((appConfig.auth === 'oauth' || hasCardBackends) &&
|
|
2346
|
+
(resolvedHubSpotClientId || resolvedHubSpotClientSecret) &&
|
|
2347
|
+
index === 0 &&
|
|
2348
|
+
!dryRun) {
|
|
2349
|
+
const source = resolveFlag(argv, '--hubspot-client-secret') || resolveFlag(argv, '--hubspot-client-id')
|
|
2350
|
+
? 'flag'
|
|
2351
|
+
: hubSpotOAuthSecret
|
|
2352
|
+
? 'control plane'
|
|
2353
|
+
: process.env.HSX_HUBSPOT_CLIENT_SECRET || process.env.HSX_HUBSPOT_CLIENT_ID
|
|
2354
|
+
? 'env/prompt'
|
|
2355
|
+
: 'missing';
|
|
2356
|
+
emitNote(` [hubspot auth]${resolvedHubSpotClientId ? ` client id ${resolvedHubSpotClientId};` : ''} client secret ${resolvedHubSpotClientSecret ? 'configured' : 'missing'} (source: ${source})` +
|
|
2357
|
+
`${resolvedHubSpotClientSecret ? '' : ' — no client secret; card verification will fail closed'}\n`);
|
|
2358
|
+
}
|
|
1587
2359
|
const hubSpotScopesForRuntime = await readHsxAppScopesForDeploy(root);
|
|
1588
2360
|
const billingRuntimeToken = controlPlaneUrl && appConfig.billing && !dryRun
|
|
1589
2361
|
? await requestDeployBillingRuntimeToken({
|
|
@@ -1599,6 +2371,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1599
2371
|
workerName,
|
|
1600
2372
|
entrypointPath: relativeImportPath(dirname(configPath), entrypointPath),
|
|
1601
2373
|
compatibilityDate,
|
|
2374
|
+
...(cronPlan.crons.length > 0 ? { crons: cronPlan.crons } : {}),
|
|
1602
2375
|
...(installRuntimeBinding
|
|
1603
2376
|
? { installKvNamespaceId: installRuntimeBinding.installKvNamespaceId }
|
|
1604
2377
|
: {}),
|
|
@@ -1633,7 +2406,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1633
2406
|
.then((entry) => entry.isDirectory())
|
|
1634
2407
|
.catch(() => false);
|
|
1635
2408
|
if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && !migrationsDirExists) {
|
|
1636
|
-
|
|
2409
|
+
emitNote('warning: @hs-x/runtime did not ship its tenant migrations (pre-0.2.4 package) — tenant D1 tables were NOT applied; upgrade @hs-x/runtime.\n');
|
|
1637
2410
|
}
|
|
1638
2411
|
if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && migrationsDirExists) {
|
|
1639
2412
|
try {
|
|
@@ -1776,7 +2549,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1776
2549
|
});
|
|
1777
2550
|
}
|
|
1778
2551
|
catch (error) {
|
|
1779
|
-
|
|
2552
|
+
emitNote(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1780
2553
|
}
|
|
1781
2554
|
}
|
|
1782
2555
|
// Without the client secret on the Worker, the runtime accepts UNSIGNED
|
|
@@ -1835,14 +2608,37 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
|
|
|
1835
2608
|
hubSpotAppId: input.hubSpotAppId,
|
|
1836
2609
|
});
|
|
1837
2610
|
if (existing) {
|
|
2611
|
+
// ADR-022 fail-closed check: the binding claims an escrowed key, so the
|
|
2612
|
+
// key must resolve AND open the sealed canary before we touch anything.
|
|
2613
|
+
// Nothing has been mutated at this point — a custody failure aborts the
|
|
2614
|
+
// deploy with an actionable error and zero writes.
|
|
2615
|
+
if (existing.tokenKeyCustody.kind === 'escrowed' && !input.dryRun) {
|
|
2616
|
+
await resolveEscrowedTokenKey({ custody: existing.tokenKeyCustody });
|
|
2617
|
+
}
|
|
1838
2618
|
return {
|
|
1839
2619
|
installKvNamespaceId: existing.installKvNamespaceId,
|
|
1840
2620
|
installKvNamespaceName: existing.installKvNamespaceName,
|
|
1841
2621
|
tokenKeySecretName: existing.tokenKeySecretName,
|
|
2622
|
+
tokenKeyCustody: existing.tokenKeyCustody,
|
|
1842
2623
|
};
|
|
1843
2624
|
}
|
|
1844
2625
|
if (input.dryRun)
|
|
1845
2626
|
return undefined;
|
|
2627
|
+
// ADR-022: generate the token key and escrow it BEFORE creating any
|
|
2628
|
+
// Cloudflare resource — if custody is unavailable, the deploy stops with
|
|
2629
|
+
// zero mutations instead of minting a key that exists nowhere durable.
|
|
2630
|
+
const tokenKeySecretName = 'HSX_TOKEN_KEY';
|
|
2631
|
+
const tokenKeySecretValue = randomBytes(32).toString('base64url');
|
|
2632
|
+
const tokenKeyCustody = await escrowTokenKey({
|
|
2633
|
+
adapter: defaultTokenKeyCustodyAdapter(),
|
|
2634
|
+
custodyId: tokenKeyCustodyIdFor({
|
|
2635
|
+
ownerId: pointer.ownerId,
|
|
2636
|
+
projectId: input.projectId,
|
|
2637
|
+
environment: input.environment,
|
|
2638
|
+
hubSpotAppId: input.hubSpotAppId,
|
|
2639
|
+
}),
|
|
2640
|
+
tokenKeySecretValue,
|
|
2641
|
+
});
|
|
1846
2642
|
const installKvNamespaceName = installKvNamespaceNameFor({
|
|
1847
2643
|
accountId: input.ownerId,
|
|
1848
2644
|
projectId: input.projectId,
|
|
@@ -1867,8 +2663,8 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
|
|
|
1867
2663
|
if (!installKvNamespaceId) {
|
|
1868
2664
|
throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
|
|
1869
2665
|
}
|
|
1870
|
-
const tokenKeySecretName = 'HSX_TOKEN_KEY';
|
|
1871
2666
|
await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
|
|
2667
|
+
schemaVersion: 2,
|
|
1872
2668
|
ownerId: pointer.ownerId,
|
|
1873
2669
|
projectId: input.projectId,
|
|
1874
2670
|
hubSpotAppId: input.hubSpotAppId,
|
|
@@ -1876,18 +2672,22 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
|
|
|
1876
2672
|
installKvNamespaceId,
|
|
1877
2673
|
installKvNamespaceName,
|
|
1878
2674
|
tokenKeySecretName,
|
|
2675
|
+
tokenKeyCustody,
|
|
1879
2676
|
updatedAt: new Date().toISOString(),
|
|
1880
2677
|
}));
|
|
1881
2678
|
return {
|
|
1882
2679
|
installKvNamespaceId,
|
|
1883
2680
|
installKvNamespaceName,
|
|
1884
2681
|
tokenKeySecretName,
|
|
1885
|
-
tokenKeySecretValue
|
|
2682
|
+
tokenKeySecretValue,
|
|
2683
|
+
tokenKeyCustody,
|
|
1886
2684
|
};
|
|
1887
2685
|
}
|
|
1888
2686
|
async function ensureDeployInstallRuntimeBinding(input) {
|
|
1889
2687
|
const existing = await readDeployInstallRuntimeBinding(input);
|
|
1890
2688
|
if (existing) {
|
|
2689
|
+
// Control-plane metadata carries no key custody (and never key bytes) —
|
|
2690
|
+
// on a machine that never generated the key this is worker-secret-only.
|
|
1891
2691
|
return {
|
|
1892
2692
|
installKvNamespaceId: existing.installKvNamespaceId,
|
|
1893
2693
|
installKvNamespaceName: existing.installKvNamespaceName,
|
|
@@ -1897,6 +2697,20 @@ async function ensureDeployInstallRuntimeBinding(input) {
|
|
|
1897
2697
|
if (input.dryRun) {
|
|
1898
2698
|
return undefined;
|
|
1899
2699
|
}
|
|
2700
|
+
// ADR-022: the linked path generates the token key at the same seam, so it
|
|
2701
|
+
// gets the same escrow — before any Cloudflare mutation. Only the custody
|
|
2702
|
+
// REFERENCE lands in local state; the control plane never sees key bytes.
|
|
2703
|
+
const tokenKeySecretValue = randomBytes(32).toString('base64url');
|
|
2704
|
+
const tokenKeyCustody = await escrowTokenKey({
|
|
2705
|
+
adapter: defaultTokenKeyCustodyAdapter(),
|
|
2706
|
+
custodyId: tokenKeyCustodyIdFor({
|
|
2707
|
+
ownerId: input.accountId,
|
|
2708
|
+
projectId: input.projectId,
|
|
2709
|
+
environment: input.environment,
|
|
2710
|
+
hubSpotAppId: input.hubSpotAppId,
|
|
2711
|
+
}),
|
|
2712
|
+
tokenKeySecretValue,
|
|
2713
|
+
});
|
|
1900
2714
|
const installKvNamespaceName = installKvNamespaceNameFor({
|
|
1901
2715
|
accountId: input.accountId,
|
|
1902
2716
|
projectId: input.projectId,
|
|
@@ -1932,7 +2746,8 @@ async function ensureDeployInstallRuntimeBinding(input) {
|
|
|
1932
2746
|
installKvNamespaceId,
|
|
1933
2747
|
installKvNamespaceName,
|
|
1934
2748
|
tokenKeySecretName,
|
|
1935
|
-
tokenKeySecretValue
|
|
2749
|
+
tokenKeySecretValue,
|
|
2750
|
+
tokenKeyCustody,
|
|
1936
2751
|
};
|
|
1937
2752
|
}
|
|
1938
2753
|
async function readDeployInstallRuntimeBinding(input) {
|
|
@@ -2062,8 +2877,23 @@ async function ensureTenantDataPlane(input) {
|
|
|
2062
2877
|
const tenantD1DatabaseName = existing?.tenantD1DatabaseName ?? tenantD1DatabaseNameFor(input);
|
|
2063
2878
|
let tenantD1DatabaseId = existing?.tenantD1DatabaseId;
|
|
2064
2879
|
if (!tenantD1DatabaseId) {
|
|
2065
|
-
|
|
2066
|
-
|
|
2880
|
+
// Create, or ADOPT on a name conflict: resource names are deterministic,
|
|
2881
|
+
// so a database that already exists is this project's own tenant D1 from
|
|
2882
|
+
// an earlier deploy whose local state was lost — failing here would brick
|
|
2883
|
+
// every redeploy while the data-holding database sits one lookup away.
|
|
2884
|
+
const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
|
|
2885
|
+
if (!/already exists/i.test(String(error)))
|
|
2886
|
+
throw error;
|
|
2887
|
+
return undefined;
|
|
2888
|
+
});
|
|
2889
|
+
if (created) {
|
|
2890
|
+
tenantD1DatabaseId = extractD1DatabaseId(`${created.stdout}\n${created.stderr}`);
|
|
2891
|
+
}
|
|
2892
|
+
else {
|
|
2893
|
+
const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
|
|
2894
|
+
tenantD1DatabaseId =
|
|
2895
|
+
extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
|
|
2896
|
+
}
|
|
2067
2897
|
if (!tenantD1DatabaseId) {
|
|
2068
2898
|
throw new Error('Could not read tenant D1 database id from wrangler output.');
|
|
2069
2899
|
}
|
|
@@ -2071,8 +2901,35 @@ async function ensureTenantDataPlane(input) {
|
|
|
2071
2901
|
const flagsKvNamespaceName = existing?.flagsKvNamespaceName ?? flagsKvNamespaceNameFor(input);
|
|
2072
2902
|
let flagsKvNamespaceId = existing?.flagsKvNamespaceId;
|
|
2073
2903
|
if (!flagsKvNamespaceId) {
|
|
2074
|
-
|
|
2075
|
-
|
|
2904
|
+
// Same create-or-adopt posture as the tenant D1 above.
|
|
2905
|
+
const created = await runCloudflareCommand([
|
|
2906
|
+
'bun',
|
|
2907
|
+
'x',
|
|
2908
|
+
'wrangler',
|
|
2909
|
+
'kv',
|
|
2910
|
+
'namespace',
|
|
2911
|
+
'create',
|
|
2912
|
+
flagsKvNamespaceName,
|
|
2913
|
+
'--binding',
|
|
2914
|
+
'FLAGS_KV',
|
|
2915
|
+
], { cwd: input.root, env }).catch((error) => {
|
|
2916
|
+
if (!/already exists/i.test(String(error)))
|
|
2917
|
+
throw error;
|
|
2918
|
+
return undefined;
|
|
2919
|
+
});
|
|
2920
|
+
if (created) {
|
|
2921
|
+
flagsKvNamespaceId = extractKvNamespaceId(`${created.stdout}\n${created.stderr}`);
|
|
2922
|
+
}
|
|
2923
|
+
else {
|
|
2924
|
+
const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
|
|
2925
|
+
try {
|
|
2926
|
+
const namespaces = JSON.parse(listed.stdout);
|
|
2927
|
+
flagsKvNamespaceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
|
|
2928
|
+
}
|
|
2929
|
+
catch {
|
|
2930
|
+
flagsKvNamespaceId = undefined;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2076
2933
|
if (!flagsKvNamespaceId) {
|
|
2077
2934
|
throw new Error('Could not read flags KV namespace id from wrangler output.');
|
|
2078
2935
|
}
|
|
@@ -2081,6 +2938,7 @@ async function ensureTenantDataPlane(input) {
|
|
|
2081
2938
|
const reusedSecret = existing?.syncGrantSecretValue;
|
|
2082
2939
|
const syncGrantSecretValue = reusedSecret ?? randomBytes(32).toString('base64url');
|
|
2083
2940
|
await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
|
|
2941
|
+
schemaVersion: 2,
|
|
2084
2942
|
ownerId: pointer.ownerId,
|
|
2085
2943
|
tenantScopeAccountId: input.accountId,
|
|
2086
2944
|
projectId: input.projectId,
|
|
@@ -2089,6 +2947,15 @@ async function ensureTenantDataPlane(input) {
|
|
|
2089
2947
|
installKvNamespaceId: input.install.installKvNamespaceId,
|
|
2090
2948
|
installKvNamespaceName: input.install.installKvNamespaceName,
|
|
2091
2949
|
tokenKeySecretName: input.install.tokenKeySecretName,
|
|
2950
|
+
// ADR-022: carry custody through the rewrite. A binding re-hydrated
|
|
2951
|
+
// from the control plane on a machine that never held the key is
|
|
2952
|
+
// honestly recorded as worker-secret-only (the link preflight fails
|
|
2953
|
+
// closed on that shape).
|
|
2954
|
+
// A custody freshly minted this deploy (install.tokenKeyCustody) wins
|
|
2955
|
+
// over whatever a previous binding recorded — the Worker secret is
|
|
2956
|
+
// being set to the NEW key in this same deploy.
|
|
2957
|
+
tokenKeyCustody: input.install.tokenKeyCustody ??
|
|
2958
|
+
existing?.tokenKeyCustody ?? { kind: 'worker-secret-only' },
|
|
2092
2959
|
tenantD1DatabaseId,
|
|
2093
2960
|
tenantD1DatabaseName,
|
|
2094
2961
|
flagsKvNamespaceId,
|
|
@@ -2113,7 +2980,13 @@ async function ensureTenantDataPlane(input) {
|
|
|
2113
2980
|
});
|
|
2114
2981
|
}
|
|
2115
2982
|
catch (error) {
|
|
2116
|
-
|
|
2983
|
+
const warning = `warning: could not register the sync grant secret with the control plane: ${error instanceof Error ? error.message : String(error)}\n`;
|
|
2984
|
+
if (input.note) {
|
|
2985
|
+
input.note(warning);
|
|
2986
|
+
}
|
|
2987
|
+
else {
|
|
2988
|
+
process.stderr.write(warning);
|
|
2989
|
+
}
|
|
2117
2990
|
}
|
|
2118
2991
|
}
|
|
2119
2992
|
return {
|
|
@@ -2187,6 +3060,12 @@ export function renderWranglerConfig(input) {
|
|
|
2187
3060
|
lines.push(`migrations_dir = "${tomlString(input.tenantMigrationsDir)}"`);
|
|
2188
3061
|
}
|
|
2189
3062
|
}
|
|
3063
|
+
// ADR-023 S04: cron triggers generated from declared sync schedules. One
|
|
3064
|
+
// entry per distinct cadence (syncs sharing a schedule collapse into it);
|
|
3065
|
+
// the generated `scheduled` handler fans each fire out to its syncs.
|
|
3066
|
+
if (input.crons && input.crons.length > 0) {
|
|
3067
|
+
lines.push('', '[triggers]', `crons = [ ${input.crons.map((cron) => `"${tomlString(cron)}"`).join(', ')} ]`);
|
|
3068
|
+
}
|
|
2190
3069
|
return `${lines.join('\n')}\n`;
|
|
2191
3070
|
}
|
|
2192
3071
|
function tomlString(value) {
|
|
@@ -2212,11 +3091,42 @@ async function readDeployHubSpotOAuthSecret(input) {
|
|
|
2212
3091
|
function extractWorkerUrl(output) {
|
|
2213
3092
|
return /https:\/\/[a-zA-Z0-9.-]+\.workers\.dev\b/.exec(output)?.[0];
|
|
2214
3093
|
}
|
|
2215
|
-
|
|
3094
|
+
/**
|
|
3095
|
+
* Resolve the Worker origin BEFORE deploying, for the upload-first path. An
|
|
3096
|
+
* explicit `--runtime-origin` wins; otherwise compute
|
|
3097
|
+
* `<workerName>.<subdomain>.workers.dev` from the planned worker name and the
|
|
3098
|
+
* account's workers.dev subdomain. Returns undefined (→ fall back to the
|
|
3099
|
+
* deploy-then-upload order) when the subdomain cannot be resolved, so a first
|
|
3100
|
+
* deploy that can't compute the origin behaves exactly as before.
|
|
3101
|
+
*/
|
|
3102
|
+
async function resolveUploadFirstRuntimeOrigin(input) {
|
|
3103
|
+
const flag = resolveFlag(input.argv, '--runtime-origin');
|
|
3104
|
+
if (flag)
|
|
3105
|
+
return flag;
|
|
3106
|
+
const workerName = input.controlPlanePlan.resources.workerNames[0];
|
|
3107
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
3108
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
3109
|
+
if (!workerName || !accountId || !apiToken)
|
|
3110
|
+
return undefined;
|
|
3111
|
+
const base = (process.env.HSX_CLOUDFLARE_API_BASE_URL ?? 'https://api.cloudflare.com/client/v4').replace(/\/$/, '');
|
|
3112
|
+
try {
|
|
3113
|
+
const response = await fetch(`${base}/accounts/${encodeURIComponent(accountId)}/workers/subdomain`, { headers: { authorization: `Bearer ${apiToken}` } });
|
|
3114
|
+
const body = (await response.json().catch(() => undefined));
|
|
3115
|
+
const subdomain = body?.result?.subdomain;
|
|
3116
|
+
if (!response.ok || !subdomain)
|
|
3117
|
+
return undefined;
|
|
3118
|
+
return `https://${workerName}.${subdomain}.workers.dev`;
|
|
3119
|
+
}
|
|
3120
|
+
catch {
|
|
3121
|
+
return undefined;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, runtimeOrigin, }) {
|
|
2216
3125
|
if (!needsRuntime) {
|
|
2217
3126
|
return;
|
|
2218
3127
|
}
|
|
2219
|
-
const runtimeBaseUrl =
|
|
3128
|
+
const runtimeBaseUrl = runtimeOrigin ??
|
|
3129
|
+
resolveFlag(argv, '--runtime-origin') ??
|
|
2220
3130
|
cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
2221
3131
|
if (!runtimeBaseUrl) {
|
|
2222
3132
|
throw new Error('Runtime HubSpot metadata requires a Worker URL. Pass --runtime-origin or run a non-dry-run Cloudflare deploy that reports a workers.dev URL.');
|
|
@@ -2244,6 +3154,11 @@ export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, wo
|
|
|
2244
3154
|
await mkdir(dirname(path), { recursive: true });
|
|
2245
3155
|
await writeFile(path, contents);
|
|
2246
3156
|
}
|
|
3157
|
+
const migratedShimPath = join(root, 'src', 'app', 'cards', '_hsx-migration-run-serverless.ts');
|
|
3158
|
+
if (await stat(migratedShimPath).catch(() => undefined)) {
|
|
3159
|
+
const migratedShim = await readFile(migratedShimPath, 'utf8');
|
|
3160
|
+
await writeFile(migratedShimPath, stampMigratedRunServerlessShimOrigin(migratedShim, runtimeBaseUrl));
|
|
3161
|
+
}
|
|
2247
3162
|
}
|
|
2248
3163
|
async function readHsxAppScopesForDeploy(root) {
|
|
2249
3164
|
try {
|
|
@@ -2272,6 +3187,12 @@ async function readHsxAppConfig(root) {
|
|
|
2272
3187
|
appObjects: discoverAppObjectDeclarations(raw),
|
|
2273
3188
|
appObjectAssociations: discoverAppObjectAssociationDeclarations(raw),
|
|
2274
3189
|
appEvents: discoverAppEventDeclarations(raw),
|
|
3190
|
+
...(loaded?.appEvents?.batching !== undefined
|
|
3191
|
+
? { appEventBatching: loaded.appEvents.batching }
|
|
3192
|
+
: {}),
|
|
3193
|
+
...(loaded?.rateLimits !== undefined ? { rateLimits: loaded.rateLimits } : {}),
|
|
3194
|
+
...(loaded?.alerts !== undefined ? { alerts: loaded.alerts } : {}),
|
|
3195
|
+
...(loaded?.redaction !== undefined ? { redaction: loaded.redaction } : {}),
|
|
2275
3196
|
// Cards are only recoverable from the loaded module (the literal parser
|
|
2276
3197
|
// can't evaluate `card(...)` calls); an unloadable config yields none.
|
|
2277
3198
|
cards: loaded?.cards ?? [],
|
|
@@ -2375,13 +3296,19 @@ function discoverAppObjectDeclarations(source) {
|
|
|
2375
3296
|
}
|
|
2376
3297
|
: {}),
|
|
2377
3298
|
...(readStringArrayProperty(body, 'requiredProperties').length
|
|
2378
|
-
? {
|
|
3299
|
+
? {
|
|
3300
|
+
requiredProperties: readStringArrayProperty(body, 'requiredProperties'),
|
|
3301
|
+
}
|
|
2379
3302
|
: {}),
|
|
2380
3303
|
...(readStringArrayProperty(body, 'searchableProperties').length
|
|
2381
|
-
? {
|
|
3304
|
+
? {
|
|
3305
|
+
searchableProperties: readStringArrayProperty(body, 'searchableProperties'),
|
|
3306
|
+
}
|
|
2382
3307
|
: {}),
|
|
2383
3308
|
...(readStringArrayProperty(body, 'defaultCreateFormFields').length
|
|
2384
|
-
? {
|
|
3309
|
+
? {
|
|
3310
|
+
defaultCreateFormFields: readStringArrayProperty(body, 'defaultCreateFormFields'),
|
|
3311
|
+
}
|
|
2385
3312
|
: {}),
|
|
2386
3313
|
properties,
|
|
2387
3314
|
};
|
|
@@ -2414,7 +3341,9 @@ function discoverAppEventDeclarations(source) {
|
|
|
2414
3341
|
objectType: stringPropertyValue(body, 'objectType') ?? 'CONTACT',
|
|
2415
3342
|
...(booleanPropertyValue(body, 'supportsCustomObject') === undefined
|
|
2416
3343
|
? {}
|
|
2417
|
-
: {
|
|
3344
|
+
: {
|
|
3345
|
+
supportsCustomObject: booleanPropertyValue(body, 'supportsCustomObject'),
|
|
3346
|
+
}),
|
|
2418
3347
|
...(stringPropertyValue(body, 'headerTemplate')
|
|
2419
3348
|
? { headerTemplate: stringPropertyValue(body, 'headerTemplate') }
|
|
2420
3349
|
: {}),
|
|
@@ -2434,7 +3363,10 @@ function declarationBodies(source, functionName) {
|
|
|
2434
3363
|
const end = findMatching(source, start, '{', '}');
|
|
2435
3364
|
if (end === -1)
|
|
2436
3365
|
continue;
|
|
2437
|
-
declarations.push({
|
|
3366
|
+
declarations.push({
|
|
3367
|
+
id: match[1] ?? 'unknown',
|
|
3368
|
+
body: source.slice(start + 1, end),
|
|
3369
|
+
});
|
|
2438
3370
|
}
|
|
2439
3371
|
return declarations;
|
|
2440
3372
|
}
|
|
@@ -2482,7 +3414,10 @@ function topLevelObjectEntries(source) {
|
|
|
2482
3414
|
if (braceStart !== -1 && source.slice(valueStart, braceStart).trim() === '') {
|
|
2483
3415
|
const braceEnd = findMatching(source, braceStart, '{', '}');
|
|
2484
3416
|
if (braceEnd !== -1) {
|
|
2485
|
-
entries.push({
|
|
3417
|
+
entries.push({
|
|
3418
|
+
name,
|
|
3419
|
+
body: source.slice(braceStart + 1, braceEnd),
|
|
3420
|
+
});
|
|
2486
3421
|
index = braceEnd + 1;
|
|
2487
3422
|
continue;
|
|
2488
3423
|
}
|
|
@@ -2569,16 +3504,12 @@ function runCloudflareCommand(command, options) {
|
|
|
2569
3504
|
})));
|
|
2570
3505
|
}
|
|
2571
3506
|
async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
2572
|
-
const hsprojectConfig = await readHubSpotProjectConfig(root);
|
|
2573
|
-
const configuredProjectName = typeof hsprojectConfig.name === 'string' && hsprojectConfig.name.length > 0
|
|
2574
|
-
? hsprojectConfig.name
|
|
2575
|
-
: undefined;
|
|
2576
3507
|
// The HubSpot project name must match the name baked into the generated
|
|
2577
3508
|
// bundle (hsproject.json + app-hsmeta uid), not the on-disk directory name.
|
|
2578
3509
|
// Using basename(root) breaks when the project lives in a generic dir (e.g.
|
|
2579
3510
|
// `app/`): the upload targets a project named "app" while the bundle declares
|
|
2580
3511
|
// the real project id, and HubSpot rejects the mismatch with an opaque 400.
|
|
2581
|
-
const projectName =
|
|
3512
|
+
const projectName = await resolveHubSpotProjectName(argv, root);
|
|
2582
3513
|
const intermediateRepresentation = await readHubSpotProjectIntermediateRepresentation(root);
|
|
2583
3514
|
const appUid = readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
|
|
2584
3515
|
(await readAppUidFromProjectSource(root));
|
|
@@ -2616,7 +3547,11 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2616
3547
|
? await optionalHubSpotAppId(client, projectName, appUid)
|
|
2617
3548
|
: 0;
|
|
2618
3549
|
if (appId)
|
|
2619
|
-
await persistHubSpotAppBinding(root, {
|
|
3550
|
+
await persistHubSpotAppBinding(root, {
|
|
3551
|
+
appId,
|
|
3552
|
+
projectName,
|
|
3553
|
+
developerAccountId,
|
|
3554
|
+
});
|
|
2620
3555
|
// Build SUCCESS does NOT mean the app shipped: HubSpot's auto-deploy can
|
|
2621
3556
|
// still fail (found live: "Apps are not allowed to have 0 redirect URLs"
|
|
2622
3557
|
// failed deploy #4 while the CLI reported green). Verify the deploy too.
|
|
@@ -2652,7 +3587,11 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2652
3587
|
deployId,
|
|
2653
3588
|
});
|
|
2654
3589
|
const appId = await optionalHubSpotAppId(client, projectName, appUid);
|
|
2655
|
-
await persistHubSpotAppBinding(root, {
|
|
3590
|
+
await persistHubSpotAppBinding(root, {
|
|
3591
|
+
appId,
|
|
3592
|
+
projectName,
|
|
3593
|
+
developerAccountId,
|
|
3594
|
+
});
|
|
2656
3595
|
return {
|
|
2657
3596
|
projectName,
|
|
2658
3597
|
archivePath,
|
|
@@ -2869,7 +3808,7 @@ async function collectHubSpotMetaFiles(root) {
|
|
|
2869
3808
|
}
|
|
2870
3809
|
function runZip(root, archivePath) {
|
|
2871
3810
|
return new Promise((resolve, reject) => {
|
|
2872
|
-
const child = spawn('zip', ['-qr', archivePath, '.', '-x', 'node_modules/*', '.hs-x/*'], {
|
|
3811
|
+
const child = spawn('zip', ['-qr', archivePath, '.', '-x', 'node_modules/*', '*/node_modules/*', '.hs-x/*'], {
|
|
2873
3812
|
cwd: root,
|
|
2874
3813
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2875
3814
|
});
|
|
@@ -2906,25 +3845,59 @@ async function persistHubSpotAppBinding(root, binding) {
|
|
|
2906
3845
|
await writeFile(join(dir, HUBSPOT_APP_BINDING_FILE[1]), `${JSON.stringify({
|
|
2907
3846
|
appId: binding.appId,
|
|
2908
3847
|
projectName: binding.projectName,
|
|
2909
|
-
...(binding.developerAccountId
|
|
2910
|
-
? { developerAccountId: binding.developerAccountId }
|
|
2911
|
-
: {}),
|
|
3848
|
+
...(binding.developerAccountId ? { developerAccountId: binding.developerAccountId } : {}),
|
|
2912
3849
|
}, null, 2)}\n`);
|
|
2913
3850
|
}
|
|
2914
3851
|
catch {
|
|
2915
3852
|
// Advisory persistence — never fail a deploy over it.
|
|
2916
3853
|
}
|
|
2917
3854
|
}
|
|
2918
|
-
async function
|
|
3855
|
+
async function resolveHubSpotProjectName(argv, root) {
|
|
3856
|
+
const hsprojectConfig = await readHubSpotProjectConfig(root);
|
|
3857
|
+
const configuredProjectName = typeof hsprojectConfig.name === 'string' && hsprojectConfig.name.length > 0
|
|
3858
|
+
? hsprojectConfig.name
|
|
3859
|
+
: undefined;
|
|
3860
|
+
return resolveFlag(argv, '--hubspot-project-name') ?? configuredProjectName ?? basename(root);
|
|
3861
|
+
}
|
|
3862
|
+
async function readPersistedHubSpotAppBinding(root, expectedProjectName) {
|
|
2919
3863
|
try {
|
|
2920
3864
|
const raw = await readFile(join(root, ...HUBSPOT_APP_BINDING_FILE), 'utf8');
|
|
2921
3865
|
const parsed = JSON.parse(raw);
|
|
2922
|
-
|
|
3866
|
+
// The numeric app id is only meaningful for the HubSpot project that
|
|
3867
|
+
// produced it. A renamed/re-generated project must upload first and mint
|
|
3868
|
+
// its own app id; reusing the prior project's id also binds the prior
|
|
3869
|
+
// client secret and makes every card v3 signature fail at runtime.
|
|
3870
|
+
if (parsed.projectName !== expectedProjectName)
|
|
3871
|
+
return undefined;
|
|
3872
|
+
const appId = readOptionalHubSpotAppId(typeof parsed.appId === 'number' ? String(parsed.appId) : undefined);
|
|
3873
|
+
if (!appId)
|
|
3874
|
+
return undefined;
|
|
3875
|
+
return {
|
|
3876
|
+
appId,
|
|
3877
|
+
projectName: expectedProjectName,
|
|
3878
|
+
...(typeof parsed.developerAccountId === 'string' && parsed.developerAccountId.length > 0
|
|
3879
|
+
? { developerAccountId: parsed.developerAccountId }
|
|
3880
|
+
: {}),
|
|
3881
|
+
};
|
|
2923
3882
|
}
|
|
2924
3883
|
catch {
|
|
2925
3884
|
return undefined;
|
|
2926
3885
|
}
|
|
2927
3886
|
}
|
|
3887
|
+
async function readPersistedHubSpotAppId(root, expectedProjectName) {
|
|
3888
|
+
return (await readPersistedHubSpotAppBinding(root, expectedProjectName))?.appId;
|
|
3889
|
+
}
|
|
3890
|
+
async function resolveHubSpotAppAuthUrl(argv, root) {
|
|
3891
|
+
const projectName = await resolveHubSpotProjectName(argv, root);
|
|
3892
|
+
const persisted = await readPersistedHubSpotAppBinding(root, projectName);
|
|
3893
|
+
const intermediateRepresentation = await readHubSpotProjectIntermediateRepresentation(root);
|
|
3894
|
+
const appUid = readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
|
|
3895
|
+
(await readAppUidFromProjectSource(root));
|
|
3896
|
+
const developerAccountId = resolveFlag(argv, '--developer-account-id') ??
|
|
3897
|
+
process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID ??
|
|
3898
|
+
persisted?.developerAccountId;
|
|
3899
|
+
return buildHubSpotAppAuthUrl({ developerAccountId, projectName, appUid });
|
|
3900
|
+
}
|
|
2928
3901
|
function readStatus(value) {
|
|
2929
3902
|
return isRecord(value) && typeof value.status === 'string' ? value.status : undefined;
|
|
2930
3903
|
}
|
|
@@ -2957,8 +3930,41 @@ function readOptionalHubSpotAppId(value) {
|
|
|
2957
3930
|
const parsed = readPositiveInteger(value, '--hubspot-app-id');
|
|
2958
3931
|
return Schema.decodeSync(schemas.HubSpotAppId)(parsed);
|
|
2959
3932
|
}
|
|
2960
|
-
|
|
2961
|
-
|
|
3933
|
+
/**
|
|
3934
|
+
* ADR-021 R03 deploy-time capture. Reads back Cloudflare's confirmed active
|
|
3935
|
+
* deployment for the primary Worker and returns its verifiable revision, or
|
|
3936
|
+
* `undefined` (with a warning) when credentials are missing or the read-back
|
|
3937
|
+
* fails. Best-effort: never fabricates an id and never fails the deploy —
|
|
3938
|
+
* capture is a pure read, so a retry re-reads the same version id.
|
|
3939
|
+
*/
|
|
3940
|
+
async function captureDeployRevisionForDeploy(input) {
|
|
3941
|
+
// Worker/script names are authoritative and never renamed (T03); the first
|
|
3942
|
+
// planned Worker is the project's primary runtime Worker.
|
|
3943
|
+
const scriptName = input.controlPlanePlan.resources.workerNames[0];
|
|
3944
|
+
if (!scriptName)
|
|
3945
|
+
return undefined;
|
|
3946
|
+
const warn = (message) => {
|
|
3947
|
+
if (!input.json) {
|
|
3948
|
+
(input.reporter ?? createReporter({ command: 'deploy', argv: input.argv })).warn('HSX_W_DEPLOY_REVISION_CAPTURE', message);
|
|
3949
|
+
}
|
|
3950
|
+
};
|
|
3951
|
+
const creds = resolveCloudflareCredentials(input.argv);
|
|
3952
|
+
const cloudflareAccountId = creds.accountId ?? (await loadCloudflarePointer(input.root))?.cloudflareAccountId;
|
|
3953
|
+
if (!creds.apiToken || !cloudflareAccountId) {
|
|
3954
|
+
warn('Skipped Cloudflare version capture: no Cloudflare API token / account id available. This deploy is recorded without a rollback-capable revision.');
|
|
3955
|
+
return undefined;
|
|
3956
|
+
}
|
|
3957
|
+
try {
|
|
3958
|
+
return await captureDeployRevision({
|
|
3959
|
+
cloudflareAccountId,
|
|
3960
|
+
scriptName,
|
|
3961
|
+
apiToken: creds.apiToken,
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3964
|
+
catch (error) {
|
|
3965
|
+
warn(`Could not read back the Cloudflare Worker version for "${scriptName}" (${error instanceof Error ? error.message : String(error)}); recorded without a rollback-capable revision.`);
|
|
3966
|
+
return undefined;
|
|
3967
|
+
}
|
|
2962
3968
|
}
|
|
2963
3969
|
async function requestLocalControlPlaneDeployPlan(request) {
|
|
2964
3970
|
const controlPlane = (await loadCreateControlPlane())({
|
|
@@ -2997,7 +4003,7 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
|
|
|
2997
4003
|
}
|
|
2998
4004
|
return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
|
|
2999
4005
|
}
|
|
3000
|
-
async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, }) {
|
|
4006
|
+
async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
|
|
3001
4007
|
const controlPlane = (await loadCreateControlPlane())({
|
|
3002
4008
|
now: () => new Date('2026-05-18T14:00:00.000Z'),
|
|
3003
4009
|
});
|
|
@@ -3020,7 +4026,10 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
|
|
|
3020
4026
|
manifestHash: plan.manifestHash,
|
|
3021
4027
|
signedManifest: request.manifest,
|
|
3022
4028
|
signature: `sig_${plan.deployId}`,
|
|
3023
|
-
|
|
4029
|
+
// ADR-021 R03: the real Cloudflare Worker version identity captured
|
|
4030
|
+
// back from the confirmed active deployment. Absent only when capture
|
|
4031
|
+
// was skipped/failed (best-effort) — then this deploy is history-only.
|
|
4032
|
+
...(revision ? { revision } : {}),
|
|
3024
4033
|
}),
|
|
3025
4034
|
}));
|
|
3026
4035
|
const body = await response.json();
|
|
@@ -3061,7 +4070,10 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
|
|
|
3061
4070
|
const promotionResponse = await controlPlane.fetch(new Request(`https://api.hs-x.dev/v1/deploys/${plan.deployId}/promote`, {
|
|
3062
4071
|
method: 'POST',
|
|
3063
4072
|
headers,
|
|
3064
|
-
body: JSON.stringify({
|
|
4073
|
+
body: JSON.stringify({
|
|
4074
|
+
accountId: request.accountId,
|
|
4075
|
+
projectId: request.projectId,
|
|
4076
|
+
}),
|
|
3065
4077
|
}));
|
|
3066
4078
|
const promotionBody = await promotionResponse.json();
|
|
3067
4079
|
if (!promotionResponse.ok) {
|
|
@@ -3075,7 +4087,7 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
|
|
|
3075
4087
|
promotion: Schema.decodeUnknownSync(schemas.DeployPromotionResult)(promotionBody),
|
|
3076
4088
|
};
|
|
3077
4089
|
}
|
|
3078
|
-
async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, plan, controlPlaneUrl, userId, promoteWhenHealthy, promotionTimeoutMs, workerUrl, }) {
|
|
4090
|
+
async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, plan, controlPlaneUrl, userId, promoteWhenHealthy, promotionTimeoutMs, workerUrl, revision, }) {
|
|
3079
4091
|
const response = await hostedHttp({
|
|
3080
4092
|
url: new URL(`/v1/deploys/${encodeURIComponent(plan.deployId)}/record`, controlPlaneUrl),
|
|
3081
4093
|
method: 'POST',
|
|
@@ -3086,7 +4098,10 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
|
|
|
3086
4098
|
manifestHash: plan.manifestHash,
|
|
3087
4099
|
signedManifest: request.manifest,
|
|
3088
4100
|
signature: `sig_${plan.deployId}`,
|
|
3089
|
-
|
|
4101
|
+
// ADR-021 R03: the real Cloudflare Worker version identity captured back
|
|
4102
|
+
// from the confirmed active deployment. Absent only when capture was
|
|
4103
|
+
// skipped/failed (best-effort) — then this deploy is history-only.
|
|
4104
|
+
...(revision ? { revision } : {}),
|
|
3090
4105
|
...(await collectDeployProvenance(userId)),
|
|
3091
4106
|
},
|
|
3092
4107
|
});
|
|
@@ -3157,6 +4172,18 @@ async function discoverWorkerManifests(root, options = {}) {
|
|
|
3157
4172
|
if (!workerName) {
|
|
3158
4173
|
continue;
|
|
3159
4174
|
}
|
|
4175
|
+
// Prefer the runtime manifest (the source of truth `worker.manifest()`
|
|
4176
|
+
// produces): load the worker module and read its capabilities directly.
|
|
4177
|
+
// This handles loops, shared field-map consts, computed ids, and full field
|
|
4178
|
+
// fidelity (supportedValueTypes / isRequired / descriptions) that source
|
|
4179
|
+
// regex cannot — and keeps deploy in agreement with the runtime + dev/invoke
|
|
4180
|
+
// (which already call `.manifest()`). Falls back to regex parsing when the
|
|
4181
|
+
// module can't be imported. See checkpoint FINDINGS #14.
|
|
4182
|
+
const loadedManifest = await loadWorkerManifestModule(file).catch(() => undefined);
|
|
4183
|
+
if (loadedManifest) {
|
|
4184
|
+
workers.push(applyNoManageSchemaOverride(loadedManifest, options));
|
|
4185
|
+
continue;
|
|
4186
|
+
}
|
|
3160
4187
|
const toolCapabilities = [
|
|
3161
4188
|
...source.matchAll(/(?:worker\.)?(?:tool|action)\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*\{([\s\S]*?)(?:async\s+)?handler\s*(?:[:(])/g),
|
|
3162
4189
|
].map((match) => ({
|
|
@@ -3174,6 +4201,8 @@ async function discoverWorkerManifests(root, options = {}) {
|
|
|
3174
4201
|
kind: 'card-backend',
|
|
3175
4202
|
id: match[1] ?? 'unknown',
|
|
3176
4203
|
label: /label\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2] ?? '')?.[1] ?? match[1] ?? 'unknown',
|
|
4204
|
+
...objectFieldsProperty(match[2] ?? '', 'input'),
|
|
4205
|
+
...objectFieldsProperty(match[2] ?? '', 'output'),
|
|
3177
4206
|
runtimeNeeds: ['worker'],
|
|
3178
4207
|
}));
|
|
3179
4208
|
const sourceDefinitions = discoverSourceDefinitions(source);
|
|
@@ -3221,6 +4250,41 @@ async function discoverWorkerManifests(root, options = {}) {
|
|
|
3221
4250
|
}
|
|
3222
4251
|
return workers;
|
|
3223
4252
|
}
|
|
4253
|
+
/**
|
|
4254
|
+
* Load a worker module and return its `worker.manifest()` — the runtime source
|
|
4255
|
+
* of truth. Mirrors `loadHsxAppConfigModule`: a cache-busted dynamic import of
|
|
4256
|
+
* the user's TypeScript. Returns undefined (so the caller falls back to regex
|
|
4257
|
+
* parsing) when the file has no default WorkerDefinition or can't be imported.
|
|
4258
|
+
*/
|
|
4259
|
+
async function loadWorkerManifestModule(file) {
|
|
4260
|
+
const url = pathToFileURL(file);
|
|
4261
|
+
url.searchParams.set('hsxWorkerLoad', String(Date.now()));
|
|
4262
|
+
const mod = (await import(url.href));
|
|
4263
|
+
const def = mod.default;
|
|
4264
|
+
if (def &&
|
|
4265
|
+
typeof def === 'object' &&
|
|
4266
|
+
typeof def.manifest === 'function') {
|
|
4267
|
+
const manifest = def.manifest();
|
|
4268
|
+
if (manifest && Array.isArray(manifest.capabilities)) {
|
|
4269
|
+
return manifest;
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
return undefined;
|
|
4273
|
+
}
|
|
4274
|
+
/**
|
|
4275
|
+
* `--no-manage-schema` is a deploy-time flag, not part of the worker's own
|
|
4276
|
+
* declaration, so apply it to sync capabilities of a loaded manifest (the regex
|
|
4277
|
+
* path applies it inline).
|
|
4278
|
+
*/
|
|
4279
|
+
function applyNoManageSchemaOverride(manifest, options) {
|
|
4280
|
+
if (!options.noManageSchema) {
|
|
4281
|
+
return manifest;
|
|
4282
|
+
}
|
|
4283
|
+
return {
|
|
4284
|
+
...manifest,
|
|
4285
|
+
capabilities: manifest.capabilities.map((capability) => capability.kind === 'sync' ? { ...capability, manageSchema: false } : capability),
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
3224
4288
|
function stringProperty(source, propertyName) {
|
|
3225
4289
|
const match = new RegExp(`${propertyName}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(source)?.[1];
|
|
3226
4290
|
return match ? { [propertyName]: match } : {};
|
|
@@ -3270,7 +4334,10 @@ function schemaProperty(source) {
|
|
|
3270
4334
|
const inner = body.slice(valueStart + 1, end);
|
|
3271
4335
|
const type = /type\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
|
|
3272
4336
|
const property = /property\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
|
|
3273
|
-
schema[name] = {
|
|
4337
|
+
schema[name] = {
|
|
4338
|
+
...(type ? { type } : {}),
|
|
4339
|
+
...(property ? { property } : {}),
|
|
4340
|
+
};
|
|
3274
4341
|
index = end + 1;
|
|
3275
4342
|
}
|
|
3276
4343
|
else {
|
|
@@ -3289,9 +4356,9 @@ function objectFieldsProperty(source, propertyName) {
|
|
|
3289
4356
|
return {};
|
|
3290
4357
|
}
|
|
3291
4358
|
const fields = {};
|
|
3292
|
-
for (const match of body.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*\{([\s\S]*?)\}\s*,?/g)) {
|
|
3293
|
-
const fieldName = match[1];
|
|
3294
|
-
const definition = match[
|
|
4359
|
+
for (const match of body.matchAll(/(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])\s*:\s*\{([\s\S]*?)\}\s*,?/g)) {
|
|
4360
|
+
const fieldName = match[1] ?? match[2];
|
|
4361
|
+
const definition = match[3] ?? '';
|
|
3295
4362
|
if (!fieldName) {
|
|
3296
4363
|
continue;
|
|
3297
4364
|
}
|
|
@@ -3299,6 +4366,8 @@ function objectFieldsProperty(source, propertyName) {
|
|
|
3299
4366
|
...stringProperty(definition, 'type'),
|
|
3300
4367
|
...stringProperty(definition, 'label'),
|
|
3301
4368
|
...literalProperty(definition, 'default'),
|
|
4369
|
+
...literalProperty(definition, 'required'),
|
|
4370
|
+
...literalProperty(definition, 'isRequired'),
|
|
3302
4371
|
};
|
|
3303
4372
|
}
|
|
3304
4373
|
return Object.keys(fields).length > 0 ? { [propertyName]: fields } : {};
|
|
@@ -3488,6 +4557,10 @@ const deployOptions = {
|
|
|
3488
4557
|
applySchema: Options.boolean('apply-schema').pipe(Options.withDefault(false)), // --apply-schema
|
|
3489
4558
|
heartbeat: Options.boolean('heartbeat').pipe(Options.withDefault(false)), // --heartbeat
|
|
3490
4559
|
noHeartbeat: Options.boolean('no-heartbeat').pipe(Options.withDefault(false)), // --no-heartbeat
|
|
4560
|
+
// Declared explicitly: without it @effect/cli treats `--no-record` as the
|
|
4561
|
+
// negation of an undeclared `record` boolean and fails routing with a
|
|
4562
|
+
// confusing root-level "Invalid subcommand" error (the flag is documented).
|
|
4563
|
+
noRecord: Options.boolean('no-record').pipe(Options.withDefault(false)), // --no-record
|
|
3491
4564
|
portalSchemaLive: Options.boolean('portal-schema-live').pipe(Options.withDefault(false)), // --portal-schema-live
|
|
3492
4565
|
localHubspotSchema: Options.boolean('local-hubspot-schema').pipe(Options.withDefault(false)), // --local-hubspot-schema
|
|
3493
4566
|
force: Options.boolean('force').pipe(Options.withDefault(false)), // --force
|
|
@@ -3543,13 +4616,14 @@ const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler
|
|
|
3543
4616
|
flag('--apply-schema', opts.applySchema);
|
|
3544
4617
|
flag('--heartbeat', opts.heartbeat);
|
|
3545
4618
|
flag('--no-heartbeat', opts.noHeartbeat);
|
|
4619
|
+
flag('--no-record', opts.noRecord);
|
|
3546
4620
|
flag('--portal-schema-live', opts.portalSchemaLive);
|
|
3547
4621
|
flag('--local-hubspot-schema', opts.localHubspotSchema);
|
|
3548
4622
|
flag('--force', opts.force);
|
|
3549
4623
|
flag('--yes', opts.yes);
|
|
3550
4624
|
if (opts.json)
|
|
3551
4625
|
argv.push('--json');
|
|
3552
|
-
yield* runQuarantined(opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
4626
|
+
yield* runQuarantined('deploy', opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
3553
4627
|
})));
|
|
3554
4628
|
// `hs-x deploy promote` — preserved as a subcommand that delegates to the legacy
|
|
3555
4629
|
// `deployCommand` promote branch (argv[1] === 'promote'), byte-for-byte. The
|
|
@@ -3583,7 +4657,7 @@ const deployPromoteSubCmd = Command.make('promote', {
|
|
|
3583
4657
|
argv.push('--yes');
|
|
3584
4658
|
if (opts.json)
|
|
3585
4659
|
argv.push('--json');
|
|
3586
|
-
yield* runQuarantined(opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
4660
|
+
yield* runQuarantined('deploy', opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
3587
4661
|
})));
|
|
3588
4662
|
export const deployCmd = deployBaseCmd.pipe(Command.withSubcommands([deployPromoteSubCmd]));
|
|
3589
4663
|
/**
|
|
@@ -3594,7 +4668,11 @@ export const deployCmd = deployBaseCmd.pipe(Command.withSubcommands([deployPromo
|
|
|
3594
4668
|
async function collectDeployProvenance(userId) {
|
|
3595
4669
|
const git = async (args) => {
|
|
3596
4670
|
try {
|
|
3597
|
-
const proc = Bun.spawn({
|
|
4671
|
+
const proc = Bun.spawn({
|
|
4672
|
+
cmd: ['git', ...args],
|
|
4673
|
+
stdout: 'pipe',
|
|
4674
|
+
stderr: 'ignore',
|
|
4675
|
+
});
|
|
3598
4676
|
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
3599
4677
|
const trimmed = out.trim();
|
|
3600
4678
|
return code === 0 && trimmed ? trimmed : undefined;
|