@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.
Files changed (285) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +25 -8
  3. package/dist/audit-interactive.d.ts +29 -0
  4. package/dist/audit-interactive.d.ts.map +1 -0
  5. package/dist/audit-interactive.js +156 -0
  6. package/dist/audit-interactive.js.map +1 -0
  7. package/dist/bin/hs-x.js.map +1 -1
  8. package/dist/checkpoint-interactive.d.ts +24 -0
  9. package/dist/checkpoint-interactive.d.ts.map +1 -0
  10. package/dist/checkpoint-interactive.js +65 -0
  11. package/dist/checkpoint-interactive.js.map +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +266 -39
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/kit.d.ts +1 -1
  16. package/dist/cli/kit.d.ts.map +1 -1
  17. package/dist/cli/kit.js +19 -3
  18. package/dist/cli/kit.js.map +1 -1
  19. package/dist/cloudflare-auth.d.ts +8 -0
  20. package/dist/cloudflare-auth.d.ts.map +1 -1
  21. package/dist/cloudflare-auth.js +12 -2
  22. package/dist/cloudflare-auth.js.map +1 -1
  23. package/dist/cloudflare-oauth-store.d.ts +9 -1
  24. package/dist/cloudflare-oauth-store.d.ts.map +1 -1
  25. package/dist/cloudflare-oauth-store.js +23 -4
  26. package/dist/cloudflare-oauth-store.js.map +1 -1
  27. package/dist/cloudflare-oauth.d.ts +1 -0
  28. package/dist/cloudflare-oauth.d.ts.map +1 -1
  29. package/dist/cloudflare-oauth.js +6 -2
  30. package/dist/cloudflare-oauth.js.map +1 -1
  31. package/dist/commands/account.d.ts +28 -13
  32. package/dist/commands/account.d.ts.map +1 -1
  33. package/dist/commands/account.js +219 -69
  34. package/dist/commands/account.js.map +1 -1
  35. package/dist/commands/api.d.ts.map +1 -1
  36. package/dist/commands/api.js +7 -11
  37. package/dist/commands/api.js.map +1 -1
  38. package/dist/commands/completion.d.ts.map +1 -1
  39. package/dist/commands/completion.js +13 -7
  40. package/dist/commands/completion.js.map +1 -1
  41. package/dist/commands/connect.d.ts.map +1 -1
  42. package/dist/commands/connect.js +239 -125
  43. package/dist/commands/connect.js.map +1 -1
  44. package/dist/commands/control-plane-read.d.ts +71 -10
  45. package/dist/commands/control-plane-read.d.ts.map +1 -1
  46. package/dist/commands/control-plane-read.js +1035 -58
  47. package/dist/commands/control-plane-read.js.map +1 -1
  48. package/dist/commands/deploy-promote.d.ts +25 -3
  49. package/dist/commands/deploy-promote.d.ts.map +1 -1
  50. package/dist/commands/deploy-promote.js +314 -38
  51. package/dist/commands/deploy-promote.js.map +1 -1
  52. package/dist/commands/deploy.d.ts +69 -4
  53. package/dist/commands/deploy.d.ts.map +1 -1
  54. package/dist/commands/deploy.js +1512 -276
  55. package/dist/commands/deploy.js.map +1 -1
  56. package/dist/commands/dev.d.ts +5 -1
  57. package/dist/commands/dev.d.ts.map +1 -1
  58. package/dist/commands/dev.js +502 -107
  59. package/dist/commands/dev.js.map +1 -1
  60. package/dist/commands/doctor.d.ts +14 -0
  61. package/dist/commands/doctor.d.ts.map +1 -1
  62. package/dist/commands/doctor.js +131 -20
  63. package/dist/commands/doctor.js.map +1 -1
  64. package/dist/commands/flags.d.ts +108 -6
  65. package/dist/commands/flags.d.ts.map +1 -1
  66. package/dist/commands/flags.js +996 -75
  67. package/dist/commands/flags.js.map +1 -1
  68. package/dist/commands/help-command.d.ts +14 -0
  69. package/dist/commands/help-command.d.ts.map +1 -1
  70. package/dist/commands/help-command.js +144 -44
  71. package/dist/commands/help-command.js.map +1 -1
  72. package/dist/commands/history.js +14 -10
  73. package/dist/commands/history.js.map +1 -1
  74. package/dist/commands/init.d.ts +4 -2
  75. package/dist/commands/init.d.ts.map +1 -1
  76. package/dist/commands/init.js +98 -17
  77. package/dist/commands/init.js.map +1 -1
  78. package/dist/commands/link.d.ts +2 -3
  79. package/dist/commands/link.d.ts.map +1 -1
  80. package/dist/commands/link.js +201 -101
  81. package/dist/commands/link.js.map +1 -1
  82. package/dist/commands/login.d.ts.map +1 -1
  83. package/dist/commands/login.js +22 -5
  84. package/dist/commands/login.js.map +1 -1
  85. package/dist/commands/migrate.d.ts +37 -17
  86. package/dist/commands/migrate.d.ts.map +1 -1
  87. package/dist/commands/migrate.js +500 -73
  88. package/dist/commands/migrate.js.map +1 -1
  89. package/dist/commands/rollback.d.ts +42 -2
  90. package/dist/commands/rollback.d.ts.map +1 -1
  91. package/dist/commands/rollback.js +422 -58
  92. package/dist/commands/rollback.js.map +1 -1
  93. package/dist/commands/secrets.d.ts +76 -3
  94. package/dist/commands/secrets.d.ts.map +1 -1
  95. package/dist/commands/secrets.js +328 -41
  96. package/dist/commands/secrets.js.map +1 -1
  97. package/dist/commands/status.d.ts +13 -2
  98. package/dist/commands/status.d.ts.map +1 -1
  99. package/dist/commands/status.js +154 -17
  100. package/dist/commands/status.js.map +1 -1
  101. package/dist/commands/unlink.d.ts +2 -2
  102. package/dist/commands/unlink.d.ts.map +1 -1
  103. package/dist/commands/unlink.js +2 -2
  104. package/dist/commands/unlink.js.map +1 -1
  105. package/dist/commands/update.d.ts.map +1 -1
  106. package/dist/commands/update.js +4 -3
  107. package/dist/commands/update.js.map +1 -1
  108. package/dist/commands/validate.d.ts +12 -3
  109. package/dist/commands/validate.d.ts.map +1 -1
  110. package/dist/commands/validate.js +70 -8
  111. package/dist/commands/validate.js.map +1 -1
  112. package/dist/constants.d.ts +1 -1
  113. package/dist/constants.js +1 -1
  114. package/dist/control-plane-fetch.d.ts.map +1 -1
  115. package/dist/control-plane-fetch.js +5 -4
  116. package/dist/control-plane-fetch.js.map +1 -1
  117. package/dist/deploy-revision-capture.d.ts +48 -0
  118. package/dist/deploy-revision-capture.d.ts.map +1 -0
  119. package/dist/deploy-revision-capture.js +125 -0
  120. package/dist/deploy-revision-capture.js.map +1 -0
  121. package/dist/dev/event-bus.d.ts +5 -2
  122. package/dist/dev/event-bus.d.ts.map +1 -1
  123. package/dist/dev/event-bus.js +4 -1
  124. package/dist/dev/event-bus.js.map +1 -1
  125. package/dist/dev/hubspot-logs-poller.d.ts +35 -0
  126. package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
  127. package/dist/dev/hubspot-logs-poller.js +94 -0
  128. package/dist/dev/hubspot-logs-poller.js.map +1 -0
  129. package/dist/dev/invoke.d.ts +9 -1
  130. package/dist/dev/invoke.d.ts.map +1 -1
  131. package/dist/dev/invoke.js +69 -20
  132. package/dist/dev/invoke.js.map +1 -1
  133. package/dist/dev/keyboard.d.ts +31 -0
  134. package/dist/dev/keyboard.d.ts.map +1 -0
  135. package/dist/dev/keyboard.js +143 -0
  136. package/dist/dev/keyboard.js.map +1 -0
  137. package/dist/dev/log-server.d.ts +7 -0
  138. package/dist/dev/log-server.d.ts.map +1 -1
  139. package/dist/dev/log-server.js +68 -31
  140. package/dist/dev/log-server.js.map +1 -1
  141. package/dist/dev/migrated-card.d.ts +17 -0
  142. package/dist/dev/migrated-card.d.ts.map +1 -0
  143. package/dist/dev/migrated-card.js +114 -0
  144. package/dist/dev/migrated-card.js.map +1 -0
  145. package/dist/dev/stream-filter.d.ts +40 -0
  146. package/dist/dev/stream-filter.d.ts.map +1 -0
  147. package/dist/dev/stream-filter.js +93 -0
  148. package/dist/dev/stream-filter.js.map +1 -0
  149. package/dist/dev/stream-renderer.d.ts +34 -0
  150. package/dist/dev/stream-renderer.d.ts.map +1 -1
  151. package/dist/dev/stream-renderer.js +222 -8
  152. package/dist/dev/stream-renderer.js.map +1 -1
  153. package/dist/errors-registry.d.ts.map +1 -1
  154. package/dist/errors-registry.js +280 -36
  155. package/dist/errors-registry.js.map +1 -1
  156. package/dist/errors.d.ts.map +1 -1
  157. package/dist/errors.js.map +1 -1
  158. package/dist/help.d.ts.map +1 -1
  159. package/dist/help.js +8 -0
  160. package/dist/help.js.map +1 -1
  161. package/dist/hubspot-app-logs.d.ts +76 -0
  162. package/dist/hubspot-app-logs.d.ts.map +1 -0
  163. package/dist/hubspot-app-logs.js +323 -0
  164. package/dist/hubspot-app-logs.js.map +1 -0
  165. package/dist/hubspot-auth.d.ts +8 -2
  166. package/dist/hubspot-auth.d.ts.map +1 -1
  167. package/dist/hubspot-auth.js +9 -9
  168. package/dist/hubspot-auth.js.map +1 -1
  169. package/dist/hubspot-developer-client.js +119 -11
  170. package/dist/hubspot-developer-client.js.map +1 -1
  171. package/dist/index.d.ts +1 -0
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +1 -0
  174. package/dist/index.js.map +1 -1
  175. package/dist/init/templates.d.ts +11 -1
  176. package/dist/init/templates.d.ts.map +1 -1
  177. package/dist/init/templates.js +228 -15
  178. package/dist/init/templates.js.map +1 -1
  179. package/dist/link-migration-journal.d.ts +131 -0
  180. package/dist/link-migration-journal.d.ts.map +1 -0
  181. package/dist/link-migration-journal.js +395 -0
  182. package/dist/link-migration-journal.js.map +1 -0
  183. package/dist/link-preflight.d.ts +78 -0
  184. package/dist/link-preflight.d.ts.map +1 -0
  185. package/dist/link-preflight.js +231 -0
  186. package/dist/link-preflight.js.map +1 -0
  187. package/dist/local-project-picker.d.ts +34 -0
  188. package/dist/local-project-picker.d.ts.map +1 -0
  189. package/dist/local-project-picker.js +121 -0
  190. package/dist/local-project-picker.js.map +1 -0
  191. package/dist/logs-interactive.d.ts +37 -0
  192. package/dist/logs-interactive.d.ts.map +1 -0
  193. package/dist/logs-interactive.js +110 -0
  194. package/dist/logs-interactive.js.map +1 -0
  195. package/dist/output-context.d.ts +13 -0
  196. package/dist/output-context.d.ts.map +1 -0
  197. package/dist/output-context.js +46 -0
  198. package/dist/output-context.js.map +1 -0
  199. package/dist/project-context.d.ts +55 -0
  200. package/dist/project-context.d.ts.map +1 -0
  201. package/dist/project-context.js +115 -0
  202. package/dist/project-context.js.map +1 -0
  203. package/dist/project-picker.d.ts +65 -0
  204. package/dist/project-picker.d.ts.map +1 -0
  205. package/dist/project-picker.js +227 -0
  206. package/dist/project-picker.js.map +1 -0
  207. package/dist/prompt.d.ts +8 -0
  208. package/dist/prompt.d.ts.map +1 -1
  209. package/dist/prompt.js +81 -13
  210. package/dist/prompt.js.map +1 -1
  211. package/dist/reporter/human.d.ts +10 -2
  212. package/dist/reporter/human.d.ts.map +1 -1
  213. package/dist/reporter/human.js +82 -14
  214. package/dist/reporter/human.js.map +1 -1
  215. package/dist/reporter/index.d.ts.map +1 -1
  216. package/dist/reporter/index.js +7 -5
  217. package/dist/reporter/index.js.map +1 -1
  218. package/dist/reporter/json.d.ts +2 -1
  219. package/dist/reporter/json.d.ts.map +1 -1
  220. package/dist/reporter/json.js +10 -7
  221. package/dist/reporter/json.js.map +1 -1
  222. package/dist/reporter/style.d.ts +19 -0
  223. package/dist/reporter/style.d.ts.map +1 -1
  224. package/dist/reporter/style.js +126 -6
  225. package/dist/reporter/style.js.map +1 -1
  226. package/dist/reporter/types.d.ts +34 -4
  227. package/dist/reporter/types.d.ts.map +1 -1
  228. package/dist/reporter/types.js.map +1 -1
  229. package/dist/services/account-store.d.ts +3 -1
  230. package/dist/services/account-store.d.ts.map +1 -1
  231. package/dist/services/account-store.js +2 -1
  232. package/dist/services/account-store.js.map +1 -1
  233. package/dist/services/app-paths.d.ts.map +1 -1
  234. package/dist/services/app-paths.js +3 -1
  235. package/dist/services/app-paths.js.map +1 -1
  236. package/dist/services/cloudflare-auth.d.ts +1 -1
  237. package/dist/services/cloudflare-auth.d.ts.map +1 -1
  238. package/dist/services/cloudflare-auth.js +2 -2
  239. package/dist/services/cloudflare-auth.js.map +1 -1
  240. package/dist/services/cloudflare-kv.d.ts.map +1 -1
  241. package/dist/services/cloudflare-kv.js.map +1 -1
  242. package/dist/services/command-history.d.ts.map +1 -1
  243. package/dist/services/command-history.js.map +1 -1
  244. package/dist/services/config.d.ts.map +1 -1
  245. package/dist/services/config.js +1 -1
  246. package/dist/services/config.js.map +1 -1
  247. package/dist/services/control-plane.d.ts.map +1 -1
  248. package/dist/services/control-plane.js +3 -3
  249. package/dist/services/control-plane.js.map +1 -1
  250. package/dist/services/env-loader.d.ts.map +1 -1
  251. package/dist/services/env-loader.js +1 -3
  252. package/dist/services/env-loader.js.map +1 -1
  253. package/dist/services/http.d.ts.map +1 -1
  254. package/dist/services/http.js.map +1 -1
  255. package/dist/services/hubspot-dev-client.d.ts.map +1 -1
  256. package/dist/services/hubspot-dev-client.js +30 -0
  257. package/dist/services/hubspot-dev-client.js.map +1 -1
  258. package/dist/services/live.d.ts +2 -2
  259. package/dist/services/live.d.ts.map +1 -1
  260. package/dist/services/live.js +1 -1
  261. package/dist/services/live.js.map +1 -1
  262. package/dist/services/machine-id.d.ts.map +1 -1
  263. package/dist/services/machine-id.js.map +1 -1
  264. package/dist/services/reporter.d.ts +8 -5
  265. package/dist/services/reporter.d.ts.map +1 -1
  266. package/dist/services/reporter.js +6 -4
  267. package/dist/services/reporter.js.map +1 -1
  268. package/dist/services/tenant-state.d.ts.map +1 -1
  269. package/dist/services/tenant-state.js.map +1 -1
  270. package/dist/state-store.d.ts +4 -0
  271. package/dist/state-store.d.ts.map +1 -1
  272. package/dist/state-store.js +15 -0
  273. package/dist/state-store.js.map +1 -1
  274. package/dist/status-drilldown.d.ts +24 -0
  275. package/dist/status-drilldown.d.ts.map +1 -0
  276. package/dist/status-drilldown.js +50 -0
  277. package/dist/status-drilldown.js.map +1 -0
  278. package/dist/tenant-state.d.ts.map +1 -1
  279. package/dist/tenant-state.js +3 -2
  280. package/dist/tenant-state.js.map +1 -1
  281. package/dist/token-key-custody.d.ts +91 -0
  282. package/dist/token-key-custody.d.ts.map +1 -0
  283. package/dist/token-key-custody.js +375 -0
  284. package/dist/token-key-custody.js.map +1 -0
  285. package/package.json +14 -15
