@hs-x/cli 0.2.7 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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.map +1 -1
- package/dist/commands/api.js +7 -11
- 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 +71 -10
- 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 +69 -4
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +1512 -276
- 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 +14 -0
- 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 +98 -17
- 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.map +1 -1
- package/dist/commands/login.js +22 -5
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/migrate.d.ts +37 -17
- 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/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 +9 -9
- 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 +11 -1
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +228 -15
- 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/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 +14 -15
package/dist/commands/deploy.js
CHANGED
|
@@ -1,34 +1,38 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
-
import { cp, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rmdir, stat, unlink, writeFile, } from 'node:fs/promises';
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { basename, dirname, isAbsolute, 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,17 +617,48 @@ 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
|
}
|
|
388
648
|
const needsRuntime = workers.some((worker) => worker.capabilities.some((capability) => capability.runtimeNeeds.some((need) => need !== 'hubspot-only')));
|
|
649
|
+
const needsHubSpotBundle = needsRuntime || app.mcpServers.length > 0;
|
|
389
650
|
const cloudflareDeployRequested = !buildOnly &&
|
|
390
651
|
(explicitCloudflareDeployRequested || (!planOnly && needsRuntime && !explicitHubspotUpload));
|
|
391
652
|
const hubspotUploadRequested = !buildOnly && (explicitHubspotUpload || (!planOnly && !cloudflareDryRun && Boolean(projectId)));
|
|
653
|
+
if (echo) {
|
|
654
|
+
echo.info(`Runtime: ${needsRuntime ? 'Cloudflare Worker required' : 'HubSpot-only compatible'}`);
|
|
655
|
+
if (hubspotOnly && needsRuntime) {
|
|
656
|
+
echo.warn('HSX_W_DEPLOY_HUBSPOT_ONLY_INCOMPATIBLE', 'Cannot use --hubspot-only because at least one capability has a runtime handler.');
|
|
657
|
+
}
|
|
658
|
+
if (hubspotUploadRequested && !hubspotOnly && !cloudflareDeployRequested) {
|
|
659
|
+
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.');
|
|
660
|
+
}
|
|
661
|
+
}
|
|
392
662
|
// Hosted control-plane deploys record by default: shipping code without
|
|
393
663
|
// recording the revision guarantees `unknown_code` drift on the next
|
|
394
664
|
// attestation (the worker attests a deploy id the control plane has no
|
|
@@ -403,10 +673,38 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
403
673
|
!cloudflareDryRun &&
|
|
404
674
|
Boolean(controlPlaneUrl) &&
|
|
405
675
|
(cloudflareDeployRequested || hubspotUploadRequested));
|
|
406
|
-
const
|
|
676
|
+
const portalSchemaRequested = Boolean(resolveFlag(argv, '--portal-schema-fixture')) ||
|
|
677
|
+
argv.includes('--portal-schema-live') ||
|
|
678
|
+
argv.includes('--local-hubspot-schema');
|
|
679
|
+
const portalSchemaStep = portalSchemaRequested
|
|
680
|
+
? progress?.step('Reading portal schema')
|
|
681
|
+
: undefined;
|
|
682
|
+
let portalSchemaRead;
|
|
683
|
+
try {
|
|
684
|
+
portalSchemaRead = await readPortalSchema(argv, workers);
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
portalSchemaStep?.fail(firstErrorLine(error));
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
407
690
|
const portalSchemaPlan = portalSchemaRead
|
|
408
|
-
? planPortalSchemaManagement({
|
|
691
|
+
? planPortalSchemaManagement({
|
|
692
|
+
workers,
|
|
693
|
+
observed: portalSchemaRead.observed,
|
|
694
|
+
})
|
|
409
695
|
: undefined;
|
|
696
|
+
if (portalSchemaStep) {
|
|
697
|
+
if (portalSchemaRead) {
|
|
698
|
+
const objectCount = portalSchemaRead.observed.objects.length;
|
|
699
|
+
portalSchemaStep.ok(`${objectCount} object type${objectCount === 1 ? '' : 's'} (${portalSchemaRead.source})`);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
portalSchemaStep.ok('skipped');
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (echo && portalSchemaPlan) {
|
|
706
|
+
echo.info(renderPortalSchemaPlan(portalSchemaPlan).trimEnd());
|
|
707
|
+
}
|
|
410
708
|
const willChangeAnything = !planOnly &&
|
|
411
709
|
(applySchema ||
|
|
412
710
|
shouldRecordControlPlane ||
|
|
@@ -445,9 +743,7 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
445
743
|
shouldRecordControlPlane,
|
|
446
744
|
promoteWhenHealthy,
|
|
447
745
|
});
|
|
448
|
-
|
|
449
|
-
reporter.header(basename(root));
|
|
450
|
-
reporter.block(summary);
|
|
746
|
+
progress?.reporter.block(summary);
|
|
451
747
|
const confirm = await promptConfirm({
|
|
452
748
|
message: 'Apply this plan?',
|
|
453
749
|
default: false,
|
|
@@ -456,12 +752,21 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
456
752
|
return cancelledResult(argv, 'deploy');
|
|
457
753
|
}
|
|
458
754
|
}
|
|
755
|
+
// `--apply-schema` writes property definitions to the live portal, but
|
|
756
|
+
// `--plan`/`--dry-run` promise to change nothing. Honoring both would let a
|
|
757
|
+
// "dry run" mutate production (cold-stranger run 2026-07-22-001, finding #2):
|
|
758
|
+
// the confirm prompt above is already suppressed under `planOnly`, so without
|
|
759
|
+
// this guard the apply ran silently. Skip the apply and say so.
|
|
760
|
+
if (applySchema && planOnly && echo) {
|
|
761
|
+
echo.warn('HSX_W_DEPLOY_APPLY_SCHEMA_DRY_RUN', '--apply-schema ignored because --plan/--dry-run was passed; no portal changes were made.');
|
|
762
|
+
}
|
|
459
763
|
// The schema apply is the first phase that touches the portal. A failure here
|
|
460
764
|
// used to throw a raw HubSpot error out of the command — no HSX_E code, no
|
|
461
765
|
// phase/PLAN output (cold-stranger run #3, finding A). Render the curated
|
|
462
766
|
// failure WITH the plan that was being applied instead.
|
|
463
767
|
let portalSchemaApply;
|
|
464
|
-
if (applySchema) {
|
|
768
|
+
if (applySchema && !planOnly) {
|
|
769
|
+
const applyStep = progress?.step('Applying portal schema');
|
|
465
770
|
try {
|
|
466
771
|
portalSchemaApply = await applyPortalSchemaPlan({
|
|
467
772
|
argv,
|
|
@@ -470,6 +775,7 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
470
775
|
});
|
|
471
776
|
}
|
|
472
777
|
catch (error) {
|
|
778
|
+
applyStep?.fail(error instanceof PortalSchemaApplyError ? error.action : firstErrorLine(error));
|
|
473
779
|
return portalSchemaApplyFailure({
|
|
474
780
|
argv,
|
|
475
781
|
json,
|
|
@@ -478,16 +784,29 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
478
784
|
workers,
|
|
479
785
|
portalSchemaPlan,
|
|
480
786
|
error,
|
|
787
|
+
...(progress ? { progress } : {}),
|
|
481
788
|
});
|
|
482
789
|
}
|
|
790
|
+
applyStep?.ok(`${portalSchemaApply.applied.length} applied${portalSchemaApply.skipped.length > 0 ? `, ${portalSchemaApply.skipped.length} skipped` : ''}`);
|
|
791
|
+
if (echo)
|
|
792
|
+
echo.info(renderPortalSchemaApply(portalSchemaApply).trimEnd());
|
|
483
793
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
794
|
+
const artifactsStep = progress?.step('Generating project artifacts');
|
|
795
|
+
let manifest;
|
|
796
|
+
try {
|
|
797
|
+
manifest = await generateProjectArtifacts({
|
|
798
|
+
root,
|
|
799
|
+
workers,
|
|
800
|
+
appObjects: app.appObjects,
|
|
801
|
+
appObjectAssociations: app.appObjectAssociations,
|
|
802
|
+
appEvents: app.appEvents,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
catch (error) {
|
|
806
|
+
artifactsStep?.fail(firstErrorLine(error));
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
artifactsStep?.ok('.hs-x/manifest.json + refs stubs');
|
|
491
810
|
const shouldCreateControlPlaneRequest = !hubspotOnly && Boolean(accountId);
|
|
492
811
|
let controlPlaneRequest;
|
|
493
812
|
let resolvedProjectId = projectId;
|
|
@@ -511,6 +830,7 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
511
830
|
projectId: resolvedProjectId,
|
|
512
831
|
manifest,
|
|
513
832
|
...(app.billing ? { billing: app.billing } : {}),
|
|
833
|
+
...(app.alerts ? { alerts: app.alerts } : {}),
|
|
514
834
|
});
|
|
515
835
|
}
|
|
516
836
|
catch (error) {
|
|
@@ -526,24 +846,53 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
526
846
|
});
|
|
527
847
|
}
|
|
528
848
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
849
|
+
if (echo && controlPlaneRequest) {
|
|
850
|
+
echo.info(`Control plane: ready to POST /v1/deploys/plan for ${controlPlaneRequest.projectId}`);
|
|
851
|
+
}
|
|
852
|
+
const controlPlanePlanStep = controlPlaneRequest && (localControlPlane || controlPlaneUrl)
|
|
853
|
+
? progress?.step(localControlPlane
|
|
854
|
+
? 'Planning deploy (local control plane)'
|
|
855
|
+
: 'Planning deploy (control plane)')
|
|
856
|
+
: undefined;
|
|
857
|
+
let controlPlanePlan;
|
|
858
|
+
try {
|
|
859
|
+
controlPlanePlan =
|
|
860
|
+
localControlPlane && controlPlaneRequest
|
|
861
|
+
? await requestLocalControlPlaneDeployPlan(controlPlaneRequest)
|
|
862
|
+
: controlPlaneUrl && controlPlaneRequest
|
|
863
|
+
? await requestHostedControlPlaneDeployPlan({
|
|
864
|
+
request: controlPlaneRequest,
|
|
865
|
+
controlPlaneUrl,
|
|
866
|
+
userId,
|
|
867
|
+
})
|
|
868
|
+
: !buildOnly && !hubspotOnly && projectId
|
|
869
|
+
? createUnlinkedDeployPlan({
|
|
870
|
+
projectId,
|
|
871
|
+
machineId,
|
|
872
|
+
manifest,
|
|
873
|
+
workers,
|
|
874
|
+
})
|
|
875
|
+
: undefined;
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
controlPlanePlanStep?.fail(firstErrorLine(error));
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
545
881
|
const controlPlaneBackedPlan = Boolean(controlPlanePlan && controlPlaneRequest && (localControlPlane || controlPlaneUrl));
|
|
546
882
|
const unlinkedDeployPlan = Boolean(controlPlanePlan && !controlPlaneBackedPlan);
|
|
883
|
+
if (controlPlanePlan) {
|
|
884
|
+
controlPlanePlanStep?.ok(controlPlanePlan.deployId);
|
|
885
|
+
if (echo) {
|
|
886
|
+
if (unlinkedDeployPlan)
|
|
887
|
+
echo.info(`Unlinked deploy id: ${controlPlanePlan.deployId}`);
|
|
888
|
+
if (controlPlanePlan.sharedCloudflareQuota) {
|
|
889
|
+
echo.info(`Shared Cloudflare quota: ${controlPlanePlan.sharedCloudflareQuota.visibleSiblingAccountIds.join(', ')}`);
|
|
890
|
+
}
|
|
891
|
+
if (controlPlanePlan.leases.length > 0) {
|
|
892
|
+
echo.info(`Cloudflare credential leases issued by the control plane: ${controlPlanePlan.leases.length}`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
547
896
|
if (controlPlanePlan) {
|
|
548
897
|
await writeFile(join(root, '.hs-x', 'alchemy.run.ts'), renderAlchemyProgram({
|
|
549
898
|
hsXAccountId: controlPlanePlan.accountId,
|
|
@@ -552,6 +901,20 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
552
901
|
workers,
|
|
553
902
|
}));
|
|
554
903
|
}
|
|
904
|
+
// Bridge a prior `hs-x connect cloudflare` to this deploy: if there's no API
|
|
905
|
+
// token in env/flags but a stored OAuth connection exists, mint a token from
|
|
906
|
+
// it now so BOTH the interactive prompt below and the non-interactive
|
|
907
|
+
// (`--yes`) preflight succeed instead of demanding a token connect says
|
|
908
|
+
// doesn't exist (cold-stranger run 2026-07-22-001, finding #1).
|
|
909
|
+
if (cloudflareDeployRequested && !planOnly && !cloudflareDryRun) {
|
|
910
|
+
const hydrated = await hydrateCloudflareTokenFromStoredOAuth({
|
|
911
|
+
argv,
|
|
912
|
+
root,
|
|
913
|
+
});
|
|
914
|
+
if (hydrated && echo) {
|
|
915
|
+
echo.info('Using the Cloudflare connection from `hs-x connect cloudflare`.');
|
|
916
|
+
}
|
|
917
|
+
}
|
|
555
918
|
// Missing Cloudflare credentials on a real CF deploy: in a TTY, prompt for
|
|
556
919
|
// the token (and account id) now — first deploy on a fresh machine is the
|
|
557
920
|
// common case. Non-TTY / --json / --yes keep the fail-fast preflight error.
|
|
@@ -567,74 +930,243 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
567
930
|
if (!proceed)
|
|
568
931
|
return cancelledResult(argv, 'deploy');
|
|
569
932
|
}
|
|
570
|
-
|
|
571
|
-
|
|
933
|
+
// Upload-first (fixes the first-deploy chicken-and-egg): the HubSpot app id
|
|
934
|
+
// does not exist until the upload creates it, so a plain deploy binds no
|
|
935
|
+
// HSX_APP_ID and the install + card v3 verification fail until a manual
|
|
936
|
+
// second deploy. When this is that first deploy (no app id known yet) and the
|
|
937
|
+
// Worker origin is resolvable up front, upload BEFORE the Cloudflare deploy so
|
|
938
|
+
// the minted app id binds in a single pass. Falls back to the original
|
|
939
|
+
// deploy-then-upload order whenever the origin can't be resolved.
|
|
940
|
+
const appIdKnownBeforeDeploy = readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root)));
|
|
941
|
+
const uploadFirstOrigin = cloudflareDeployRequested &&
|
|
942
|
+
!cloudflareDryRun &&
|
|
943
|
+
!planOnly &&
|
|
944
|
+
hubspotUploadRequested &&
|
|
945
|
+
validation.ok &&
|
|
946
|
+
controlPlanePlan &&
|
|
947
|
+
needsRuntime &&
|
|
948
|
+
!hubspotOnly &&
|
|
949
|
+
!appIdKnownBeforeDeploy
|
|
950
|
+
? await resolveUploadFirstRuntimeOrigin({ argv, root, controlPlanePlan })
|
|
951
|
+
: undefined;
|
|
952
|
+
let hubspotUploadResult;
|
|
953
|
+
// One step per HubSpot upload, wherever it runs in the ordering (upload-first
|
|
954
|
+
// vs deploy-then-upload). A FAILED build resolves the step as a failure so
|
|
955
|
+
// the failing phase is visible in the transcript, not just the exit code.
|
|
956
|
+
const runHubSpotUploadPhase = async () => {
|
|
957
|
+
const uploadStartedAt = Date.now();
|
|
958
|
+
const uploadStep = progress?.step('Uploading to HubSpot');
|
|
959
|
+
let result;
|
|
960
|
+
try {
|
|
961
|
+
result = await executeHubSpotOnlyUpload({
|
|
962
|
+
argv,
|
|
963
|
+
root,
|
|
964
|
+
local: localHubspotUpload,
|
|
965
|
+
uploadOnly: hubspotUploadOnly,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
catch (error) {
|
|
969
|
+
uploadStep?.fail(firstErrorLine(error));
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
const buildState = readStatus(result.buildStatus) ?? 'UNKNOWN';
|
|
973
|
+
const detail = `build #${result.buildId} ${buildState} (${elapsedSince(uploadStartedAt)})`;
|
|
974
|
+
if (buildState === 'SUCCESS') {
|
|
975
|
+
uploadStep?.ok(detail);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
uploadStep?.fail(detail);
|
|
979
|
+
}
|
|
980
|
+
if (echo)
|
|
981
|
+
streamHubSpotUploadOutcome(echo, result);
|
|
982
|
+
return result;
|
|
983
|
+
};
|
|
984
|
+
if (uploadFirstOrigin && controlPlanePlan) {
|
|
985
|
+
const bundleStep = progress?.step('Generating HubSpot bundle');
|
|
986
|
+
try {
|
|
987
|
+
await ensureHubSpotRuntimeProjectArtifacts({
|
|
988
|
+
argv,
|
|
989
|
+
root,
|
|
990
|
+
app,
|
|
991
|
+
workers,
|
|
992
|
+
cloudflareDeployResult: undefined,
|
|
993
|
+
needsRuntime,
|
|
994
|
+
runtimeOrigin: uploadFirstOrigin,
|
|
995
|
+
...(controlPlanePlan ? { controlPlanePlan } : {}),
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
bundleStep?.fail(firstErrorLine(error));
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
bundleStep?.ok(uploadFirstOrigin);
|
|
1003
|
+
hubspotUploadResult = await runHubSpotUploadPhase();
|
|
1004
|
+
}
|
|
1005
|
+
const willDeployCloudflare = cloudflareDeployRequested && !planOnly && validation.ok && Boolean(controlPlanePlan);
|
|
1006
|
+
const cloudflareStep = willDeployCloudflare
|
|
1007
|
+
? progress?.step(cloudflareDryRun ? 'Deploying to Cloudflare (dry-run)' : 'Deploying to Cloudflare')
|
|
1008
|
+
: undefined;
|
|
1009
|
+
const cloudflareStartedAt = Date.now();
|
|
1010
|
+
let cloudflareDeployResult;
|
|
1011
|
+
try {
|
|
1012
|
+
cloudflareDeployResult =
|
|
1013
|
+
willDeployCloudflare && controlPlanePlan
|
|
1014
|
+
? await executeCloudflareDeploy({
|
|
1015
|
+
argv,
|
|
1016
|
+
root,
|
|
1017
|
+
workers,
|
|
1018
|
+
controlPlanePlan,
|
|
1019
|
+
userId,
|
|
1020
|
+
machineId,
|
|
1021
|
+
heartbeatEnabled: noHeartbeat
|
|
1022
|
+
? false
|
|
1023
|
+
: controlPlaneBackedPlan
|
|
1024
|
+
? configHeartbeat !== false
|
|
1025
|
+
: heartbeatFlag || configHeartbeat === true,
|
|
1026
|
+
...(controlPlaneBackedPlan && controlPlaneUrl ? { controlPlaneUrl } : {}),
|
|
1027
|
+
dryRun: cloudflareDryRun,
|
|
1028
|
+
...(progress ? { note: (line) => progress.reporter.rawErr(line) } : {}),
|
|
1029
|
+
})
|
|
1030
|
+
: undefined;
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
cloudflareStep?.fail(firstErrorLine(error));
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
if (cloudflareDeployResult && cloudflareStep) {
|
|
1037
|
+
const firstUrl = cloudflareDeployResult.workers.find((worker) => worker.url)?.url;
|
|
1038
|
+
const workerCount = cloudflareDeployResult.workers.length;
|
|
1039
|
+
cloudflareStep.ok(`${workerCount} worker${workerCount === 1 ? '' : 's'}${firstUrl ? ` at ${firstUrl}` : ''} (${elapsedSince(cloudflareStartedAt)})`);
|
|
1040
|
+
}
|
|
1041
|
+
if (echo && cloudflareDeployResult) {
|
|
1042
|
+
for (const worker of cloudflareDeployResult.workers) {
|
|
1043
|
+
if (worker.unauthenticatedInvoke) {
|
|
1044
|
+
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).`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
// ADR-021 R03: after the Worker is live, read back Cloudflare's confirmed
|
|
1049
|
+
// active deployment and capture the real version identity (verbatim id +
|
|
1050
|
+
// binding fingerprint) to attach to the deploy record. This runs BEFORE the
|
|
1051
|
+
// control-plane record below, so the control plane never stores a revision
|
|
1052
|
+
// Cloudflare has not confirmed. Best-effort + pure read: a read-back failure
|
|
1053
|
+
// records without a revision, and a retry re-reads the same active
|
|
1054
|
+
// deployment, so it converges to the same version id with no duplicate.
|
|
1055
|
+
const willCaptureRevision = shouldRecordControlPlane &&
|
|
1056
|
+
Boolean(controlPlanePlan) &&
|
|
1057
|
+
Boolean(cloudflareDeployResult) &&
|
|
1058
|
+
!cloudflareDryRun;
|
|
1059
|
+
const revisionStep = willCaptureRevision
|
|
1060
|
+
? progress?.step('Capturing Worker revision')
|
|
1061
|
+
: undefined;
|
|
1062
|
+
const capturedRevision = willCaptureRevision && controlPlanePlan
|
|
1063
|
+
? await captureDeployRevisionForDeploy({
|
|
572
1064
|
argv,
|
|
573
1065
|
root,
|
|
574
|
-
workers,
|
|
575
1066
|
controlPlanePlan,
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
heartbeatEnabled: noHeartbeat
|
|
579
|
-
? false
|
|
580
|
-
: controlPlaneBackedPlan
|
|
581
|
-
? configHeartbeat !== false
|
|
582
|
-
: heartbeatFlag || configHeartbeat === true,
|
|
583
|
-
...(controlPlaneBackedPlan && controlPlaneUrl ? { controlPlaneUrl } : {}),
|
|
584
|
-
dryRun: cloudflareDryRun,
|
|
1067
|
+
json,
|
|
1068
|
+
...(progress ? { reporter: progress.reporter } : {}),
|
|
585
1069
|
})
|
|
586
1070
|
: 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
|
|
1071
|
+
if (revisionStep) {
|
|
1072
|
+
if (capturedRevision) {
|
|
1073
|
+
revisionStep.ok(`version ${capturedRevision.versionId}`);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
revisionStep.warn('recorded without a rollback-capable revision');
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const willRecordControlPlane = shouldRecordControlPlane &&
|
|
1080
|
+
Boolean(controlPlaneRequest) &&
|
|
1081
|
+
Boolean(controlPlanePlan) &&
|
|
1082
|
+
(localControlPlane || Boolean(controlPlaneUrl));
|
|
1083
|
+
const recordStep = willRecordControlPlane
|
|
1084
|
+
? progress?.step(promoteWhenHealthy ? 'Recording deploy + promoting when healthy' : 'Recording deploy')
|
|
608
1085
|
: undefined;
|
|
1086
|
+
const recordStartedAt = Date.now();
|
|
1087
|
+
let localRecordResult;
|
|
1088
|
+
try {
|
|
1089
|
+
localRecordResult =
|
|
1090
|
+
shouldRecordControlPlane && controlPlaneRequest && controlPlanePlan
|
|
1091
|
+
? localControlPlane
|
|
1092
|
+
? await requestLocalControlPlaneDeployRecordAndMaybePromote({
|
|
1093
|
+
request: controlPlaneRequest,
|
|
1094
|
+
plan: controlPlanePlan,
|
|
1095
|
+
promoteWhenHealthy,
|
|
1096
|
+
...(capturedRevision ? { revision: capturedRevision } : {}),
|
|
1097
|
+
})
|
|
1098
|
+
: controlPlaneUrl
|
|
1099
|
+
? await requestHostedControlPlaneDeployRecordAndMaybePromote({
|
|
1100
|
+
request: controlPlaneRequest,
|
|
1101
|
+
plan: controlPlanePlan,
|
|
1102
|
+
controlPlaneUrl,
|
|
1103
|
+
userId,
|
|
1104
|
+
promoteWhenHealthy,
|
|
1105
|
+
promotionTimeoutMs: Number(resolveFlag(argv, '--promotion-timeout-ms') ?? '60000'),
|
|
1106
|
+
...(capturedRevision ? { revision: capturedRevision } : {}),
|
|
1107
|
+
...(() => {
|
|
1108
|
+
const deployedWorkerUrl = cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
1109
|
+
return deployedWorkerUrl ? { workerUrl: deployedWorkerUrl } : {};
|
|
1110
|
+
})(),
|
|
1111
|
+
})
|
|
1112
|
+
: undefined
|
|
1113
|
+
: undefined;
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
recordStep?.fail(firstErrorLine(error));
|
|
1117
|
+
throw error;
|
|
1118
|
+
}
|
|
1119
|
+
if (recordStep) {
|
|
1120
|
+
if (localRecordResult) {
|
|
1121
|
+
recordStep.ok(`${localRecordResult.record.status}${localRecordResult.promotion
|
|
1122
|
+
? ` · promoted ${localRecordResult.promotion.promoted.deployId}`
|
|
1123
|
+
: ''} (${elapsedSince(recordStartedAt)})`);
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
recordStep.warn('skipped');
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
609
1129
|
const controlPlaneRecord = localRecordResult?.record;
|
|
610
1130
|
const controlPlaneDrift = localRecordResult?.drift;
|
|
611
1131
|
const controlPlanePromotion = localRecordResult?.promotion;
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1132
|
+
if (echo && controlPlaneRecord?.configDrift) {
|
|
1133
|
+
const configDrift = controlPlaneRecord.configDrift;
|
|
1134
|
+
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.`);
|
|
1135
|
+
}
|
|
1136
|
+
// Default order (skipped when we already uploaded first above): generate the
|
|
1137
|
+
// HubSpot bundle from the deployed Worker URL, then upload.
|
|
1138
|
+
if (!uploadFirstOrigin && hubspotUploadRequested && validation.ok) {
|
|
1139
|
+
const bundleStep = needsHubSpotBundle ? progress?.step('Generating HubSpot bundle') : undefined;
|
|
1140
|
+
try {
|
|
1141
|
+
await ensureHubSpotRuntimeProjectArtifacts({
|
|
1142
|
+
argv,
|
|
1143
|
+
root,
|
|
1144
|
+
app,
|
|
1145
|
+
workers,
|
|
1146
|
+
cloudflareDeployResult,
|
|
1147
|
+
needsRuntime,
|
|
1148
|
+
...(controlPlanePlan ? { controlPlanePlan } : {}),
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
catch (error) {
|
|
1152
|
+
bundleStep?.fail(firstErrorLine(error));
|
|
1153
|
+
throw error;
|
|
1154
|
+
}
|
|
1155
|
+
bundleStep?.ok();
|
|
1156
|
+
}
|
|
1157
|
+
if (!uploadFirstOrigin) {
|
|
1158
|
+
hubspotUploadResult =
|
|
1159
|
+
hubspotUploadRequested &&
|
|
1160
|
+
!planOnly &&
|
|
1161
|
+
validation.ok &&
|
|
1162
|
+
!(hubspotOnly && needsRuntime) &&
|
|
1163
|
+
(!needsRuntime ||
|
|
1164
|
+
hubspotOnly ||
|
|
1165
|
+
cloudflareDeployRequested ||
|
|
1166
|
+
resolveFlag(argv, '--runtime-origin'))
|
|
1167
|
+
? await runHubSpotUploadPhase()
|
|
1168
|
+
: undefined;
|
|
622
1169
|
}
|
|
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
1170
|
// Everything that gates a healthy deploy *except* the HubSpot upload, so we
|
|
639
1171
|
// can tell a total failure apart from "the Cloudflare worker is live but the
|
|
640
1172
|
// HubSpot upload failed". The CF deploy runs first and stands on its own, so
|
|
@@ -697,10 +1229,10 @@ export async function deployCommand({ argv, root, json, }) {
|
|
|
697
1229
|
if (json) {
|
|
698
1230
|
write(`${JSON.stringify(plan, null, 2)}\n`);
|
|
699
1231
|
}
|
|
700
|
-
else {
|
|
1232
|
+
else if (progress) {
|
|
701
1233
|
renderDeployHuman({
|
|
702
|
-
|
|
703
|
-
|
|
1234
|
+
reporter: progress.reporter,
|
|
1235
|
+
counts: progress.counts(),
|
|
704
1236
|
plan,
|
|
705
1237
|
root,
|
|
706
1238
|
planOnly,
|
|
@@ -816,7 +1348,10 @@ async function emitAnonymousDeployEvent(input) {
|
|
|
816
1348
|
await fetch(endpoint, {
|
|
817
1349
|
method: 'POST',
|
|
818
1350
|
signal: controller.signal,
|
|
819
|
-
headers: {
|
|
1351
|
+
headers: {
|
|
1352
|
+
'content-type': 'application/json',
|
|
1353
|
+
accept: 'application/json',
|
|
1354
|
+
},
|
|
820
1355
|
body: JSON.stringify({
|
|
821
1356
|
schema_version: 1,
|
|
822
1357
|
event: 'deploy.succeeded',
|
|
@@ -872,28 +1407,168 @@ async function recordTenantDeployState(input) {
|
|
|
872
1407
|
updatedAt: now,
|
|
873
1408
|
}));
|
|
874
1409
|
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
:
|
|
1410
|
+
/**
|
|
1411
|
+
* Final render for the streaming deploy transcript. The phases themselves were
|
|
1412
|
+
* already narrated as steps while they ran; human/plain mode closes with a
|
|
1413
|
+
* railed key/value fact summary + the done footer, while ndjson keeps emitting
|
|
1414
|
+
* the full legacy info/warn event block (its consumers see the same events as
|
|
1415
|
+
* before, with the step events purely additive).
|
|
1416
|
+
*/
|
|
1417
|
+
function renderDeployHuman(input) {
|
|
1418
|
+
if (input.reporter.mode === 'ndjson') {
|
|
1419
|
+
renderDeployNdjsonSummary(input);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const reporter = input.reporter;
|
|
1423
|
+
reporter.rows(buildDeploySummaryRows(input));
|
|
1424
|
+
const firstWorkerUrl = input.cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
1425
|
+
if (input.plan.partial) {
|
|
1426
|
+
// The worker deployed; only the HubSpot upload failed. Say so loudly so the
|
|
1427
|
+
// live worker isn't mistaken for a total failure despite the non-zero exit.
|
|
1428
|
+
reporter.warn('HSX_W_DEPLOY_PARTIAL', `Cloudflare worker is live${firstWorkerUrl ? ` at ${firstWorkerUrl}` : ''}, but the HubSpot upload failed (see above). Exit code 20.`);
|
|
1429
|
+
}
|
|
1430
|
+
reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1, input.counts);
|
|
1431
|
+
}
|
|
1432
|
+
function deployDoneMessage(input, firstWorkerUrl) {
|
|
1433
|
+
return input.planOnly
|
|
1434
|
+
? 'Planned'
|
|
1435
|
+
: input.plan.partial
|
|
1436
|
+
? `Deployed to Cloudflare${firstWorkerUrl ? ` (${firstWorkerUrl})` : ''}; HubSpot upload failed`
|
|
1437
|
+
: input.cloudflareDeployResult
|
|
1438
|
+
? input.linked
|
|
1439
|
+
? `Deployed${firstWorkerUrl ? ` to ${firstWorkerUrl}` : ''}`
|
|
1440
|
+
: `Deployed (unlinked)${firstWorkerUrl ? ` to ${firstWorkerUrl}` : ''}; machine_id ${input.machineId}. Run \`hs-x link\` to claim this history.`
|
|
1441
|
+
: input.hubspotUploadResult
|
|
1442
|
+
? input.linked
|
|
1443
|
+
? 'Deployed to HubSpot'
|
|
1444
|
+
: `Deployed (unlinked); machine_id ${input.machineId}. Run \`hs-x link\` to claim this history.`
|
|
1445
|
+
: 'Built artifacts';
|
|
1446
|
+
}
|
|
1447
|
+
/** The railed key/value facts of what this deploy produced (human/plain). */
|
|
1448
|
+
function buildDeploySummaryRows(input) {
|
|
1449
|
+
const rows = [];
|
|
1450
|
+
if (input.controlPlanePlan) {
|
|
1451
|
+
rows.push({
|
|
1452
|
+
key: 'deploy',
|
|
1453
|
+
value: input.controlPlanePlan.deployId,
|
|
1454
|
+
status: 'ok',
|
|
1455
|
+
detail: input.unlinkedDeployPlan ? 'unlinked' : 'control plane',
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
for (const worker of input.cloudflareDeployResult?.workers ?? []) {
|
|
1459
|
+
rows.push({
|
|
1460
|
+
key: 'worker',
|
|
1461
|
+
value: worker.workerName,
|
|
1462
|
+
...(worker.dryRun
|
|
1463
|
+
? { detail: 'dry-run' }
|
|
1464
|
+
: {
|
|
1465
|
+
status: 'ok',
|
|
1466
|
+
...(worker.url ? { detail: worker.url } : {}),
|
|
1467
|
+
}),
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
if (input.controlPlaneRecord) {
|
|
1471
|
+
rows.push({
|
|
1472
|
+
key: 'record',
|
|
1473
|
+
value: input.controlPlaneRecord.status,
|
|
1474
|
+
status: 'ok',
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
if (input.controlPlanePromotion) {
|
|
1478
|
+
rows.push({
|
|
1479
|
+
key: 'promoted',
|
|
1480
|
+
value: input.controlPlanePromotion.promoted.deployId,
|
|
1481
|
+
status: 'ok',
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
if (input.hubspotUploadResult) {
|
|
1485
|
+
const upload = input.hubspotUploadResult;
|
|
1486
|
+
const buildState = readStatus(upload.buildStatus) ?? 'UNKNOWN';
|
|
1487
|
+
rows.push({
|
|
1488
|
+
key: 'hubspot build',
|
|
1489
|
+
value: `#${upload.buildId} ${buildState}`,
|
|
1490
|
+
status: buildState === 'SUCCESS' ? 'ok' : 'fail',
|
|
1491
|
+
});
|
|
1492
|
+
if (upload.deployId !== undefined) {
|
|
1493
|
+
rows.push({
|
|
1494
|
+
key: 'hubspot deploy',
|
|
1495
|
+
value: `#${upload.deployId}`,
|
|
1496
|
+
status: 'ok',
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
else if (buildState === 'SUCCESS' &&
|
|
1500
|
+
upload.deploySkipped &&
|
|
1501
|
+
upload.deployStatus !== undefined) {
|
|
1502
|
+
const autoStatus = readStatus(upload.deployStatus) ?? 'UNKNOWN';
|
|
1503
|
+
rows.push({
|
|
1504
|
+
key: 'hubspot deploy',
|
|
1505
|
+
value: `auto-deploy ${autoStatus}`,
|
|
1506
|
+
status: autoStatus === 'SUCCESS' ? 'ok' : 'warn',
|
|
1507
|
+
detail: `build #${upload.buildId}`,
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
else if (buildState === 'SUCCESS' && upload.deploySkipped) {
|
|
1511
|
+
rows.push({
|
|
1512
|
+
key: 'hubspot deploy',
|
|
1513
|
+
value: 'skipped',
|
|
1514
|
+
detail: 'upload-only or auto-deploy handles it',
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
const authUrl = hubSpotAppAuthUrl(upload);
|
|
1518
|
+
if (!input.linked && authUrl) {
|
|
1519
|
+
rows.push({
|
|
1520
|
+
key: 'app auth',
|
|
1521
|
+
value: authUrl,
|
|
1522
|
+
hint: 'grab the client secret / configure install OAuth here',
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (!input.linked && input.cloudflareDeployResult) {
|
|
1527
|
+
rows.push({ key: 'history', value: machineDashboardUrl(input.machineId) });
|
|
1528
|
+
}
|
|
1529
|
+
return rows;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Streamed right after the HubSpot upload step resolves (human/plain): the
|
|
1533
|
+
* per-component build results and the auto-deploy verdict, so a failed build is
|
|
1534
|
+
* diagnosable from the terminal, not the HubSpot UI (run-008 build #2 failed
|
|
1535
|
+
* silently here).
|
|
1536
|
+
*/
|
|
1537
|
+
function streamHubSpotUploadOutcome(reporter, upload) {
|
|
1538
|
+
for (const subbuild of readSubbuildStatuses(upload.buildStatus)) {
|
|
1539
|
+
if (subbuild.status === 'SUCCESS') {
|
|
1540
|
+
reporter.info(` [ok] ${subbuild.name}${subbuild.type ? ` (${subbuild.type})` : ''}`);
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
reporter.error('HSX_E_DEPLOY_HUBSPOT_BUILD_COMPONENT', `${subbuild.name}${subbuild.type ? ` (${subbuild.type})` : ''}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const buildState = readStatus(upload.buildStatus) ?? 'UNKNOWN';
|
|
1547
|
+
if (upload.deployId === undefined &&
|
|
1548
|
+
buildState === 'SUCCESS' &&
|
|
1549
|
+
upload.deploySkipped &&
|
|
1550
|
+
upload.deployStatus !== undefined) {
|
|
1551
|
+
const autoStatus = readStatus(upload.deployStatus) ?? 'UNKNOWN';
|
|
1552
|
+
if (autoStatus !== 'SUCCESS') {
|
|
1553
|
+
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}).`);
|
|
1554
|
+
for (const subbuild of readSubbuildStatuses(upload.deployStatus)) {
|
|
1555
|
+
if (subbuild.status !== 'SUCCESS') {
|
|
1556
|
+
reporter.info(` [x] ${subbuild.name}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Legacy single-shot summary for --json-stream: the same info/warn event block
|
|
1564
|
+
* the pre-streaming renderer emitted (steps stream additively alongside it).
|
|
1565
|
+
*/
|
|
1566
|
+
function renderDeployNdjsonSummary(input) {
|
|
1567
|
+
const reporter = input.reporter;
|
|
1568
|
+
const sources = workerSourceSummaries(input.workers);
|
|
894
1569
|
if (sources.length > 0)
|
|
895
1570
|
reporter.info(`Sources: ${sources.join(', ')}`);
|
|
896
|
-
const syncSchemaModes = input.workers
|
|
1571
|
+
const syncSchemaModes = workerSyncSchemaModes(input.workers);
|
|
897
1572
|
if (syncSchemaModes.length > 0) {
|
|
898
1573
|
reporter.info(`Portal schema management: ${syncSchemaModes.join(', ')}`);
|
|
899
1574
|
}
|
|
@@ -934,6 +1609,10 @@ function renderDeployHuman(input) {
|
|
|
934
1609
|
}
|
|
935
1610
|
if (input.controlPlaneRecord) {
|
|
936
1611
|
reporter.info(`Local control plane record status: ${input.controlPlaneRecord.status}`);
|
|
1612
|
+
const configDrift = input.controlPlaneRecord.configDrift;
|
|
1613
|
+
if (configDrift) {
|
|
1614
|
+
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.`);
|
|
1615
|
+
}
|
|
937
1616
|
}
|
|
938
1617
|
if (input.controlPlanePromotion) {
|
|
939
1618
|
reporter.info(`Promoted deploy: ${input.controlPlanePromotion.promoted.deployId}`);
|
|
@@ -977,7 +1656,7 @@ function renderDeployHuman(input) {
|
|
|
977
1656
|
reporter.info(`HubSpot deploy: auto-deploy SUCCESS (build #${upload.buildId})`);
|
|
978
1657
|
}
|
|
979
1658
|
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.
|
|
1659
|
+
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
1660
|
for (const subbuild of readSubbuildStatuses(upload.deployStatus)) {
|
|
982
1661
|
if (subbuild.status !== 'SUCCESS') {
|
|
983
1662
|
reporter.info(` [x] ${subbuild.name}: ${subbuild.errorMessage ?? subbuild.status}`);
|
|
@@ -996,24 +1675,9 @@ function renderDeployHuman(input) {
|
|
|
996
1675
|
reporter.info('Generated .hs-x/manifest.json and refs stubs.');
|
|
997
1676
|
const firstWorkerUrl = input.cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
998
1677
|
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
1678
|
reporter.warn('HSX_W_DEPLOY_PARTIAL', `Cloudflare worker is live${firstWorkerUrl ? ` at ${firstWorkerUrl}` : ''}, but the HubSpot upload failed (see above). Exit code 20.`);
|
|
1002
1679
|
}
|
|
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);
|
|
1680
|
+
reporter.done(deployDoneMessage(input, firstWorkerUrl), input.plan.ok ? 0 : input.plan.partial ? 20 : 1);
|
|
1017
1681
|
}
|
|
1018
1682
|
function machineDashboardUrl(machineId) {
|
|
1019
1683
|
const base = process.env.HSX_DASHBOARD_URL ?? 'https://app.hs-x.dev';
|
|
@@ -1026,10 +1690,17 @@ function machineDashboardUrl(machineId) {
|
|
|
1026
1690
|
* returns undefined when either is unknown (e.g. a control-plane lease deploy).
|
|
1027
1691
|
*/
|
|
1028
1692
|
function hubSpotAppAuthUrl(result) {
|
|
1029
|
-
|
|
1693
|
+
return buildHubSpotAppAuthUrl({
|
|
1694
|
+
developerAccountId: result.developerAccountId,
|
|
1695
|
+
projectName: result.projectName,
|
|
1696
|
+
appUid: result.appUid,
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
function buildHubSpotAppAuthUrl(input) {
|
|
1700
|
+
if (!input.developerAccountId || !input.appUid)
|
|
1030
1701
|
return undefined;
|
|
1031
1702
|
const base = process.env.HSX_HUBSPOT_APP_BASE_URL ?? 'https://app.hubspot.com';
|
|
1032
|
-
return `${base.replace(/\/$/, '')}/developer-projects/${encodeURIComponent(
|
|
1703
|
+
return `${base.replace(/\/$/, '')}/developer-projects/${encodeURIComponent(input.developerAccountId)}/project/${encodeURIComponent(input.projectName)}/component/${encodeURIComponent(input.appUid)}/auth`;
|
|
1033
1704
|
}
|
|
1034
1705
|
function formatDiagnostic(d) {
|
|
1035
1706
|
if (d && typeof d === 'object') {
|
|
@@ -1050,7 +1721,10 @@ async function readPortalSchema(argv, workers) {
|
|
|
1050
1721
|
if (!isRecord(value) || !Array.isArray(value.objects)) {
|
|
1051
1722
|
throw new Error('--portal-schema-fixture must point to JSON with an objects array.');
|
|
1052
1723
|
}
|
|
1053
|
-
return {
|
|
1724
|
+
return {
|
|
1725
|
+
observed: value,
|
|
1726
|
+
source: 'fixture',
|
|
1727
|
+
};
|
|
1054
1728
|
}
|
|
1055
1729
|
if (!live && !local) {
|
|
1056
1730
|
return undefined;
|
|
@@ -1075,7 +1749,9 @@ async function readLivePortalSchema(argv, workers, options) {
|
|
|
1075
1749
|
const objects = [];
|
|
1076
1750
|
for (const objectType of targets) {
|
|
1077
1751
|
try {
|
|
1078
|
-
const properties = await client.schemaManagement.listProperties({
|
|
1752
|
+
const properties = await client.schemaManagement.listProperties({
|
|
1753
|
+
objectType,
|
|
1754
|
+
});
|
|
1079
1755
|
objects.push({
|
|
1080
1756
|
name: objectType,
|
|
1081
1757
|
properties: properties.results.map(toObservedPortalProperty),
|
|
@@ -1143,7 +1819,12 @@ async function applyPortalSchemaPlan({ argv, plan, source, }) {
|
|
|
1143
1819
|
await run();
|
|
1144
1820
|
}
|
|
1145
1821
|
catch (cause) {
|
|
1146
|
-
throw new PortalSchemaApplyError({
|
|
1822
|
+
throw new PortalSchemaApplyError({
|
|
1823
|
+
action: label,
|
|
1824
|
+
objectType,
|
|
1825
|
+
applied: [...applied],
|
|
1826
|
+
cause,
|
|
1827
|
+
});
|
|
1147
1828
|
}
|
|
1148
1829
|
applied.push(label);
|
|
1149
1830
|
};
|
|
@@ -1356,26 +2037,44 @@ function portalSchemaApplyFailure(input) {
|
|
|
1356
2037
|
}, null, 2)}\n`);
|
|
1357
2038
|
return { exitCode };
|
|
1358
2039
|
}
|
|
1359
|
-
const reporter =
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
if (input.
|
|
2040
|
+
const reporter = input.progress?.reporter ??
|
|
2041
|
+
createReporter({
|
|
2042
|
+
command: 'deploy',
|
|
2043
|
+
argv: input.argv,
|
|
2044
|
+
startedAt: input.startedAt,
|
|
2045
|
+
});
|
|
2046
|
+
if (!input.progress) {
|
|
2047
|
+
// Standalone render (no streamed transcript preceded this failure):
|
|
2048
|
+
// reconstruct the header + validation context around the error.
|
|
2049
|
+
reporter.header(basename(input.root));
|
|
2050
|
+
const capCount = input.workers.reduce((count, worker) => count + worker.capabilities.length, 0);
|
|
2051
|
+
reporter
|
|
2052
|
+
.step('Validating project')
|
|
2053
|
+
.ok(`${input.workers.length} workers, ${capCount} capabilities`);
|
|
2054
|
+
if (input.portalSchemaPlan) {
|
|
2055
|
+
reporter.info(renderPortalSchemaPlan(input.portalSchemaPlan).trimEnd());
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
else if (reporter.mode === 'ndjson' && input.portalSchemaPlan) {
|
|
2059
|
+
// The streamed transcript gates the plan info to human/plain; keep the
|
|
2060
|
+
// ndjson event stream carrying it on the failure path.
|
|
1366
2061
|
reporter.info(renderPortalSchemaPlan(input.portalSchemaPlan).trimEnd());
|
|
1367
2062
|
}
|
|
1368
2063
|
if (applyError && applyError.applied.length > 0) {
|
|
1369
2064
|
for (const done of applyError.applied)
|
|
1370
2065
|
reporter.info(`APPLIED ${done}`);
|
|
1371
2066
|
}
|
|
1372
|
-
|
|
2067
|
+
// With a streamed transcript the caller already resolved the phase step with
|
|
2068
|
+
// `.fail(...)`; only the standalone render reconstructs it here.
|
|
2069
|
+
if (!input.progress) {
|
|
2070
|
+
reporter.step('Applying portal schema').fail(applyError?.action);
|
|
2071
|
+
}
|
|
1373
2072
|
reporter.error(code, message, {
|
|
1374
2073
|
hint,
|
|
1375
2074
|
cause: hubSpotDetail,
|
|
1376
2075
|
docs_url: `https://hs-x.dev/errors/${code}`,
|
|
1377
2076
|
});
|
|
1378
|
-
reporter.done(undefined, exitCode);
|
|
2077
|
+
reporter.done(undefined, exitCode, input.progress?.counts());
|
|
1379
2078
|
return { exitCode };
|
|
1380
2079
|
}
|
|
1381
2080
|
function renderPortalSchemaPlan(plan) {
|
|
@@ -1430,13 +2129,48 @@ function defaultHubSpotFieldType(type) {
|
|
|
1430
2129
|
return 'text';
|
|
1431
2130
|
}
|
|
1432
2131
|
}
|
|
1433
|
-
async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, }) {
|
|
2132
|
+
async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
|
|
1434
2133
|
// Fail fast on missing Cloudflare credentials BEFORE deploying any Worker, so
|
|
1435
2134
|
// an unlinked deploy can't leave a Worker live and then error on the
|
|
1436
2135
|
// tenant-state write for a missing account id.
|
|
1437
2136
|
if (!controlPlaneUrl && !dryRun) {
|
|
1438
2137
|
await preflightTenantStateCredentials({ argv, root });
|
|
1439
2138
|
}
|
|
2139
|
+
const hasCardBackends = workers.some((worker) => worker.capabilities.some((capability) => capability.kind === 'card-backend'));
|
|
2140
|
+
if (hasCardBackends && !dryRun) {
|
|
2141
|
+
const appConfig = await readHsxAppConfig(root);
|
|
2142
|
+
const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
|
|
2143
|
+
const authUrl = await resolveHubSpotAppAuthUrl(argv, root);
|
|
2144
|
+
const hubspotAppId = requireHubSpotCardAppId(readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root))), authUrl);
|
|
2145
|
+
const stored = controlPlaneUrl
|
|
2146
|
+
? await readDeployHubSpotOAuthSecret({
|
|
2147
|
+
controlPlaneUrl,
|
|
2148
|
+
userId,
|
|
2149
|
+
accountId: controlPlanePlan.accountId,
|
|
2150
|
+
projectId: controlPlanePlan.projectId,
|
|
2151
|
+
environment,
|
|
2152
|
+
hubSpotAppId: hubspotAppId,
|
|
2153
|
+
})
|
|
2154
|
+
: undefined;
|
|
2155
|
+
const credentials = await resolveHubSpotRuntimeCredentials({
|
|
2156
|
+
auth: appConfig.auth,
|
|
2157
|
+
requiresCardSignatureSecret: true,
|
|
2158
|
+
dryRun: false,
|
|
2159
|
+
allowPrompt: !argv.includes('--json') && !argv.includes('--yes') && isInteractive(),
|
|
2160
|
+
hubSpotClientId: resolveFlag(argv, '--hubspot-client-id') ??
|
|
2161
|
+
stored?.clientId ??
|
|
2162
|
+
process.env.HSX_HUBSPOT_CLIENT_ID,
|
|
2163
|
+
hubSpotClientSecret: resolveFlag(argv, '--hubspot-client-secret') ??
|
|
2164
|
+
stored?.clientSecret ??
|
|
2165
|
+
process.env.HSX_HUBSPOT_CLIENT_SECRET,
|
|
2166
|
+
...(authUrl ? { authUrl } : {}),
|
|
2167
|
+
});
|
|
2168
|
+
await validateHubSpotClientCredentials({
|
|
2169
|
+
clientId: credentials.clientId,
|
|
2170
|
+
clientSecret: credentials.clientSecret,
|
|
2171
|
+
...(authUrl ? { authUrl } : {}),
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
1440
2174
|
const sourcePaths = await discoverWorkerSourcePaths(root);
|
|
1441
2175
|
const concurrency = Math.max(1, Math.min(4, workers.length));
|
|
1442
2176
|
const results = await Effect.runPromise(Effect.forEach(workers.map((worker, index) => ({ worker, index })), ({ worker, index }) => Effect.promise(() => executeCloudflareWorkerDeploy({
|
|
@@ -1444,6 +2178,7 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
|
|
|
1444
2178
|
root,
|
|
1445
2179
|
worker,
|
|
1446
2180
|
index,
|
|
2181
|
+
allowCredentialPrompt: !hasCardBackends && index === 0,
|
|
1447
2182
|
sourcePaths,
|
|
1448
2183
|
controlPlanePlan,
|
|
1449
2184
|
userId,
|
|
@@ -1451,13 +2186,18 @@ async function executeCloudflareDeploy({ argv, root, workers, controlPlanePlan,
|
|
|
1451
2186
|
heartbeatEnabled,
|
|
1452
2187
|
dryRun,
|
|
1453
2188
|
...(controlPlaneUrl ? { controlPlaneUrl } : {}),
|
|
2189
|
+
...(note ? { note } : {}),
|
|
1454
2190
|
})), { concurrency }));
|
|
1455
2191
|
return {
|
|
1456
2192
|
deployId: controlPlanePlan.deployId,
|
|
1457
2193
|
workers: results,
|
|
1458
2194
|
};
|
|
1459
2195
|
}
|
|
1460
|
-
async function executeCloudflareWorkerDeploy({ argv, root, worker, index, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, }) {
|
|
2196
|
+
async function executeCloudflareWorkerDeploy({ argv, root, worker, index, allowCredentialPrompt, sourcePaths, controlPlanePlan, controlPlaneUrl, userId, machineId, heartbeatEnabled, dryRun, note, }) {
|
|
2197
|
+
const emitNote = note ??
|
|
2198
|
+
((line) => {
|
|
2199
|
+
process.stderr.write(line);
|
|
2200
|
+
});
|
|
1461
2201
|
const workerSourcePath = sourcePaths.get(worker.name);
|
|
1462
2202
|
if (!workerSourcePath) {
|
|
1463
2203
|
throw new Error(`Could not find source file for worker ${worker.name}.`);
|
|
@@ -1468,12 +2208,24 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1468
2208
|
}
|
|
1469
2209
|
const entrypointPath = join(root, '.hs-x', 'cloudflare', `${fileSlug(worker.name)}.entry.ts`);
|
|
1470
2210
|
await mkdir(dirname(entrypointPath), { recursive: true });
|
|
2211
|
+
// ADR-023 S04: translate this worker's declared sync schedules into a
|
|
2212
|
+
// deduplicated Cloudflare cron-trigger plan. The same plan feeds both the
|
|
2213
|
+
// wrangler `[triggers] crons` list and the generated `scheduled` handler's
|
|
2214
|
+
// baked dispatch map, so the fired `event.cron` always matches a sync.
|
|
2215
|
+
const cronPlan = planWorkerCronTriggers(worker);
|
|
1471
2216
|
const appConfig = await readHsxAppConfig(root);
|
|
1472
2217
|
const environment = resolveFlag(argv, '--env') ?? resolveFlag(argv, '--environment') ?? 'production';
|
|
1473
2218
|
const deployCliConfig = loadCliConfig(argv);
|
|
1474
2219
|
const runtimeControlPlaneUrl = formatConfigUrl(deployCliConfig.runtimeControlPlaneUrl ?? new URL(DEFAULT_CONTROL_PLANE_URL));
|
|
1475
2220
|
await writeFile(entrypointPath, renderCloudflareWorkerEntrypoint({
|
|
1476
2221
|
workerImportPath: relativeImportPath(dirname(entrypointPath), workerSourcePath),
|
|
2222
|
+
requireActiveCardInstall: appConfig.auth === 'oauth',
|
|
2223
|
+
...(cronPlan.dispatch.length > 0 ? { syncCron: cronPlan } : {}),
|
|
2224
|
+
...(appConfig.appEventBatching !== undefined
|
|
2225
|
+
? { appEventBatching: appConfig.appEventBatching }
|
|
2226
|
+
: {}),
|
|
2227
|
+
...(appConfig.rateLimits !== undefined ? { rateLimits: appConfig.rateLimits } : {}),
|
|
2228
|
+
...(appConfig.redaction !== undefined ? { redaction: appConfig.redaction } : {}),
|
|
1477
2229
|
...(controlPlaneUrl && appConfig.billing
|
|
1478
2230
|
? {
|
|
1479
2231
|
billing: {
|
|
@@ -1514,7 +2266,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1514
2266
|
// Resolution: flag → env → the binding persisted by the last successful
|
|
1515
2267
|
// upload (.hs-x/hubspot.json). Users should never have to replay an app id
|
|
1516
2268
|
// 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));
|
|
2269
|
+
const hubspotAppId = readOptionalHubSpotAppId(resolveFlag(argv, '--hubspot-app-id') ?? process.env.HSX_HUBSPOT_APP_ID) ?? (await readPersistedHubSpotAppId(root, await resolveHubSpotProjectName(argv, root)));
|
|
1518
2270
|
const installRuntimeBinding = hubspotAppId
|
|
1519
2271
|
? controlPlaneUrl
|
|
1520
2272
|
? await ensureDeployInstallRuntimeBinding({
|
|
@@ -1553,6 +2305,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1553
2305
|
install: installRuntimeBinding,
|
|
1554
2306
|
controlPlaneUrl,
|
|
1555
2307
|
userId,
|
|
2308
|
+
...(note ? { note } : {}),
|
|
1556
2309
|
})
|
|
1557
2310
|
: undefined;
|
|
1558
2311
|
const hubSpotOAuthSecret = hubspotAppId && controlPlaneUrl
|
|
@@ -1565,25 +2318,45 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1565
2318
|
hubSpotAppId: hubspotAppId,
|
|
1566
2319
|
})
|
|
1567
2320
|
: undefined;
|
|
1568
|
-
const hubSpotClientId =
|
|
1569
|
-
|
|
2321
|
+
const hubSpotClientId = resolveFlag(argv, '--hubspot-client-id') ??
|
|
2322
|
+
hubSpotOAuthSecret?.clientId ??
|
|
1570
2323
|
process.env.HSX_HUBSPOT_CLIENT_ID;
|
|
1571
2324
|
// The client secret comes from the control plane when linked; unlinked it can
|
|
1572
2325
|
// be supplied directly so the deployed Worker can complete the install OAuth
|
|
1573
2326
|
// token exchange (otherwise /oauth-start 500s with "missing HSX_HUBSPOT_CLIENT_ID").
|
|
1574
|
-
const hubSpotClientSecret =
|
|
1575
|
-
|
|
2327
|
+
const hubSpotClientSecret = resolveFlag(argv, '--hubspot-client-secret') ??
|
|
2328
|
+
hubSpotOAuthSecret?.clientSecret ??
|
|
1576
2329
|
process.env.HSX_HUBSPOT_CLIENT_SECRET;
|
|
1577
|
-
|
|
1578
|
-
//
|
|
1579
|
-
|
|
2330
|
+
const hasCardBackends = worker.capabilities.some((capability) => capability.kind === 'card-backend');
|
|
2331
|
+
// Interactively fill credentials required by OAuth and card backends. A card
|
|
2332
|
+
// backend is never deployed without the v3-signature HMAC key.
|
|
2333
|
+
const { clientId: resolvedHubSpotClientId, clientSecret: resolvedHubSpotClientSecret } = await resolveHubSpotRuntimeCredentials({
|
|
1580
2334
|
auth: appConfig.auth,
|
|
1581
|
-
|
|
2335
|
+
requiresCardSignatureSecret: hasCardBackends,
|
|
1582
2336
|
dryRun,
|
|
1583
|
-
allowPrompt:
|
|
2337
|
+
allowPrompt: allowCredentialPrompt,
|
|
1584
2338
|
hubSpotClientId,
|
|
1585
2339
|
hubSpotClientSecret,
|
|
1586
2340
|
});
|
|
2341
|
+
// Surface the OAuth client id + where it came from. This binding is the HMAC
|
|
2342
|
+
// key for card v3-signature verification; a wrong one fails silently at
|
|
2343
|
+
// runtime (401 invalid_hubspot_signature) hours later, so make it visible at
|
|
2344
|
+
// deploy time — if it looks wrong, `hs-x secrets hubspot-oauth set` corrects
|
|
2345
|
+
// the control-plane copy. Once per deploy (index 0), stderr to keep --json clean.
|
|
2346
|
+
if ((appConfig.auth === 'oauth' || hasCardBackends) &&
|
|
2347
|
+
(resolvedHubSpotClientId || resolvedHubSpotClientSecret) &&
|
|
2348
|
+
index === 0 &&
|
|
2349
|
+
!dryRun) {
|
|
2350
|
+
const source = resolveFlag(argv, '--hubspot-client-secret') || resolveFlag(argv, '--hubspot-client-id')
|
|
2351
|
+
? 'flag'
|
|
2352
|
+
: hubSpotOAuthSecret
|
|
2353
|
+
? 'control plane'
|
|
2354
|
+
: process.env.HSX_HUBSPOT_CLIENT_SECRET || process.env.HSX_HUBSPOT_CLIENT_ID
|
|
2355
|
+
? 'env/prompt'
|
|
2356
|
+
: 'missing';
|
|
2357
|
+
emitNote(` [hubspot auth]${resolvedHubSpotClientId ? ` client id ${resolvedHubSpotClientId};` : ''} client secret ${resolvedHubSpotClientSecret ? 'configured' : 'missing'} (source: ${source})` +
|
|
2358
|
+
`${resolvedHubSpotClientSecret ? '' : ' — no client secret; card verification will fail closed'}\n`);
|
|
2359
|
+
}
|
|
1587
2360
|
const hubSpotScopesForRuntime = await readHsxAppScopesForDeploy(root);
|
|
1588
2361
|
const billingRuntimeToken = controlPlaneUrl && appConfig.billing && !dryRun
|
|
1589
2362
|
? await requestDeployBillingRuntimeToken({
|
|
@@ -1599,6 +2372,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1599
2372
|
workerName,
|
|
1600
2373
|
entrypointPath: relativeImportPath(dirname(configPath), entrypointPath),
|
|
1601
2374
|
compatibilityDate,
|
|
2375
|
+
...(cronPlan.crons.length > 0 ? { crons: cronPlan.crons } : {}),
|
|
1602
2376
|
...(installRuntimeBinding
|
|
1603
2377
|
? { installKvNamespaceId: installRuntimeBinding.installKvNamespaceId }
|
|
1604
2378
|
: {}),
|
|
@@ -1633,7 +2407,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1633
2407
|
.then((entry) => entry.isDirectory())
|
|
1634
2408
|
.catch(() => false);
|
|
1635
2409
|
if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && !migrationsDirExists) {
|
|
1636
|
-
|
|
2410
|
+
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
2411
|
}
|
|
1638
2412
|
if (tenantDataPlane?.tenantD1DatabaseName && !dryRun && migrationsDirExists) {
|
|
1639
2413
|
try {
|
|
@@ -1776,7 +2550,7 @@ async function executeCloudflareWorkerDeploy({ argv, root, worker, index, source
|
|
|
1776
2550
|
});
|
|
1777
2551
|
}
|
|
1778
2552
|
catch (error) {
|
|
1779
|
-
|
|
2553
|
+
emitNote(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1780
2554
|
}
|
|
1781
2555
|
}
|
|
1782
2556
|
// Without the client secret on the Worker, the runtime accepts UNSIGNED
|
|
@@ -1835,14 +2609,37 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
|
|
|
1835
2609
|
hubSpotAppId: input.hubSpotAppId,
|
|
1836
2610
|
});
|
|
1837
2611
|
if (existing) {
|
|
2612
|
+
// ADR-022 fail-closed check: the binding claims an escrowed key, so the
|
|
2613
|
+
// key must resolve AND open the sealed canary before we touch anything.
|
|
2614
|
+
// Nothing has been mutated at this point — a custody failure aborts the
|
|
2615
|
+
// deploy with an actionable error and zero writes.
|
|
2616
|
+
if (existing.tokenKeyCustody.kind === 'escrowed' && !input.dryRun) {
|
|
2617
|
+
await resolveEscrowedTokenKey({ custody: existing.tokenKeyCustody });
|
|
2618
|
+
}
|
|
1838
2619
|
return {
|
|
1839
2620
|
installKvNamespaceId: existing.installKvNamespaceId,
|
|
1840
2621
|
installKvNamespaceName: existing.installKvNamespaceName,
|
|
1841
2622
|
tokenKeySecretName: existing.tokenKeySecretName,
|
|
2623
|
+
tokenKeyCustody: existing.tokenKeyCustody,
|
|
1842
2624
|
};
|
|
1843
2625
|
}
|
|
1844
2626
|
if (input.dryRun)
|
|
1845
2627
|
return undefined;
|
|
2628
|
+
// ADR-022: generate the token key and escrow it BEFORE creating any
|
|
2629
|
+
// Cloudflare resource — if custody is unavailable, the deploy stops with
|
|
2630
|
+
// zero mutations instead of minting a key that exists nowhere durable.
|
|
2631
|
+
const tokenKeySecretName = 'HSX_TOKEN_KEY';
|
|
2632
|
+
const tokenKeySecretValue = randomBytes(32).toString('base64url');
|
|
2633
|
+
const tokenKeyCustody = await escrowTokenKey({
|
|
2634
|
+
adapter: defaultTokenKeyCustodyAdapter(),
|
|
2635
|
+
custodyId: tokenKeyCustodyIdFor({
|
|
2636
|
+
ownerId: pointer.ownerId,
|
|
2637
|
+
projectId: input.projectId,
|
|
2638
|
+
environment: input.environment,
|
|
2639
|
+
hubSpotAppId: input.hubSpotAppId,
|
|
2640
|
+
}),
|
|
2641
|
+
tokenKeySecretValue,
|
|
2642
|
+
});
|
|
1846
2643
|
const installKvNamespaceName = installKvNamespaceNameFor({
|
|
1847
2644
|
accountId: input.ownerId,
|
|
1848
2645
|
projectId: input.projectId,
|
|
@@ -1867,8 +2664,8 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
|
|
|
1867
2664
|
if (!installKvNamespaceId) {
|
|
1868
2665
|
throw new Error('Could not read Cloudflare KV namespace id from wrangler output.');
|
|
1869
2666
|
}
|
|
1870
|
-
const tokenKeySecretName = 'HSX_TOKEN_KEY';
|
|
1871
2667
|
await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
|
|
2668
|
+
schemaVersion: 2,
|
|
1872
2669
|
ownerId: pointer.ownerId,
|
|
1873
2670
|
projectId: input.projectId,
|
|
1874
2671
|
hubSpotAppId: input.hubSpotAppId,
|
|
@@ -1876,18 +2673,22 @@ async function ensureTenantDeployInstallRuntimeBinding(input) {
|
|
|
1876
2673
|
installKvNamespaceId,
|
|
1877
2674
|
installKvNamespaceName,
|
|
1878
2675
|
tokenKeySecretName,
|
|
2676
|
+
tokenKeyCustody,
|
|
1879
2677
|
updatedAt: new Date().toISOString(),
|
|
1880
2678
|
}));
|
|
1881
2679
|
return {
|
|
1882
2680
|
installKvNamespaceId,
|
|
1883
2681
|
installKvNamespaceName,
|
|
1884
2682
|
tokenKeySecretName,
|
|
1885
|
-
tokenKeySecretValue
|
|
2683
|
+
tokenKeySecretValue,
|
|
2684
|
+
tokenKeyCustody,
|
|
1886
2685
|
};
|
|
1887
2686
|
}
|
|
1888
2687
|
async function ensureDeployInstallRuntimeBinding(input) {
|
|
1889
2688
|
const existing = await readDeployInstallRuntimeBinding(input);
|
|
1890
2689
|
if (existing) {
|
|
2690
|
+
// Control-plane metadata carries no key custody (and never key bytes) —
|
|
2691
|
+
// on a machine that never generated the key this is worker-secret-only.
|
|
1891
2692
|
return {
|
|
1892
2693
|
installKvNamespaceId: existing.installKvNamespaceId,
|
|
1893
2694
|
installKvNamespaceName: existing.installKvNamespaceName,
|
|
@@ -1897,6 +2698,20 @@ async function ensureDeployInstallRuntimeBinding(input) {
|
|
|
1897
2698
|
if (input.dryRun) {
|
|
1898
2699
|
return undefined;
|
|
1899
2700
|
}
|
|
2701
|
+
// ADR-022: the linked path generates the token key at the same seam, so it
|
|
2702
|
+
// gets the same escrow — before any Cloudflare mutation. Only the custody
|
|
2703
|
+
// REFERENCE lands in local state; the control plane never sees key bytes.
|
|
2704
|
+
const tokenKeySecretValue = randomBytes(32).toString('base64url');
|
|
2705
|
+
const tokenKeyCustody = await escrowTokenKey({
|
|
2706
|
+
adapter: defaultTokenKeyCustodyAdapter(),
|
|
2707
|
+
custodyId: tokenKeyCustodyIdFor({
|
|
2708
|
+
ownerId: input.accountId,
|
|
2709
|
+
projectId: input.projectId,
|
|
2710
|
+
environment: input.environment,
|
|
2711
|
+
hubSpotAppId: input.hubSpotAppId,
|
|
2712
|
+
}),
|
|
2713
|
+
tokenKeySecretValue,
|
|
2714
|
+
});
|
|
1900
2715
|
const installKvNamespaceName = installKvNamespaceNameFor({
|
|
1901
2716
|
accountId: input.accountId,
|
|
1902
2717
|
projectId: input.projectId,
|
|
@@ -1932,7 +2747,8 @@ async function ensureDeployInstallRuntimeBinding(input) {
|
|
|
1932
2747
|
installKvNamespaceId,
|
|
1933
2748
|
installKvNamespaceName,
|
|
1934
2749
|
tokenKeySecretName,
|
|
1935
|
-
tokenKeySecretValue
|
|
2750
|
+
tokenKeySecretValue,
|
|
2751
|
+
tokenKeyCustody,
|
|
1936
2752
|
};
|
|
1937
2753
|
}
|
|
1938
2754
|
async function readDeployInstallRuntimeBinding(input) {
|
|
@@ -2062,8 +2878,23 @@ async function ensureTenantDataPlane(input) {
|
|
|
2062
2878
|
const tenantD1DatabaseName = existing?.tenantD1DatabaseName ?? tenantD1DatabaseNameFor(input);
|
|
2063
2879
|
let tenantD1DatabaseId = existing?.tenantD1DatabaseId;
|
|
2064
2880
|
if (!tenantD1DatabaseId) {
|
|
2065
|
-
|
|
2066
|
-
|
|
2881
|
+
// Create, or ADOPT on a name conflict: resource names are deterministic,
|
|
2882
|
+
// so a database that already exists is this project's own tenant D1 from
|
|
2883
|
+
// an earlier deploy whose local state was lost — failing here would brick
|
|
2884
|
+
// every redeploy while the data-holding database sits one lookup away.
|
|
2885
|
+
const created = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env }).catch((error) => {
|
|
2886
|
+
if (!/already exists/i.test(String(error)))
|
|
2887
|
+
throw error;
|
|
2888
|
+
return undefined;
|
|
2889
|
+
});
|
|
2890
|
+
if (created) {
|
|
2891
|
+
tenantD1DatabaseId = extractD1DatabaseId(`${created.stdout}\n${created.stderr}`);
|
|
2892
|
+
}
|
|
2893
|
+
else {
|
|
2894
|
+
const info = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'info', tenantD1DatabaseName, '--json'], { cwd: input.root, env });
|
|
2895
|
+
tenantD1DatabaseId =
|
|
2896
|
+
extractD1DatabaseId(info.stdout) ?? /"uuid"\s*:\s*"([^"]+)"/.exec(info.stdout)?.[1];
|
|
2897
|
+
}
|
|
2067
2898
|
if (!tenantD1DatabaseId) {
|
|
2068
2899
|
throw new Error('Could not read tenant D1 database id from wrangler output.');
|
|
2069
2900
|
}
|
|
@@ -2071,8 +2902,35 @@ async function ensureTenantDataPlane(input) {
|
|
|
2071
2902
|
const flagsKvNamespaceName = existing?.flagsKvNamespaceName ?? flagsKvNamespaceNameFor(input);
|
|
2072
2903
|
let flagsKvNamespaceId = existing?.flagsKvNamespaceId;
|
|
2073
2904
|
if (!flagsKvNamespaceId) {
|
|
2074
|
-
|
|
2075
|
-
|
|
2905
|
+
// Same create-or-adopt posture as the tenant D1 above.
|
|
2906
|
+
const created = await runCloudflareCommand([
|
|
2907
|
+
'bun',
|
|
2908
|
+
'x',
|
|
2909
|
+
'wrangler',
|
|
2910
|
+
'kv',
|
|
2911
|
+
'namespace',
|
|
2912
|
+
'create',
|
|
2913
|
+
flagsKvNamespaceName,
|
|
2914
|
+
'--binding',
|
|
2915
|
+
'FLAGS_KV',
|
|
2916
|
+
], { cwd: input.root, env }).catch((error) => {
|
|
2917
|
+
if (!/already exists/i.test(String(error)))
|
|
2918
|
+
throw error;
|
|
2919
|
+
return undefined;
|
|
2920
|
+
});
|
|
2921
|
+
if (created) {
|
|
2922
|
+
flagsKvNamespaceId = extractKvNamespaceId(`${created.stdout}\n${created.stderr}`);
|
|
2923
|
+
}
|
|
2924
|
+
else {
|
|
2925
|
+
const listed = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'list'], { cwd: input.root, env });
|
|
2926
|
+
try {
|
|
2927
|
+
const namespaces = JSON.parse(listed.stdout);
|
|
2928
|
+
flagsKvNamespaceId = namespaces.find((namespace) => namespace.title === flagsKvNamespaceName)?.id;
|
|
2929
|
+
}
|
|
2930
|
+
catch {
|
|
2931
|
+
flagsKvNamespaceId = undefined;
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2076
2934
|
if (!flagsKvNamespaceId) {
|
|
2077
2935
|
throw new Error('Could not read flags KV namespace id from wrangler output.');
|
|
2078
2936
|
}
|
|
@@ -2081,6 +2939,7 @@ async function ensureTenantDataPlane(input) {
|
|
|
2081
2939
|
const reusedSecret = existing?.syncGrantSecretValue;
|
|
2082
2940
|
const syncGrantSecretValue = reusedSecret ?? randomBytes(32).toString('base64url');
|
|
2083
2941
|
await stateStore.putBinding(Schema.decodeUnknownSync(schemas.LocalAppRuntimeBinding)({
|
|
2942
|
+
schemaVersion: 2,
|
|
2084
2943
|
ownerId: pointer.ownerId,
|
|
2085
2944
|
tenantScopeAccountId: input.accountId,
|
|
2086
2945
|
projectId: input.projectId,
|
|
@@ -2089,6 +2948,15 @@ async function ensureTenantDataPlane(input) {
|
|
|
2089
2948
|
installKvNamespaceId: input.install.installKvNamespaceId,
|
|
2090
2949
|
installKvNamespaceName: input.install.installKvNamespaceName,
|
|
2091
2950
|
tokenKeySecretName: input.install.tokenKeySecretName,
|
|
2951
|
+
// ADR-022: carry custody through the rewrite. A binding re-hydrated
|
|
2952
|
+
// from the control plane on a machine that never held the key is
|
|
2953
|
+
// honestly recorded as worker-secret-only (the link preflight fails
|
|
2954
|
+
// closed on that shape).
|
|
2955
|
+
// A custody freshly minted this deploy (install.tokenKeyCustody) wins
|
|
2956
|
+
// over whatever a previous binding recorded — the Worker secret is
|
|
2957
|
+
// being set to the NEW key in this same deploy.
|
|
2958
|
+
tokenKeyCustody: input.install.tokenKeyCustody ??
|
|
2959
|
+
existing?.tokenKeyCustody ?? { kind: 'worker-secret-only' },
|
|
2092
2960
|
tenantD1DatabaseId,
|
|
2093
2961
|
tenantD1DatabaseName,
|
|
2094
2962
|
flagsKvNamespaceId,
|
|
@@ -2113,7 +2981,13 @@ async function ensureTenantDataPlane(input) {
|
|
|
2113
2981
|
});
|
|
2114
2982
|
}
|
|
2115
2983
|
catch (error) {
|
|
2116
|
-
|
|
2984
|
+
const warning = `warning: could not register the sync grant secret with the control plane: ${error instanceof Error ? error.message : String(error)}\n`;
|
|
2985
|
+
if (input.note) {
|
|
2986
|
+
input.note(warning);
|
|
2987
|
+
}
|
|
2988
|
+
else {
|
|
2989
|
+
process.stderr.write(warning);
|
|
2990
|
+
}
|
|
2117
2991
|
}
|
|
2118
2992
|
}
|
|
2119
2993
|
return {
|
|
@@ -2187,6 +3061,12 @@ export function renderWranglerConfig(input) {
|
|
|
2187
3061
|
lines.push(`migrations_dir = "${tomlString(input.tenantMigrationsDir)}"`);
|
|
2188
3062
|
}
|
|
2189
3063
|
}
|
|
3064
|
+
// ADR-023 S04: cron triggers generated from declared sync schedules. One
|
|
3065
|
+
// entry per distinct cadence (syncs sharing a schedule collapse into it);
|
|
3066
|
+
// the generated `scheduled` handler fans each fire out to its syncs.
|
|
3067
|
+
if (input.crons && input.crons.length > 0) {
|
|
3068
|
+
lines.push('', '[triggers]', `crons = [ ${input.crons.map((cron) => `"${tomlString(cron)}"`).join(', ')} ]`);
|
|
3069
|
+
}
|
|
2190
3070
|
return `${lines.join('\n')}\n`;
|
|
2191
3071
|
}
|
|
2192
3072
|
function tomlString(value) {
|
|
@@ -2212,11 +3092,195 @@ async function readDeployHubSpotOAuthSecret(input) {
|
|
|
2212
3092
|
function extractWorkerUrl(output) {
|
|
2213
3093
|
return /https:\/\/[a-zA-Z0-9.-]+\.workers\.dev\b/.exec(output)?.[0];
|
|
2214
3094
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
3095
|
+
/**
|
|
3096
|
+
* Resolve the Worker origin BEFORE deploying, for the upload-first path. An
|
|
3097
|
+
* explicit `--runtime-origin` wins; otherwise compute
|
|
3098
|
+
* `<workerName>.<subdomain>.workers.dev` from the planned worker name and the
|
|
3099
|
+
* account's workers.dev subdomain. Returns undefined (→ fall back to the
|
|
3100
|
+
* deploy-then-upload order) when the subdomain cannot be resolved, so a first
|
|
3101
|
+
* deploy that can't compute the origin behaves exactly as before.
|
|
3102
|
+
*/
|
|
3103
|
+
async function resolveUploadFirstRuntimeOrigin(input) {
|
|
3104
|
+
const flag = resolveFlag(input.argv, '--runtime-origin');
|
|
3105
|
+
if (flag)
|
|
3106
|
+
return flag;
|
|
3107
|
+
const workerName = input.controlPlanePlan.resources.workerNames[0];
|
|
3108
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
3109
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
3110
|
+
if (!workerName || !accountId || !apiToken)
|
|
3111
|
+
return undefined;
|
|
3112
|
+
const base = (process.env.HSX_CLOUDFLARE_API_BASE_URL ?? 'https://api.cloudflare.com/client/v4').replace(/\/$/, '');
|
|
3113
|
+
try {
|
|
3114
|
+
const response = await fetch(`${base}/accounts/${encodeURIComponent(accountId)}/workers/subdomain`, { headers: { authorization: `Bearer ${apiToken}` } });
|
|
3115
|
+
const body = (await response.json().catch(() => undefined));
|
|
3116
|
+
const subdomain = body?.result?.subdomain;
|
|
3117
|
+
if (!response.ok || !subdomain)
|
|
3118
|
+
return undefined;
|
|
3119
|
+
return `https://${workerName}.${subdomain}.workers.dev`;
|
|
3120
|
+
}
|
|
3121
|
+
catch {
|
|
3122
|
+
return undefined;
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
const HUBSPOT_GENERATED_LEDGER = '.hs-x/hubspot-generated-files.json';
|
|
3126
|
+
const USER_OWNED_GENERATED_PATHS = new Set(['src/app/cards/package.json']);
|
|
3127
|
+
function generatedFileHash(contents) {
|
|
3128
|
+
return createHash('sha256').update(contents).digest('hex');
|
|
3129
|
+
}
|
|
3130
|
+
function isKnownGeneratedHubSpotPath(file) {
|
|
3131
|
+
return (file === 'hsproject.json' ||
|
|
3132
|
+
file === 'src/app/app-hsmeta.json' ||
|
|
3133
|
+
file === 'src/app/cards/package.json' ||
|
|
3134
|
+
file === 'src/app/cards/_hsx-backend.ts' ||
|
|
3135
|
+
/^src\/app\/(?:workflow-actions|app-objects|app-object-associations|app-events|mcp-server|cards)\/[^/]+-hsmeta\.json$/.test(file));
|
|
3136
|
+
}
|
|
3137
|
+
/** Resolve a ledger path only when it is a known, normalized root-relative artifact. */
|
|
3138
|
+
function resolveGeneratedFilePath(root, file) {
|
|
3139
|
+
if (!isKnownGeneratedHubSpotPath(file) || isAbsolute(file) || file.includes('\\')) {
|
|
3140
|
+
return undefined;
|
|
3141
|
+
}
|
|
3142
|
+
const segments = file.split('/');
|
|
3143
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
3144
|
+
return undefined;
|
|
3145
|
+
}
|
|
3146
|
+
const rootPath = resolve(root);
|
|
3147
|
+
const resolved = resolve(rootPath, file);
|
|
3148
|
+
return resolved.startsWith(`${rootPath}/`) ? resolved : undefined;
|
|
3149
|
+
}
|
|
3150
|
+
async function assertGeneratedPathIsContained(root, path) {
|
|
3151
|
+
const rootRealPath = await realpath(root);
|
|
3152
|
+
let existingAncestor = dirname(path);
|
|
3153
|
+
while (true) {
|
|
3154
|
+
try {
|
|
3155
|
+
const ancestorRealPath = await realpath(existingAncestor);
|
|
3156
|
+
const fromRoot = relative(rootRealPath, ancestorRealPath);
|
|
3157
|
+
if (fromRoot.startsWith('..') || isAbsolute(fromRoot)) {
|
|
3158
|
+
throw new Error(`Generated HubSpot artifact parent escapes the project root: ${path}`);
|
|
3159
|
+
}
|
|
3160
|
+
break;
|
|
3161
|
+
}
|
|
3162
|
+
catch (error) {
|
|
3163
|
+
if (error.code !== 'ENOENT')
|
|
3164
|
+
throw error;
|
|
3165
|
+
const parent = dirname(existingAncestor);
|
|
3166
|
+
if (parent === existingAncestor)
|
|
3167
|
+
throw error;
|
|
3168
|
+
existingAncestor = parent;
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
const target = await lstat(path).catch((error) => {
|
|
3172
|
+
if (error.code === 'ENOENT')
|
|
3173
|
+
return undefined;
|
|
3174
|
+
throw error;
|
|
3175
|
+
});
|
|
3176
|
+
if (target?.isSymbolicLink()) {
|
|
3177
|
+
throw new Error(`Generated HubSpot artifact path must not be a symbolic link: ${path}`);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
async function readGeneratedFileLedger(root) {
|
|
3181
|
+
try {
|
|
3182
|
+
const parsed = JSON.parse(await readFile(join(root, HUBSPOT_GENERATED_LEDGER), 'utf8'));
|
|
3183
|
+
if (typeof parsed !== 'object' ||
|
|
3184
|
+
parsed === null ||
|
|
3185
|
+
parsed.version !== 1 ||
|
|
3186
|
+
typeof parsed.files !== 'object' ||
|
|
3187
|
+
parsed.files === null) {
|
|
3188
|
+
return { version: 1, files: {} };
|
|
3189
|
+
}
|
|
3190
|
+
const files = {};
|
|
3191
|
+
for (const [file, hash] of Object.entries(parsed.files)) {
|
|
3192
|
+
if (resolveGeneratedFilePath(root, file) && /^[a-f0-9]{64}$/.test(String(hash))) {
|
|
3193
|
+
files[file] = String(hash);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
return { version: 1, files };
|
|
3197
|
+
}
|
|
3198
|
+
catch {
|
|
3199
|
+
// No heuristic cleanup for projects that predate the ledger, or for a
|
|
3200
|
+
// malformed ledger. Generation can safely establish a fresh ledger.
|
|
3201
|
+
return { version: 1, files: {} };
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
async function pruneEmptyGeneratedDirectories(root, from) {
|
|
3205
|
+
const rootPath = resolve(root);
|
|
3206
|
+
let directory = resolve(from);
|
|
3207
|
+
while (directory !== rootPath && directory.startsWith(`${rootPath}/`)) {
|
|
3208
|
+
try {
|
|
3209
|
+
if ((await readdir(directory)).length > 0)
|
|
3210
|
+
return;
|
|
3211
|
+
await rmdir(directory);
|
|
3212
|
+
}
|
|
3213
|
+
catch {
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
directory = dirname(directory);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
async function reconcileHubSpotRuntimeProjectFiles(root, generatedFiles, options = {}) {
|
|
3220
|
+
const previous = await readGeneratedFileLedger(root);
|
|
3221
|
+
const generatedPaths = new Set(Object.keys(generatedFiles));
|
|
3222
|
+
const nextFiles = {};
|
|
3223
|
+
for (const [file, expectedHash] of Object.entries(previous.files)) {
|
|
3224
|
+
if (generatedPaths.has(file) || USER_OWNED_GENERATED_PATHS.has(file))
|
|
3225
|
+
continue;
|
|
3226
|
+
if (options.preservePrevious?.(file)) {
|
|
3227
|
+
nextFiles[file] = expectedHash;
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
const path = resolveGeneratedFilePath(root, file);
|
|
3231
|
+
if (!path)
|
|
3232
|
+
continue;
|
|
3233
|
+
try {
|
|
3234
|
+
await assertGeneratedPathIsContained(root, path);
|
|
3235
|
+
const current = await readFile(path);
|
|
3236
|
+
if (generatedFileHash(current) !== expectedHash)
|
|
3237
|
+
continue;
|
|
3238
|
+
await unlink(path);
|
|
3239
|
+
await pruneEmptyGeneratedDirectories(root, dirname(path));
|
|
3240
|
+
}
|
|
3241
|
+
catch {
|
|
3242
|
+
// Missing and non-file paths are preserved; only a byte-for-byte owned
|
|
3243
|
+
// regular file is eligible for removal.
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
for (const [file, contents] of Object.entries(generatedFiles)) {
|
|
3247
|
+
const path = resolveGeneratedFilePath(root, file);
|
|
3248
|
+
if (!path)
|
|
3249
|
+
throw new Error(`Generated HubSpot artifact has an unsafe path: ${file}`);
|
|
3250
|
+
await assertGeneratedPathIsContained(root, path);
|
|
3251
|
+
// HubSpot tells card authors to add dependencies here. Once created, this
|
|
3252
|
+
// file is user-owned and is deliberately never entered in the ledger.
|
|
3253
|
+
if (USER_OWNED_GENERATED_PATHS.has(file)) {
|
|
3254
|
+
if (!(await stat(path).catch(() => undefined))) {
|
|
3255
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3256
|
+
await writeFile(path, contents);
|
|
3257
|
+
}
|
|
3258
|
+
continue;
|
|
3259
|
+
}
|
|
3260
|
+
await mkdir(dirname(path), { recursive: true });
|
|
3261
|
+
await writeFile(path, contents);
|
|
3262
|
+
nextFiles[file] = generatedFileHash(contents);
|
|
3263
|
+
}
|
|
3264
|
+
const ledgerPath = join(root, HUBSPOT_GENERATED_LEDGER);
|
|
3265
|
+
await assertGeneratedPathIsContained(root, ledgerPath);
|
|
3266
|
+
await mkdir(dirname(ledgerPath), { recursive: true });
|
|
3267
|
+
const temporaryLedgerPath = `${ledgerPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
3268
|
+
await writeFile(temporaryLedgerPath, `${JSON.stringify({ version: 1, files: nextFiles }, null, 2)}\n`);
|
|
3269
|
+
await rename(temporaryLedgerPath, ledgerPath);
|
|
3270
|
+
}
|
|
3271
|
+
export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, runtimeOrigin, }) {
|
|
3272
|
+
const hasMcpServers = app.mcpServers.length > 0;
|
|
3273
|
+
if (!needsRuntime && !hasMcpServers) {
|
|
3274
|
+
// A prior MCP-only generation may have left ledger-owned MCP files. Remove
|
|
3275
|
+
// those declarations, but retain other generated metadata: a display-only
|
|
3276
|
+
// card or schema declaration remains valid when its runtime worker is gone.
|
|
3277
|
+
await reconcileHubSpotRuntimeProjectFiles(root, {}, {
|
|
3278
|
+
preservePrevious: (file) => !file.startsWith('src/app/mcp-server/'),
|
|
3279
|
+
});
|
|
2217
3280
|
return;
|
|
2218
3281
|
}
|
|
2219
|
-
const runtimeBaseUrl =
|
|
3282
|
+
const runtimeBaseUrl = runtimeOrigin ??
|
|
3283
|
+
resolveFlag(argv, '--runtime-origin') ??
|
|
2220
3284
|
cloudflareDeployResult?.workers.find((worker) => worker.url)?.url;
|
|
2221
3285
|
if (!runtimeBaseUrl) {
|
|
2222
3286
|
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.');
|
|
@@ -2225,24 +3289,20 @@ export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, wo
|
|
|
2225
3289
|
const redirectUrls = app.auth === 'oauth' && oauthCallbackPath
|
|
2226
3290
|
? [`${runtimeBaseUrl.replace(/\/$/, '')}${oauthCallbackPath}`]
|
|
2227
3291
|
: [];
|
|
3292
|
+
const platformVersion = resolveFlag(argv, '--platform-version') ?? app.platformVersion;
|
|
2228
3293
|
const generated = generateHubSpotRuntimeProject({
|
|
2229
3294
|
...app,
|
|
3295
|
+
platformVersion,
|
|
2230
3296
|
workers,
|
|
2231
3297
|
runtimeBaseUrl,
|
|
2232
3298
|
redirectUrls,
|
|
2233
3299
|
});
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
// user's file verbatim; only generate one when absent.
|
|
2241
|
-
if (file === 'src/app/cards/package.json' && (await stat(path).catch(() => undefined))) {
|
|
2242
|
-
continue;
|
|
2243
|
-
}
|
|
2244
|
-
await mkdir(dirname(path), { recursive: true });
|
|
2245
|
-
await writeFile(path, contents);
|
|
3300
|
+
await reconcileHubSpotRuntimeProjectFiles(root, generated.files);
|
|
3301
|
+
const migratedShimPath = join(root, 'src', 'app', 'cards', '_hsx-migration-run-serverless.ts');
|
|
3302
|
+
await assertGeneratedPathIsContained(root, migratedShimPath);
|
|
3303
|
+
if (await stat(migratedShimPath).catch(() => undefined)) {
|
|
3304
|
+
const migratedShim = await readFile(migratedShimPath, 'utf8');
|
|
3305
|
+
await writeFile(migratedShimPath, stampMigratedRunServerlessShimOrigin(migratedShim, runtimeBaseUrl));
|
|
2246
3306
|
}
|
|
2247
3307
|
}
|
|
2248
3308
|
async function readHsxAppScopesForDeploy(root) {
|
|
@@ -2254,9 +3314,19 @@ async function readHsxAppScopesForDeploy(root) {
|
|
|
2254
3314
|
return [];
|
|
2255
3315
|
}
|
|
2256
3316
|
}
|
|
2257
|
-
async function readHsxAppConfig(root) {
|
|
3317
|
+
export async function readHsxAppConfig(root) {
|
|
2258
3318
|
const raw = await readFile(join(root, 'hsx.config.ts'), 'utf8');
|
|
2259
|
-
|
|
3319
|
+
let loaded = parseHsxAppConfigLiteral(raw);
|
|
3320
|
+
if (!loaded) {
|
|
3321
|
+
try {
|
|
3322
|
+
loaded = await loadHsxAppConfigModule(root);
|
|
3323
|
+
}
|
|
3324
|
+
catch (error) {
|
|
3325
|
+
if (/\bmcpServer\s*\(|\bmcpServers\b/.test(raw)) {
|
|
3326
|
+
throw new Error('Could not evaluate helper-based MCP declarations in hsx.config.ts; refusing to treat the declaration set as empty because that could remove generated MCP metadata.', { cause: error });
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
2260
3330
|
return {
|
|
2261
3331
|
appName: loaded?.name ?? /name\s*:\s*["'`]([^"'`]+)["'`]/.exec(raw)?.[1] ?? basename(root),
|
|
2262
3332
|
// No regex fallback for description: a raw-text match could pick up a
|
|
@@ -2272,9 +3342,16 @@ async function readHsxAppConfig(root) {
|
|
|
2272
3342
|
appObjects: discoverAppObjectDeclarations(raw),
|
|
2273
3343
|
appObjectAssociations: discoverAppObjectAssociationDeclarations(raw),
|
|
2274
3344
|
appEvents: discoverAppEventDeclarations(raw),
|
|
2275
|
-
|
|
2276
|
-
|
|
3345
|
+
...(loaded?.appEvents?.batching !== undefined
|
|
3346
|
+
? { appEventBatching: loaded.appEvents.batching }
|
|
3347
|
+
: {}),
|
|
3348
|
+
...(loaded?.rateLimits !== undefined ? { rateLimits: loaded.rateLimits } : {}),
|
|
3349
|
+
...(loaded?.alerts !== undefined ? { alerts: loaded.alerts } : {}),
|
|
3350
|
+
...(loaded?.redaction !== undefined ? { redaction: loaded.redaction } : {}),
|
|
3351
|
+
// Helper-based declarations are only recoverable from the loaded module
|
|
3352
|
+
// (the literal parser cannot evaluate card(...) or mcpServer(...) calls).
|
|
2277
3353
|
cards: loaded?.cards ?? [],
|
|
3354
|
+
mcpServers: loaded?.mcpServers ?? [],
|
|
2278
3355
|
...(loaded?.billing ? { billing: loaded.billing } : {}),
|
|
2279
3356
|
};
|
|
2280
3357
|
}
|
|
@@ -2375,13 +3452,19 @@ function discoverAppObjectDeclarations(source) {
|
|
|
2375
3452
|
}
|
|
2376
3453
|
: {}),
|
|
2377
3454
|
...(readStringArrayProperty(body, 'requiredProperties').length
|
|
2378
|
-
? {
|
|
3455
|
+
? {
|
|
3456
|
+
requiredProperties: readStringArrayProperty(body, 'requiredProperties'),
|
|
3457
|
+
}
|
|
2379
3458
|
: {}),
|
|
2380
3459
|
...(readStringArrayProperty(body, 'searchableProperties').length
|
|
2381
|
-
? {
|
|
3460
|
+
? {
|
|
3461
|
+
searchableProperties: readStringArrayProperty(body, 'searchableProperties'),
|
|
3462
|
+
}
|
|
2382
3463
|
: {}),
|
|
2383
3464
|
...(readStringArrayProperty(body, 'defaultCreateFormFields').length
|
|
2384
|
-
? {
|
|
3465
|
+
? {
|
|
3466
|
+
defaultCreateFormFields: readStringArrayProperty(body, 'defaultCreateFormFields'),
|
|
3467
|
+
}
|
|
2385
3468
|
: {}),
|
|
2386
3469
|
properties,
|
|
2387
3470
|
};
|
|
@@ -2414,7 +3497,9 @@ function discoverAppEventDeclarations(source) {
|
|
|
2414
3497
|
objectType: stringPropertyValue(body, 'objectType') ?? 'CONTACT',
|
|
2415
3498
|
...(booleanPropertyValue(body, 'supportsCustomObject') === undefined
|
|
2416
3499
|
? {}
|
|
2417
|
-
: {
|
|
3500
|
+
: {
|
|
3501
|
+
supportsCustomObject: booleanPropertyValue(body, 'supportsCustomObject'),
|
|
3502
|
+
}),
|
|
2418
3503
|
...(stringPropertyValue(body, 'headerTemplate')
|
|
2419
3504
|
? { headerTemplate: stringPropertyValue(body, 'headerTemplate') }
|
|
2420
3505
|
: {}),
|
|
@@ -2434,7 +3519,10 @@ function declarationBodies(source, functionName) {
|
|
|
2434
3519
|
const end = findMatching(source, start, '{', '}');
|
|
2435
3520
|
if (end === -1)
|
|
2436
3521
|
continue;
|
|
2437
|
-
declarations.push({
|
|
3522
|
+
declarations.push({
|
|
3523
|
+
id: match[1] ?? 'unknown',
|
|
3524
|
+
body: source.slice(start + 1, end),
|
|
3525
|
+
});
|
|
2438
3526
|
}
|
|
2439
3527
|
return declarations;
|
|
2440
3528
|
}
|
|
@@ -2482,7 +3570,10 @@ function topLevelObjectEntries(source) {
|
|
|
2482
3570
|
if (braceStart !== -1 && source.slice(valueStart, braceStart).trim() === '') {
|
|
2483
3571
|
const braceEnd = findMatching(source, braceStart, '{', '}');
|
|
2484
3572
|
if (braceEnd !== -1) {
|
|
2485
|
-
entries.push({
|
|
3573
|
+
entries.push({
|
|
3574
|
+
name,
|
|
3575
|
+
body: source.slice(braceStart + 1, braceEnd),
|
|
3576
|
+
});
|
|
2486
3577
|
index = braceEnd + 1;
|
|
2487
3578
|
continue;
|
|
2488
3579
|
}
|
|
@@ -2569,16 +3660,12 @@ function runCloudflareCommand(command, options) {
|
|
|
2569
3660
|
})));
|
|
2570
3661
|
}
|
|
2571
3662
|
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
3663
|
// The HubSpot project name must match the name baked into the generated
|
|
2577
3664
|
// bundle (hsproject.json + app-hsmeta uid), not the on-disk directory name.
|
|
2578
3665
|
// Using basename(root) breaks when the project lives in a generic dir (e.g.
|
|
2579
3666
|
// `app/`): the upload targets a project named "app" while the bundle declares
|
|
2580
3667
|
// the real project id, and HubSpot rejects the mismatch with an opaque 400.
|
|
2581
|
-
const projectName =
|
|
3668
|
+
const projectName = await resolveHubSpotProjectName(argv, root);
|
|
2582
3669
|
const intermediateRepresentation = await readHubSpotProjectIntermediateRepresentation(root);
|
|
2583
3670
|
const appUid = readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
|
|
2584
3671
|
(await readAppUidFromProjectSource(root));
|
|
@@ -2596,11 +3683,13 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2596
3683
|
cwd: root,
|
|
2597
3684
|
});
|
|
2598
3685
|
await client.projects.ensureProject({ projectName });
|
|
3686
|
+
const hsproject = await readHubSpotProjectConfig(root);
|
|
3687
|
+
const configuredPlatformVersion = typeof hsproject.platformVersion === 'string' ? hsproject.platformVersion : undefined;
|
|
2599
3688
|
const upload = await client.projects.upload({
|
|
2600
3689
|
projectName,
|
|
2601
3690
|
archivePath,
|
|
2602
3691
|
message: resolveFlag(argv, '--message') ?? 'HS-X HubSpot-only deploy',
|
|
2603
|
-
platformVersion: resolveFlag(argv, '--platform-version') ?? '2026.03',
|
|
3692
|
+
platformVersion: resolveFlag(argv, '--platform-version') ?? configuredPlatformVersion ?? '2026.03',
|
|
2604
3693
|
intermediateRepresentation,
|
|
2605
3694
|
});
|
|
2606
3695
|
const buildStatus = await waitForHubSpotBuildStatus({
|
|
@@ -2616,7 +3705,11 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2616
3705
|
? await optionalHubSpotAppId(client, projectName, appUid)
|
|
2617
3706
|
: 0;
|
|
2618
3707
|
if (appId)
|
|
2619
|
-
await persistHubSpotAppBinding(root, {
|
|
3708
|
+
await persistHubSpotAppBinding(root, {
|
|
3709
|
+
appId,
|
|
3710
|
+
projectName,
|
|
3711
|
+
developerAccountId,
|
|
3712
|
+
});
|
|
2620
3713
|
// Build SUCCESS does NOT mean the app shipped: HubSpot's auto-deploy can
|
|
2621
3714
|
// still fail (found live: "Apps are not allowed to have 0 redirect URLs"
|
|
2622
3715
|
// failed deploy #4 while the CLI reported green). Verify the deploy too.
|
|
@@ -2652,7 +3745,11 @@ async function executeHubSpotOnlyUpload({ argv, root, local, uploadOnly, }) {
|
|
|
2652
3745
|
deployId,
|
|
2653
3746
|
});
|
|
2654
3747
|
const appId = await optionalHubSpotAppId(client, projectName, appUid);
|
|
2655
|
-
await persistHubSpotAppBinding(root, {
|
|
3748
|
+
await persistHubSpotAppBinding(root, {
|
|
3749
|
+
appId,
|
|
3750
|
+
projectName,
|
|
3751
|
+
developerAccountId,
|
|
3752
|
+
});
|
|
2656
3753
|
return {
|
|
2657
3754
|
projectName,
|
|
2658
3755
|
archivePath,
|
|
@@ -2869,7 +3966,7 @@ async function collectHubSpotMetaFiles(root) {
|
|
|
2869
3966
|
}
|
|
2870
3967
|
function runZip(root, archivePath) {
|
|
2871
3968
|
return new Promise((resolve, reject) => {
|
|
2872
|
-
const child = spawn('zip', ['-qr', archivePath, '.', '-x', 'node_modules/*', '.hs-x/*'], {
|
|
3969
|
+
const child = spawn('zip', ['-qr', archivePath, '.', '-x', 'node_modules/*', '*/node_modules/*', '.hs-x/*'], {
|
|
2873
3970
|
cwd: root,
|
|
2874
3971
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
2875
3972
|
});
|
|
@@ -2906,25 +4003,59 @@ async function persistHubSpotAppBinding(root, binding) {
|
|
|
2906
4003
|
await writeFile(join(dir, HUBSPOT_APP_BINDING_FILE[1]), `${JSON.stringify({
|
|
2907
4004
|
appId: binding.appId,
|
|
2908
4005
|
projectName: binding.projectName,
|
|
2909
|
-
...(binding.developerAccountId
|
|
2910
|
-
? { developerAccountId: binding.developerAccountId }
|
|
2911
|
-
: {}),
|
|
4006
|
+
...(binding.developerAccountId ? { developerAccountId: binding.developerAccountId } : {}),
|
|
2912
4007
|
}, null, 2)}\n`);
|
|
2913
4008
|
}
|
|
2914
4009
|
catch {
|
|
2915
4010
|
// Advisory persistence — never fail a deploy over it.
|
|
2916
4011
|
}
|
|
2917
4012
|
}
|
|
2918
|
-
async function
|
|
4013
|
+
async function resolveHubSpotProjectName(argv, root) {
|
|
4014
|
+
const hsprojectConfig = await readHubSpotProjectConfig(root);
|
|
4015
|
+
const configuredProjectName = typeof hsprojectConfig.name === 'string' && hsprojectConfig.name.length > 0
|
|
4016
|
+
? hsprojectConfig.name
|
|
4017
|
+
: undefined;
|
|
4018
|
+
return resolveFlag(argv, '--hubspot-project-name') ?? configuredProjectName ?? basename(root);
|
|
4019
|
+
}
|
|
4020
|
+
async function readPersistedHubSpotAppBinding(root, expectedProjectName) {
|
|
2919
4021
|
try {
|
|
2920
4022
|
const raw = await readFile(join(root, ...HUBSPOT_APP_BINDING_FILE), 'utf8');
|
|
2921
4023
|
const parsed = JSON.parse(raw);
|
|
2922
|
-
|
|
4024
|
+
// The numeric app id is only meaningful for the HubSpot project that
|
|
4025
|
+
// produced it. A renamed/re-generated project must upload first and mint
|
|
4026
|
+
// its own app id; reusing the prior project's id also binds the prior
|
|
4027
|
+
// client secret and makes every card v3 signature fail at runtime.
|
|
4028
|
+
if (parsed.projectName !== expectedProjectName)
|
|
4029
|
+
return undefined;
|
|
4030
|
+
const appId = readOptionalHubSpotAppId(typeof parsed.appId === 'number' ? String(parsed.appId) : undefined);
|
|
4031
|
+
if (!appId)
|
|
4032
|
+
return undefined;
|
|
4033
|
+
return {
|
|
4034
|
+
appId,
|
|
4035
|
+
projectName: expectedProjectName,
|
|
4036
|
+
...(typeof parsed.developerAccountId === 'string' && parsed.developerAccountId.length > 0
|
|
4037
|
+
? { developerAccountId: parsed.developerAccountId }
|
|
4038
|
+
: {}),
|
|
4039
|
+
};
|
|
2923
4040
|
}
|
|
2924
4041
|
catch {
|
|
2925
4042
|
return undefined;
|
|
2926
4043
|
}
|
|
2927
4044
|
}
|
|
4045
|
+
async function readPersistedHubSpotAppId(root, expectedProjectName) {
|
|
4046
|
+
return (await readPersistedHubSpotAppBinding(root, expectedProjectName))?.appId;
|
|
4047
|
+
}
|
|
4048
|
+
async function resolveHubSpotAppAuthUrl(argv, root) {
|
|
4049
|
+
const projectName = await resolveHubSpotProjectName(argv, root);
|
|
4050
|
+
const persisted = await readPersistedHubSpotAppBinding(root, projectName);
|
|
4051
|
+
const intermediateRepresentation = await readHubSpotProjectIntermediateRepresentation(root);
|
|
4052
|
+
const appUid = readAppUidFromIntermediateRepresentation(intermediateRepresentation) ??
|
|
4053
|
+
(await readAppUidFromProjectSource(root));
|
|
4054
|
+
const developerAccountId = resolveFlag(argv, '--developer-account-id') ??
|
|
4055
|
+
process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID ??
|
|
4056
|
+
persisted?.developerAccountId;
|
|
4057
|
+
return buildHubSpotAppAuthUrl({ developerAccountId, projectName, appUid });
|
|
4058
|
+
}
|
|
2928
4059
|
function readStatus(value) {
|
|
2929
4060
|
return isRecord(value) && typeof value.status === 'string' ? value.status : undefined;
|
|
2930
4061
|
}
|
|
@@ -2957,8 +4088,41 @@ function readOptionalHubSpotAppId(value) {
|
|
|
2957
4088
|
const parsed = readPositiveInteger(value, '--hubspot-app-id');
|
|
2958
4089
|
return Schema.decodeSync(schemas.HubSpotAppId)(parsed);
|
|
2959
4090
|
}
|
|
2960
|
-
|
|
2961
|
-
|
|
4091
|
+
/**
|
|
4092
|
+
* ADR-021 R03 deploy-time capture. Reads back Cloudflare's confirmed active
|
|
4093
|
+
* deployment for the primary Worker and returns its verifiable revision, or
|
|
4094
|
+
* `undefined` (with a warning) when credentials are missing or the read-back
|
|
4095
|
+
* fails. Best-effort: never fabricates an id and never fails the deploy —
|
|
4096
|
+
* capture is a pure read, so a retry re-reads the same version id.
|
|
4097
|
+
*/
|
|
4098
|
+
async function captureDeployRevisionForDeploy(input) {
|
|
4099
|
+
// Worker/script names are authoritative and never renamed (T03); the first
|
|
4100
|
+
// planned Worker is the project's primary runtime Worker.
|
|
4101
|
+
const scriptName = input.controlPlanePlan.resources.workerNames[0];
|
|
4102
|
+
if (!scriptName)
|
|
4103
|
+
return undefined;
|
|
4104
|
+
const warn = (message) => {
|
|
4105
|
+
if (!input.json) {
|
|
4106
|
+
(input.reporter ?? createReporter({ command: 'deploy', argv: input.argv })).warn('HSX_W_DEPLOY_REVISION_CAPTURE', message);
|
|
4107
|
+
}
|
|
4108
|
+
};
|
|
4109
|
+
const creds = resolveCloudflareCredentials(input.argv);
|
|
4110
|
+
const cloudflareAccountId = creds.accountId ?? (await loadCloudflarePointer(input.root))?.cloudflareAccountId;
|
|
4111
|
+
if (!creds.apiToken || !cloudflareAccountId) {
|
|
4112
|
+
warn('Skipped Cloudflare version capture: no Cloudflare API token / account id available. This deploy is recorded without a rollback-capable revision.');
|
|
4113
|
+
return undefined;
|
|
4114
|
+
}
|
|
4115
|
+
try {
|
|
4116
|
+
return await captureDeployRevision({
|
|
4117
|
+
cloudflareAccountId,
|
|
4118
|
+
scriptName,
|
|
4119
|
+
apiToken: creds.apiToken,
|
|
4120
|
+
});
|
|
4121
|
+
}
|
|
4122
|
+
catch (error) {
|
|
4123
|
+
warn(`Could not read back the Cloudflare Worker version for "${scriptName}" (${error instanceof Error ? error.message : String(error)}); recorded without a rollback-capable revision.`);
|
|
4124
|
+
return undefined;
|
|
4125
|
+
}
|
|
2962
4126
|
}
|
|
2963
4127
|
async function requestLocalControlPlaneDeployPlan(request) {
|
|
2964
4128
|
const controlPlane = (await loadCreateControlPlane())({
|
|
@@ -2997,7 +4161,7 @@ async function requestHostedControlPlaneDeployPlan({ request, controlPlaneUrl, u
|
|
|
2997
4161
|
}
|
|
2998
4162
|
return Schema.decodeUnknownSync(schemas.DeployPlanResponse)(body);
|
|
2999
4163
|
}
|
|
3000
|
-
async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, }) {
|
|
4164
|
+
async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, plan, promoteWhenHealthy, revision, }) {
|
|
3001
4165
|
const controlPlane = (await loadCreateControlPlane())({
|
|
3002
4166
|
now: () => new Date('2026-05-18T14:00:00.000Z'),
|
|
3003
4167
|
});
|
|
@@ -3020,7 +4184,10 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
|
|
|
3020
4184
|
manifestHash: plan.manifestHash,
|
|
3021
4185
|
signedManifest: request.manifest,
|
|
3022
4186
|
signature: `sig_${plan.deployId}`,
|
|
3023
|
-
|
|
4187
|
+
// ADR-021 R03: the real Cloudflare Worker version identity captured
|
|
4188
|
+
// back from the confirmed active deployment. Absent only when capture
|
|
4189
|
+
// was skipped/failed (best-effort) — then this deploy is history-only.
|
|
4190
|
+
...(revision ? { revision } : {}),
|
|
3024
4191
|
}),
|
|
3025
4192
|
}));
|
|
3026
4193
|
const body = await response.json();
|
|
@@ -3061,7 +4228,10 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
|
|
|
3061
4228
|
const promotionResponse = await controlPlane.fetch(new Request(`https://api.hs-x.dev/v1/deploys/${plan.deployId}/promote`, {
|
|
3062
4229
|
method: 'POST',
|
|
3063
4230
|
headers,
|
|
3064
|
-
body: JSON.stringify({
|
|
4231
|
+
body: JSON.stringify({
|
|
4232
|
+
accountId: request.accountId,
|
|
4233
|
+
projectId: request.projectId,
|
|
4234
|
+
}),
|
|
3065
4235
|
}));
|
|
3066
4236
|
const promotionBody = await promotionResponse.json();
|
|
3067
4237
|
if (!promotionResponse.ok) {
|
|
@@ -3075,7 +4245,7 @@ async function requestLocalControlPlaneDeployRecordAndMaybePromote({ request, pl
|
|
|
3075
4245
|
promotion: Schema.decodeUnknownSync(schemas.DeployPromotionResult)(promotionBody),
|
|
3076
4246
|
};
|
|
3077
4247
|
}
|
|
3078
|
-
async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, plan, controlPlaneUrl, userId, promoteWhenHealthy, promotionTimeoutMs, workerUrl, }) {
|
|
4248
|
+
async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, plan, controlPlaneUrl, userId, promoteWhenHealthy, promotionTimeoutMs, workerUrl, revision, }) {
|
|
3079
4249
|
const response = await hostedHttp({
|
|
3080
4250
|
url: new URL(`/v1/deploys/${encodeURIComponent(plan.deployId)}/record`, controlPlaneUrl),
|
|
3081
4251
|
method: 'POST',
|
|
@@ -3086,7 +4256,10 @@ async function requestHostedControlPlaneDeployRecordAndMaybePromote({ request, p
|
|
|
3086
4256
|
manifestHash: plan.manifestHash,
|
|
3087
4257
|
signedManifest: request.manifest,
|
|
3088
4258
|
signature: `sig_${plan.deployId}`,
|
|
3089
|
-
|
|
4259
|
+
// ADR-021 R03: the real Cloudflare Worker version identity captured back
|
|
4260
|
+
// from the confirmed active deployment. Absent only when capture was
|
|
4261
|
+
// skipped/failed (best-effort) — then this deploy is history-only.
|
|
4262
|
+
...(revision ? { revision } : {}),
|
|
3090
4263
|
...(await collectDeployProvenance(userId)),
|
|
3091
4264
|
},
|
|
3092
4265
|
});
|
|
@@ -3157,6 +4330,18 @@ async function discoverWorkerManifests(root, options = {}) {
|
|
|
3157
4330
|
if (!workerName) {
|
|
3158
4331
|
continue;
|
|
3159
4332
|
}
|
|
4333
|
+
// Prefer the runtime manifest (the source of truth `worker.manifest()`
|
|
4334
|
+
// produces): load the worker module and read its capabilities directly.
|
|
4335
|
+
// This handles loops, shared field-map consts, computed ids, and full field
|
|
4336
|
+
// fidelity (supportedValueTypes / isRequired / descriptions) that source
|
|
4337
|
+
// regex cannot — and keeps deploy in agreement with the runtime + dev/invoke
|
|
4338
|
+
// (which already call `.manifest()`). Falls back to regex parsing when the
|
|
4339
|
+
// module can't be imported. See checkpoint FINDINGS #14.
|
|
4340
|
+
const loadedManifest = await loadWorkerManifestModule(file).catch(() => undefined);
|
|
4341
|
+
if (loadedManifest) {
|
|
4342
|
+
workers.push(applyNoManageSchemaOverride(loadedManifest, options));
|
|
4343
|
+
continue;
|
|
4344
|
+
}
|
|
3160
4345
|
const toolCapabilities = [
|
|
3161
4346
|
...source.matchAll(/(?:worker\.)?(?:tool|action)\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*\{([\s\S]*?)(?:async\s+)?handler\s*(?:[:(])/g),
|
|
3162
4347
|
].map((match) => ({
|
|
@@ -3174,6 +4359,8 @@ async function discoverWorkerManifests(root, options = {}) {
|
|
|
3174
4359
|
kind: 'card-backend',
|
|
3175
4360
|
id: match[1] ?? 'unknown',
|
|
3176
4361
|
label: /label\s*:\s*["'`]([^"'`]+)["'`]/.exec(match[2] ?? '')?.[1] ?? match[1] ?? 'unknown',
|
|
4362
|
+
...objectFieldsProperty(match[2] ?? '', 'input'),
|
|
4363
|
+
...objectFieldsProperty(match[2] ?? '', 'output'),
|
|
3177
4364
|
runtimeNeeds: ['worker'],
|
|
3178
4365
|
}));
|
|
3179
4366
|
const sourceDefinitions = discoverSourceDefinitions(source);
|
|
@@ -3221,6 +4408,41 @@ async function discoverWorkerManifests(root, options = {}) {
|
|
|
3221
4408
|
}
|
|
3222
4409
|
return workers;
|
|
3223
4410
|
}
|
|
4411
|
+
/**
|
|
4412
|
+
* Load a worker module and return its `worker.manifest()` — the runtime source
|
|
4413
|
+
* of truth. Mirrors `loadHsxAppConfigModule`: a cache-busted dynamic import of
|
|
4414
|
+
* the user's TypeScript. Returns undefined (so the caller falls back to regex
|
|
4415
|
+
* parsing) when the file has no default WorkerDefinition or can't be imported.
|
|
4416
|
+
*/
|
|
4417
|
+
async function loadWorkerManifestModule(file) {
|
|
4418
|
+
const url = pathToFileURL(file);
|
|
4419
|
+
url.searchParams.set('hsxWorkerLoad', String(Date.now()));
|
|
4420
|
+
const mod = (await import(url.href));
|
|
4421
|
+
const def = mod.default;
|
|
4422
|
+
if (def &&
|
|
4423
|
+
typeof def === 'object' &&
|
|
4424
|
+
typeof def.manifest === 'function') {
|
|
4425
|
+
const manifest = def.manifest();
|
|
4426
|
+
if (manifest && Array.isArray(manifest.capabilities)) {
|
|
4427
|
+
return manifest;
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
return undefined;
|
|
4431
|
+
}
|
|
4432
|
+
/**
|
|
4433
|
+
* `--no-manage-schema` is a deploy-time flag, not part of the worker's own
|
|
4434
|
+
* declaration, so apply it to sync capabilities of a loaded manifest (the regex
|
|
4435
|
+
* path applies it inline).
|
|
4436
|
+
*/
|
|
4437
|
+
function applyNoManageSchemaOverride(manifest, options) {
|
|
4438
|
+
if (!options.noManageSchema) {
|
|
4439
|
+
return manifest;
|
|
4440
|
+
}
|
|
4441
|
+
return {
|
|
4442
|
+
...manifest,
|
|
4443
|
+
capabilities: manifest.capabilities.map((capability) => capability.kind === 'sync' ? { ...capability, manageSchema: false } : capability),
|
|
4444
|
+
};
|
|
4445
|
+
}
|
|
3224
4446
|
function stringProperty(source, propertyName) {
|
|
3225
4447
|
const match = new RegExp(`${propertyName}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(source)?.[1];
|
|
3226
4448
|
return match ? { [propertyName]: match } : {};
|
|
@@ -3270,7 +4492,10 @@ function schemaProperty(source) {
|
|
|
3270
4492
|
const inner = body.slice(valueStart + 1, end);
|
|
3271
4493
|
const type = /type\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
|
|
3272
4494
|
const property = /property\s*:\s*["'`]([^"'`]+)["'`]/.exec(inner)?.[1];
|
|
3273
|
-
schema[name] = {
|
|
4495
|
+
schema[name] = {
|
|
4496
|
+
...(type ? { type } : {}),
|
|
4497
|
+
...(property ? { property } : {}),
|
|
4498
|
+
};
|
|
3274
4499
|
index = end + 1;
|
|
3275
4500
|
}
|
|
3276
4501
|
else {
|
|
@@ -3289,9 +4514,9 @@ function objectFieldsProperty(source, propertyName) {
|
|
|
3289
4514
|
return {};
|
|
3290
4515
|
}
|
|
3291
4516
|
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[
|
|
4517
|
+
for (const match of body.matchAll(/(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])\s*:\s*\{([\s\S]*?)\}\s*,?/g)) {
|
|
4518
|
+
const fieldName = match[1] ?? match[2];
|
|
4519
|
+
const definition = match[3] ?? '';
|
|
3295
4520
|
if (!fieldName) {
|
|
3296
4521
|
continue;
|
|
3297
4522
|
}
|
|
@@ -3299,6 +4524,8 @@ function objectFieldsProperty(source, propertyName) {
|
|
|
3299
4524
|
...stringProperty(definition, 'type'),
|
|
3300
4525
|
...stringProperty(definition, 'label'),
|
|
3301
4526
|
...literalProperty(definition, 'default'),
|
|
4527
|
+
...literalProperty(definition, 'required'),
|
|
4528
|
+
...literalProperty(definition, 'isRequired'),
|
|
3302
4529
|
};
|
|
3303
4530
|
}
|
|
3304
4531
|
return Object.keys(fields).length > 0 ? { [propertyName]: fields } : {};
|
|
@@ -3488,6 +4715,10 @@ const deployOptions = {
|
|
|
3488
4715
|
applySchema: Options.boolean('apply-schema').pipe(Options.withDefault(false)), // --apply-schema
|
|
3489
4716
|
heartbeat: Options.boolean('heartbeat').pipe(Options.withDefault(false)), // --heartbeat
|
|
3490
4717
|
noHeartbeat: Options.boolean('no-heartbeat').pipe(Options.withDefault(false)), // --no-heartbeat
|
|
4718
|
+
// Declared explicitly: without it @effect/cli treats `--no-record` as the
|
|
4719
|
+
// negation of an undeclared `record` boolean and fails routing with a
|
|
4720
|
+
// confusing root-level "Invalid subcommand" error (the flag is documented).
|
|
4721
|
+
noRecord: Options.boolean('no-record').pipe(Options.withDefault(false)), // --no-record
|
|
3491
4722
|
portalSchemaLive: Options.boolean('portal-schema-live').pipe(Options.withDefault(false)), // --portal-schema-live
|
|
3492
4723
|
localHubspotSchema: Options.boolean('local-hubspot-schema').pipe(Options.withDefault(false)), // --local-hubspot-schema
|
|
3493
4724
|
force: Options.boolean('force').pipe(Options.withDefault(false)), // --force
|
|
@@ -3543,13 +4774,14 @@ const deployBaseCmd = Command.make('deploy', deployOptions, (opts) => runHandler
|
|
|
3543
4774
|
flag('--apply-schema', opts.applySchema);
|
|
3544
4775
|
flag('--heartbeat', opts.heartbeat);
|
|
3545
4776
|
flag('--no-heartbeat', opts.noHeartbeat);
|
|
4777
|
+
flag('--no-record', opts.noRecord);
|
|
3546
4778
|
flag('--portal-schema-live', opts.portalSchemaLive);
|
|
3547
4779
|
flag('--local-hubspot-schema', opts.localHubspotSchema);
|
|
3548
4780
|
flag('--force', opts.force);
|
|
3549
4781
|
flag('--yes', opts.yes);
|
|
3550
4782
|
if (opts.json)
|
|
3551
4783
|
argv.push('--json');
|
|
3552
|
-
yield* runQuarantined(opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
4784
|
+
yield* runQuarantined('deploy', opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
3553
4785
|
})));
|
|
3554
4786
|
// `hs-x deploy promote` — preserved as a subcommand that delegates to the legacy
|
|
3555
4787
|
// `deployCommand` promote branch (argv[1] === 'promote'), byte-for-byte. The
|
|
@@ -3583,7 +4815,7 @@ const deployPromoteSubCmd = Command.make('promote', {
|
|
|
3583
4815
|
argv.push('--yes');
|
|
3584
4816
|
if (opts.json)
|
|
3585
4817
|
argv.push('--json');
|
|
3586
|
-
yield* runQuarantined(opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
4818
|
+
yield* runQuarantined('deploy', opts.json, () => deployCommand({ argv, root, json: opts.json }));
|
|
3587
4819
|
})));
|
|
3588
4820
|
export const deployCmd = deployBaseCmd.pipe(Command.withSubcommands([deployPromoteSubCmd]));
|
|
3589
4821
|
/**
|
|
@@ -3594,7 +4826,11 @@ export const deployCmd = deployBaseCmd.pipe(Command.withSubcommands([deployPromo
|
|
|
3594
4826
|
async function collectDeployProvenance(userId) {
|
|
3595
4827
|
const git = async (args) => {
|
|
3596
4828
|
try {
|
|
3597
|
-
const proc = Bun.spawn({
|
|
4829
|
+
const proc = Bun.spawn({
|
|
4830
|
+
cmd: ['git', ...args],
|
|
4831
|
+
stdout: 'pipe',
|
|
4832
|
+
stderr: 'ignore',
|
|
4833
|
+
});
|
|
3598
4834
|
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
3599
4835
|
const trimmed = out.trim();
|
|
3600
4836
|
return code === 0 && trimmed ? trimmed : undefined;
|