@hs-x/cli 0.2.6 → 0.3.0

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