@@ -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 { isInteractive, promptConfirm, promptMultiSelect, promptSelect, promptText, } from '../prompt.js';
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
- // Non-interactive, --json, or --yes: accept the configured default rather than
64
- // silently degrading to "no account picked" that path led deploy to exit 0
65
- // after building artifacts only, looking like success.
66
- const yes = input.argv.includes('--yes') || input.argv.includes('-y');
67
- if (input.json || !isInteractive() || yes) {
68
- if (defaultId && ids.includes(defaultId)) {
69
- return { accountId: defaultId, fromPrompt: false };
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({ command: input.command, argv: input.argv });
105
- reporter.error(code, message, { hint, docs_url: `https://hs-x.dev/errors/${code}` });
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
- * Unlinked OAuth installs need the HubSpot app's client id + client secret so
121
- * the deployed Worker's `/oauth-start` can complete the token exchange. Linked
122
- * deploys read these from the control plane; unlinked supplies them via
123
- * flag/env. When interactive, PROMPT for anything missing (only on the first
124
- * worker, to avoid contending for stdin across the concurrent worker deploys)
125
- * and persist answers to the environment so sibling workers reuse them — rather
126
- * than the human having to know to pre-set the env vars, then silently shipping
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
- * We deliberately do NOT hard-fail on missing creds: the client secret is
130
- * grabbed from the app's Auth page, which only exists once the first deploy has
131
- * uploaded the app, so a bootstrap deploy legitimately has no secret yet. The
132
- * post-deploy output points the user at that Auth URL to finish and re-deploy.
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
- async function resolveUnlinkedOAuthCredentials(input) {
135
- // Only an interactive, unlinked OAuth, non-dry-run deploy prompts: linked
136
- // reads the secret from the control plane, non-OAuth apps need none, a dry run
137
- // never deploys, and non-interactive runs must supply creds via flag/env.
138
- if (input.auth !== 'oauth' || input.linked || input.dryRun || !input.allowPrompt) {
139
- return { clientId: input.hubSpotClientId, clientSecret: input.hubSpotClientSecret };
140
- }
141
- const clientId = await promptMissingText('HubSpot app client id (HSX_HUBSPOT_CLIENT_ID)', input.hubSpotClientId);
142
- const clientSecret = await promptMissingText('HubSpot app client secret — copy it from the app’s Auth tab (it is not exposed via API)', input.hubSpotClientSecret);
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({ command: input.command, argv: input.argv });
163
- reporter.error(code, message, { hint, docs_url: `https://hs-x.dev/errors/${code}` });
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({ command: input.command, argv: input.argv });
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 API token is needed to deploy this Worker.');
244
- reporter.info(`Create one (HS-X template): ${CLOUDFLARE_TOKEN_TEMPLATE_URL}`);
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({ message: 'Try again?', default: true });
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
- if (!json) {
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 portalSchemaRead = await readPortalSchema(argv, workers);
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({ workers, observed: portalSchemaRead.observed })
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
- const reporter = createReporter({ command: 'deploy', argv });
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 manifest = await generateProjectArtifacts({
485
- root,
486
- workers,
487
- appObjects: app.appObjects,
488
- appObjectAssociations: app.appObjectAssociations,
489
- appEvents: app.appEvents,
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
- const controlPlanePlan = localControlPlane && controlPlaneRequest
530
- ? await requestLocalControlPlaneDeployPlan(controlPlaneRequest)
531
- : controlPlaneUrl && controlPlaneRequest
532
- ? await requestHostedControlPlaneDeployPlan({
533
- request: controlPlaneRequest,
534
- controlPlaneUrl,
535
- userId,
536
- })
537
- : !buildOnly && !hubspotOnly && projectId
538
- ? createUnlinkedDeployPlan({
539
- projectId,
540
- machineId,
541
- manifest,
542
- workers,
543
- })
544
- : undefined;
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
- const cloudflareDeployResult = cloudflareDeployRequested && !planOnly && validation.ok && controlPlanePlan
571
- ? await executeCloudflareDeploy({
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
- userId,
577
- machineId,
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
- const localRecordResult = shouldRecordControlPlane && controlPlaneRequest && controlPlanePlan
588
- ? localControlPlane
589
- ? await requestLocalControlPlaneDeployRecordAndMaybePromote({
590
- request: controlPlaneRequest,
591
- plan: controlPlanePlan,
592
- promoteWhenHealthy,
593
- })
594
- : controlPlaneUrl
595
- ? await requestHostedControlPlaneDeployRecordAndMaybePromote({
596
- request: controlPlaneRequest,
597
- plan: controlPlanePlan,
598
- controlPlaneUrl,
599
- userId,
600
- promoteWhenHealthy,
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 (hubspotUploadRequested && validation.ok) {
613
- await ensureHubSpotRuntimeProjectArtifacts({
614
- argv,
615
- root,
616
- app,
617
- workers,
618
- cloudflareDeployResult,
619
- needsRuntime,
620
- ...(controlPlanePlan ? { controlPlanePlan } : {}),
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
- argv,
703
- startedAt: commandStartedAt,
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: { 'content-type': 'application/json', accept: 'application/json' },
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
- function renderDeployHuman(input) {
876
- const reporter = createReporter({
877
- command: 'deploy',
878
- argv: input.argv,
879
- startedAt: input.startedAt,
880
- });
881
- const subject = basename(input.root);
882
- reporter.header(subject);
883
- const capCount = input.workers.reduce((c, w) => c + w.capabilities.length, 0);
884
- reporter
885
- .step('Validating project')
886
- .ok(`${input.workers.length} workers, ${capCount} capabilities`);
887
- const sources = input.workers.flatMap((worker) => worker.capabilities.flatMap((capability) => capability.kind === 'sync' && capability.source
888
- ? [
889
- `${capability.source.name} (${capability.source.kind}${capability.source.kind === 'push'
890
- ? `, ${capability.source.webhookPath}`
891
- : `, ${capability.schedule ?? 'manual'}`})`,
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.flatMap((worker) => worker.capabilities.flatMap((capability) => capability.kind === 'sync' ? [`${capability.id}: ${capability.manageSchema ?? false}`] : []));
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. Check the deploy log in the HubSpot UI (project ${upload.projectName}).`);
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
- const doneMessage = input.planOnly
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
- if (!result.developerAccountId || !result.appUid)
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(result.developerAccountId)}/project/${encodeURIComponent(result.projectName)}/component/${encodeURIComponent(result.appUid)}/auth`;
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 { observed: value, source: 'fixture' };
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({ objectType });
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({ action: label, objectType, applied: [...applied], cause });
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 = createReporter({ command: 'deploy', argv: input.argv, startedAt: input.startedAt });
1360
- reporter.header(basename(input.root));
1361
- const capCount = input.workers.reduce((count, worker) => count + worker.capabilities.length, 0);
1362
- reporter
1363
- .step('Validating project')
1364
- .ok(`${input.workers.length} workers, ${capCount} capabilities`);
1365
- if (input.portalSchemaPlan) {
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
- reporter.step('Applying portal schema').fail(applyError?.action);
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 = hubSpotOAuthSecret?.clientId ??
1569
- resolveFlag(argv, '--hubspot-client-id') ??
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 = hubSpotOAuthSecret?.clientSecret ??
1575
- resolveFlag(argv, '--hubspot-client-secret') ??
2327
+ const hubSpotClientSecret = resolveFlag(argv, '--hubspot-client-secret') ??
2328
+ hubSpotOAuthSecret?.clientSecret ??
1576
2329
  process.env.HSX_HUBSPOT_CLIENT_SECRET;
1577
- // Interactively fill the unlinked OAuth client id/secret the deployed Worker
1578
- // needs for /oauth-start, instead of silently shipping a Worker that 500s.
1579
- const { clientId: resolvedHubSpotClientId, clientSecret: resolvedHubSpotClientSecret } = await resolveUnlinkedOAuthCredentials({
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
- linked: Boolean(controlPlaneUrl),
2335
+ requiresCardSignatureSecret: hasCardBackends,
1582
2336
  dryRun,
1583
- allowPrompt: index === 0,
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
- process.stderr.write('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');
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
- process.stderr.write(`warning: could not register the tenant Worker URL with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
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: randomBytes(32).toString('base64url'),
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: randomBytes(32).toString('base64url'),
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
- const output = await runCloudflareCommand(['bun', 'x', 'wrangler', 'd1', 'create', tenantD1DatabaseName], { cwd: input.root, env });
2066
- tenantD1DatabaseId = extractD1DatabaseId(`${output.stdout}\n${output.stderr}`);
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
- const output = await runCloudflareCommand(['bun', 'x', 'wrangler', 'kv', 'namespace', 'create', flagsKvNamespaceName, '--binding', 'FLAGS_KV'], { cwd: input.root, env });
2075
- flagsKvNamespaceId = extractKvNamespaceId(`${output.stdout}\n${output.stderr}`);
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
- process.stderr.write(`warning: could not register the sync grant secret with the control plane: ${error instanceof Error ? error.message : String(error)}\n`);
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
- export async function ensureHubSpotRuntimeProjectArtifacts({ argv, root, app, workers, cloudflareDeployResult, needsRuntime, controlPlanePlan, }) {
2216
- if (!needsRuntime) {
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 = resolveFlag(argv, '--runtime-origin') ??
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
- for (const [file, contents] of Object.entries(generated.files)) {
2235
- const path = join(root, file);
2236
- // src/app/cards/package.json is user-owned once it exists: HubSpot's own
2237
- // build errors instruct users to add card dependencies (e.g. hs-uix) to
2238
- // it, and regenerating the fixed-dep version here silently dropped those
2239
- // deps so the card never built (cold-stranger run #2 blocker B). Keep the
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
- const loaded = parseHsxAppConfigLiteral(raw) ?? (await loadHsxAppConfigModule(root).catch(() => undefined));
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
- // Cards are only recoverable from the loaded module (the literal parser
2276
- // can't evaluate `card(...)` calls); an unloadable config yields none.
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
- ? { requiredProperties: readStringArrayProperty(body, 'requiredProperties') }
3455
+ ? {
3456
+ requiredProperties: readStringArrayProperty(body, 'requiredProperties'),
3457
+ }
2379
3458
  : {}),
2380
3459
  ...(readStringArrayProperty(body, 'searchableProperties').length
2381
- ? { searchableProperties: readStringArrayProperty(body, 'searchableProperties') }
3460
+ ? {
3461
+ searchableProperties: readStringArrayProperty(body, 'searchableProperties'),
3462
+ }
2382
3463
  : {}),
2383
3464
  ...(readStringArrayProperty(body, 'defaultCreateFormFields').length
2384
- ? { defaultCreateFormFields: readStringArrayProperty(body, 'defaultCreateFormFields') }
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
- : { supportsCustomObject: booleanPropertyValue(body, 'supportsCustomObject') }),
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({ id: match[1] ?? 'unknown', body: source.slice(start + 1, end) });
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({ name, body: source.slice(braceStart + 1, braceEnd) });
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 = resolveFlag(argv, '--hubspot-project-name') ?? configuredProjectName ?? basename(root);
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, { appId, projectName, developerAccountId });
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, { appId, projectName, developerAccountId });
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 readPersistedHubSpotAppId(root) {
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
- return readOptionalHubSpotAppId(typeof parsed.appId === 'number' ? String(parsed.appId) : undefined);
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
- function deployBundleKey(input) {
2961
- return Schema.decodeSync(schemas.BundleKey)(`r2://bundles/${input.projectId}/${input.deployId}.tar.gz`);
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
- bundleKey: deployBundleKey({ projectId: request.projectId, deployId: plan.deployId }),
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({ accountId: request.accountId, projectId: request.projectId }),
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
- bundleKey: deployBundleKey({ projectId: request.projectId, deployId: plan.deployId }),
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] = { ...(type ? { type } : {}), ...(property ? { property } : {}) };
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[2] ?? '';
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({ cmd: ['git', ...args], stdout: 'pipe', stderr: 'ignore' });
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;