@hs-x/cli 0.2.7 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +25 -8
  3. package/dist/audit-interactive.d.ts +29 -0
  4. package/dist/audit-interactive.d.ts.map +1 -0
  5. package/dist/audit-interactive.js +156 -0
  6. package/dist/audit-interactive.js.map +1 -0
  7. package/dist/bin/hs-x.js.map +1 -1
  8. package/dist/checkpoint-interactive.d.ts +24 -0
  9. package/dist/checkpoint-interactive.d.ts.map +1 -0
  10. package/dist/checkpoint-interactive.js +65 -0
  11. package/dist/checkpoint-interactive.js.map +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +266 -39
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/kit.d.ts +1 -1
  16. package/dist/cli/kit.d.ts.map +1 -1
  17. package/dist/cli/kit.js +19 -3
  18. package/dist/cli/kit.js.map +1 -1
  19. package/dist/cloudflare-auth.d.ts +8 -0
  20. package/dist/cloudflare-auth.d.ts.map +1 -1
  21. package/dist/cloudflare-auth.js +12 -2
  22. package/dist/cloudflare-auth.js.map +1 -1
  23. package/dist/cloudflare-oauth-store.d.ts +9 -1
  24. package/dist/cloudflare-oauth-store.d.ts.map +1 -1
  25. package/dist/cloudflare-oauth-store.js +23 -4
  26. package/dist/cloudflare-oauth-store.js.map +1 -1
  27. package/dist/cloudflare-oauth.d.ts +1 -0
  28. package/dist/cloudflare-oauth.d.ts.map +1 -1
  29. package/dist/cloudflare-oauth.js +6 -2
  30. package/dist/cloudflare-oauth.js.map +1 -1
  31. package/dist/commands/account.d.ts +28 -13
  32. package/dist/commands/account.d.ts.map +1 -1
  33. package/dist/commands/account.js +219 -69
  34. package/dist/commands/account.js.map +1 -1
  35. package/dist/commands/api.d.ts.map +1 -1
  36. package/dist/commands/api.js +7 -11
  37. package/dist/commands/api.js.map +1 -1
  38. package/dist/commands/completion.d.ts.map +1 -1
  39. package/dist/commands/completion.js +13 -7
  40. package/dist/commands/completion.js.map +1 -1
  41. package/dist/commands/connect.d.ts.map +1 -1
  42. package/dist/commands/connect.js +239 -125
  43. package/dist/commands/connect.js.map +1 -1
  44. package/dist/commands/control-plane-read.d.ts +71 -10
  45. package/dist/commands/control-plane-read.d.ts.map +1 -1
  46. package/dist/commands/control-plane-read.js +1035 -58
  47. package/dist/commands/control-plane-read.js.map +1 -1
  48. package/dist/commands/deploy-promote.d.ts +25 -3
  49. package/dist/commands/deploy-promote.d.ts.map +1 -1
  50. package/dist/commands/deploy-promote.js +314 -38
  51. package/dist/commands/deploy-promote.js.map +1 -1
  52. package/dist/commands/deploy.d.ts +69 -4
  53. package/dist/commands/deploy.d.ts.map +1 -1
  54. package/dist/commands/deploy.js +1512 -276
  55. package/dist/commands/deploy.js.map +1 -1
  56. package/dist/commands/dev.d.ts +5 -1
  57. package/dist/commands/dev.d.ts.map +1 -1
  58. package/dist/commands/dev.js +502 -107
  59. package/dist/commands/dev.js.map +1 -1
  60. package/dist/commands/doctor.d.ts +14 -0
  61. package/dist/commands/doctor.d.ts.map +1 -1
  62. package/dist/commands/doctor.js +131 -20
  63. package/dist/commands/doctor.js.map +1 -1
  64. package/dist/commands/flags.d.ts +108 -6
  65. package/dist/commands/flags.d.ts.map +1 -1
  66. package/dist/commands/flags.js +996 -75
  67. package/dist/commands/flags.js.map +1 -1
  68. package/dist/commands/help-command.d.ts +14 -0
  69. package/dist/commands/help-command.d.ts.map +1 -1
  70. package/dist/commands/help-command.js +144 -44
  71. package/dist/commands/help-command.js.map +1 -1
  72. package/dist/commands/history.js +14 -10
  73. package/dist/commands/history.js.map +1 -1
  74. package/dist/commands/init.d.ts +4 -2
  75. package/dist/commands/init.d.ts.map +1 -1
  76. package/dist/commands/init.js +98 -17
  77. package/dist/commands/init.js.map +1 -1
  78. package/dist/commands/link.d.ts +2 -3
  79. package/dist/commands/link.d.ts.map +1 -1
  80. package/dist/commands/link.js +201 -101
  81. package/dist/commands/link.js.map +1 -1
  82. package/dist/commands/login.d.ts.map +1 -1
  83. package/dist/commands/login.js +22 -5
  84. package/dist/commands/login.js.map +1 -1
  85. package/dist/commands/migrate.d.ts +37 -17
  86. package/dist/commands/migrate.d.ts.map +1 -1
  87. package/dist/commands/migrate.js +500 -73
  88. package/dist/commands/migrate.js.map +1 -1
  89. package/dist/commands/rollback.d.ts +42 -2
  90. package/dist/commands/rollback.d.ts.map +1 -1
  91. package/dist/commands/rollback.js +422 -58
  92. package/dist/commands/rollback.js.map +1 -1
  93. package/dist/commands/secrets.d.ts +76 -3
  94. package/dist/commands/secrets.d.ts.map +1 -1
  95. package/dist/commands/secrets.js +328 -41
  96. package/dist/commands/secrets.js.map +1 -1
  97. package/dist/commands/status.d.ts +13 -2
  98. package/dist/commands/status.d.ts.map +1 -1
  99. package/dist/commands/status.js +154 -17
  100. package/dist/commands/status.js.map +1 -1
  101. package/dist/commands/unlink.d.ts +2 -2
  102. package/dist/commands/unlink.d.ts.map +1 -1
  103. package/dist/commands/unlink.js +2 -2
  104. package/dist/commands/unlink.js.map +1 -1
  105. package/dist/commands/update.d.ts.map +1 -1
  106. package/dist/commands/update.js +4 -3
  107. package/dist/commands/update.js.map +1 -1
  108. package/dist/commands/validate.d.ts +12 -3
  109. package/dist/commands/validate.d.ts.map +1 -1
  110. package/dist/commands/validate.js +70 -8
  111. package/dist/commands/validate.js.map +1 -1
  112. package/dist/constants.d.ts +1 -1
  113. package/dist/constants.js +1 -1
  114. package/dist/control-plane-fetch.d.ts.map +1 -1
  115. package/dist/control-plane-fetch.js +5 -4
  116. package/dist/control-plane-fetch.js.map +1 -1
  117. package/dist/deploy-revision-capture.d.ts +48 -0
  118. package/dist/deploy-revision-capture.d.ts.map +1 -0
  119. package/dist/deploy-revision-capture.js +125 -0
  120. package/dist/deploy-revision-capture.js.map +1 -0
  121. package/dist/dev/event-bus.d.ts +5 -2
  122. package/dist/dev/event-bus.d.ts.map +1 -1
  123. package/dist/dev/event-bus.js +4 -1
  124. package/dist/dev/event-bus.js.map +1 -1
  125. package/dist/dev/hubspot-logs-poller.d.ts +35 -0
  126. package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
  127. package/dist/dev/hubspot-logs-poller.js +94 -0
  128. package/dist/dev/hubspot-logs-poller.js.map +1 -0
  129. package/dist/dev/invoke.d.ts +9 -1
  130. package/dist/dev/invoke.d.ts.map +1 -1
  131. package/dist/dev/invoke.js +69 -20
  132. package/dist/dev/invoke.js.map +1 -1
  133. package/dist/dev/keyboard.d.ts +31 -0
  134. package/dist/dev/keyboard.d.ts.map +1 -0
  135. package/dist/dev/keyboard.js +143 -0
  136. package/dist/dev/keyboard.js.map +1 -0
  137. package/dist/dev/log-server.d.ts +7 -0
  138. package/dist/dev/log-server.d.ts.map +1 -1
  139. package/dist/dev/log-server.js +68 -31
  140. package/dist/dev/log-server.js.map +1 -1
  141. package/dist/dev/migrated-card.d.ts +17 -0
  142. package/dist/dev/migrated-card.d.ts.map +1 -0
  143. package/dist/dev/migrated-card.js +114 -0
  144. package/dist/dev/migrated-card.js.map +1 -0
  145. package/dist/dev/stream-filter.d.ts +40 -0
  146. package/dist/dev/stream-filter.d.ts.map +1 -0
  147. package/dist/dev/stream-filter.js +93 -0
  148. package/dist/dev/stream-filter.js.map +1 -0
  149. package/dist/dev/stream-renderer.d.ts +34 -0
  150. package/dist/dev/stream-renderer.d.ts.map +1 -1
  151. package/dist/dev/stream-renderer.js +222 -8
  152. package/dist/dev/stream-renderer.js.map +1 -1
  153. package/dist/errors-registry.d.ts.map +1 -1
  154. package/dist/errors-registry.js +280 -36
  155. package/dist/errors-registry.js.map +1 -1
  156. package/dist/errors.d.ts.map +1 -1
  157. package/dist/errors.js.map +1 -1
  158. package/dist/help.d.ts.map +1 -1
  159. package/dist/help.js +8 -0
  160. package/dist/help.js.map +1 -1
  161. package/dist/hubspot-app-logs.d.ts +76 -0
  162. package/dist/hubspot-app-logs.d.ts.map +1 -0
  163. package/dist/hubspot-app-logs.js +323 -0
  164. package/dist/hubspot-app-logs.js.map +1 -0
  165. package/dist/hubspot-auth.d.ts +8 -2
  166. package/dist/hubspot-auth.d.ts.map +1 -1
  167. package/dist/hubspot-auth.js +9 -9
  168. package/dist/hubspot-auth.js.map +1 -1
  169. package/dist/hubspot-developer-client.js +119 -11
  170. package/dist/hubspot-developer-client.js.map +1 -1
  171. package/dist/index.d.ts +1 -0
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +1 -0
  174. package/dist/index.js.map +1 -1
  175. package/dist/init/templates.d.ts +11 -1
  176. package/dist/init/templates.d.ts.map +1 -1
  177. package/dist/init/templates.js +228 -15
  178. package/dist/init/templates.js.map +1 -1
  179. package/dist/link-migration-journal.d.ts +131 -0
  180. package/dist/link-migration-journal.d.ts.map +1 -0
  181. package/dist/link-migration-journal.js +395 -0
  182. package/dist/link-migration-journal.js.map +1 -0
  183. package/dist/link-preflight.d.ts +78 -0
  184. package/dist/link-preflight.d.ts.map +1 -0
  185. package/dist/link-preflight.js +231 -0
  186. package/dist/link-preflight.js.map +1 -0
  187. package/dist/local-project-picker.d.ts +34 -0
  188. package/dist/local-project-picker.d.ts.map +1 -0
  189. package/dist/local-project-picker.js +121 -0
  190. package/dist/local-project-picker.js.map +1 -0
  191. package/dist/logs-interactive.d.ts +37 -0
  192. package/dist/logs-interactive.d.ts.map +1 -0
  193. package/dist/logs-interactive.js +110 -0
  194. package/dist/logs-interactive.js.map +1 -0
  195. package/dist/output-context.d.ts +13 -0
  196. package/dist/output-context.d.ts.map +1 -0
  197. package/dist/output-context.js +46 -0
  198. package/dist/output-context.js.map +1 -0
  199. package/dist/project-context.d.ts +55 -0
  200. package/dist/project-context.d.ts.map +1 -0
  201. package/dist/project-context.js +115 -0
  202. package/dist/project-context.js.map +1 -0
  203. package/dist/project-picker.d.ts +65 -0
  204. package/dist/project-picker.d.ts.map +1 -0
  205. package/dist/project-picker.js +227 -0
  206. package/dist/project-picker.js.map +1 -0
  207. package/dist/prompt.d.ts +8 -0
  208. package/dist/prompt.d.ts.map +1 -1
  209. package/dist/prompt.js +81 -13
  210. package/dist/prompt.js.map +1 -1
  211. package/dist/reporter/human.d.ts +10 -2
  212. package/dist/reporter/human.d.ts.map +1 -1
  213. package/dist/reporter/human.js +82 -14
  214. package/dist/reporter/human.js.map +1 -1
  215. package/dist/reporter/index.d.ts.map +1 -1
  216. package/dist/reporter/index.js +7 -5
  217. package/dist/reporter/index.js.map +1 -1
  218. package/dist/reporter/json.d.ts +2 -1
  219. package/dist/reporter/json.d.ts.map +1 -1
  220. package/dist/reporter/json.js +10 -7
  221. package/dist/reporter/json.js.map +1 -1
  222. package/dist/reporter/style.d.ts +19 -0
  223. package/dist/reporter/style.d.ts.map +1 -1
  224. package/dist/reporter/style.js +126 -6
  225. package/dist/reporter/style.js.map +1 -1
  226. package/dist/reporter/types.d.ts +34 -4
  227. package/dist/reporter/types.d.ts.map +1 -1
  228. package/dist/reporter/types.js.map +1 -1
  229. package/dist/services/account-store.d.ts +3 -1
  230. package/dist/services/account-store.d.ts.map +1 -1
  231. package/dist/services/account-store.js +2 -1
  232. package/dist/services/account-store.js.map +1 -1
  233. package/dist/services/app-paths.d.ts.map +1 -1
  234. package/dist/services/app-paths.js +3 -1
  235. package/dist/services/app-paths.js.map +1 -1
  236. package/dist/services/cloudflare-auth.d.ts +1 -1
  237. package/dist/services/cloudflare-auth.d.ts.map +1 -1
  238. package/dist/services/cloudflare-auth.js +2 -2
  239. package/dist/services/cloudflare-auth.js.map +1 -1
  240. package/dist/services/cloudflare-kv.d.ts.map +1 -1
  241. package/dist/services/cloudflare-kv.js.map +1 -1
  242. package/dist/services/command-history.d.ts.map +1 -1
  243. package/dist/services/command-history.js.map +1 -1
  244. package/dist/services/config.d.ts.map +1 -1
  245. package/dist/services/config.js +1 -1
  246. package/dist/services/config.js.map +1 -1
  247. package/dist/services/control-plane.d.ts.map +1 -1
  248. package/dist/services/control-plane.js +3 -3
  249. package/dist/services/control-plane.js.map +1 -1
  250. package/dist/services/env-loader.d.ts.map +1 -1
  251. package/dist/services/env-loader.js +1 -3
  252. package/dist/services/env-loader.js.map +1 -1
  253. package/dist/services/http.d.ts.map +1 -1
  254. package/dist/services/http.js.map +1 -1
  255. package/dist/services/hubspot-dev-client.d.ts.map +1 -1
  256. package/dist/services/hubspot-dev-client.js +30 -0
  257. package/dist/services/hubspot-dev-client.js.map +1 -1
  258. package/dist/services/live.d.ts +2 -2
  259. package/dist/services/live.d.ts.map +1 -1
  260. package/dist/services/live.js +1 -1
  261. package/dist/services/live.js.map +1 -1
  262. package/dist/services/machine-id.d.ts.map +1 -1
  263. package/dist/services/machine-id.js.map +1 -1
  264. package/dist/services/reporter.d.ts +8 -5
  265. package/dist/services/reporter.d.ts.map +1 -1
  266. package/dist/services/reporter.js +6 -4
  267. package/dist/services/reporter.js.map +1 -1
  268. package/dist/services/tenant-state.d.ts.map +1 -1
  269. package/dist/services/tenant-state.js.map +1 -1
  270. package/dist/state-store.d.ts +4 -0
  271. package/dist/state-store.d.ts.map +1 -1
  272. package/dist/state-store.js +15 -0
  273. package/dist/state-store.js.map +1 -1
  274. package/dist/status-drilldown.d.ts +24 -0
  275. package/dist/status-drilldown.d.ts.map +1 -0
  276. package/dist/status-drilldown.js +50 -0
  277. package/dist/status-drilldown.js.map +1 -0
  278. package/dist/tenant-state.d.ts.map +1 -1
  279. package/dist/tenant-state.js +3 -2
  280. package/dist/tenant-state.js.map +1 -1
  281. package/dist/token-key-custody.d.ts +91 -0
  282. package/dist/token-key-custody.d.ts.map +1 -0
  283. package/dist/token-key-custody.js +375 -0
  284. package/dist/token-key-custody.js.map +1 -0
  285. package/package.json +14 -15
@@ -3,11 +3,12 @@ import * as Command from '@effect/cli/Command';
3
3
  import * as Options from '@effect/cli/Options';
4
4
  import { FileSystem, Path } from '@effect/platform';
5
5
  import { generateLegacyCardProject, generateMigratedHsxProject, } from '@hs-x/codegen';
6
- import { classifyMigrationInput, detectMigrationSource, planCutover, validateProject } from '@hs-x/validator';
6
+ import { classifyMigrationInput, detectMigrationSource, planCutover, validateProject, } from '@hs-x/validator';
7
7
  import { Effect, Option } from 'effect';
8
8
  import { cliError, exitWith } from '../cli-error.js';
9
9
  import { accountIdOption, controlPlaneUrlOption, cwdOption, jsonOption, runHandler, userIdOption, } from '../cli/kit.js';
10
- import { isInteractive, promptSelect } from '../prompt.js';
10
+ import { discoverLocalProjects } from '../local-project-picker.js';
11
+ import { BACK, isInteractive, promptConfirm, promptSelect, promptText, } from '../prompt.js';
11
12
  import { Cwd } from '../services/cwd.js';
12
13
  import { HubSpotDevClient } from '../services/hubspot-dev-client.js';
13
14
  import { Reporter } from '../services/reporter.js';
@@ -48,21 +49,44 @@ function renderMigrationReport(data) {
48
49
  return `${lines.join('\n')}\n`;
49
50
  }
50
51
  function migrationNextSteps(result) {
52
+ const hasNonCardComponents = result.detected.some((kind) => kind !== 'classic-crm-card' && kind !== 'project-card');
53
+ const generateCommand = hasNonCardComponents
54
+ ? '`hs-x migrate run <path> --out <dir>`'
55
+ : '`hs-x migrate cards <path> --project <dir>`';
51
56
  if (result.manualWork.length > 0) {
52
57
  return [
53
- 'Resolve human-review findings before running `hs-x migrate cards`.',
58
+ `Resolve human-review findings before running ${generateCommand}.`,
54
59
  result.cloudflareRequired
55
60
  ? 'Plan Cloudflare runtime requirements before deploy.'
56
61
  : 'Confirm HubSpot-only replacement card targets before deploy.',
57
62
  ];
58
63
  }
59
64
  return [
60
- 'Run `hs-x migrate cards <path> --project <dir>` to generate the replacement App Card project.',
65
+ `Run ${generateCommand} to generate the replacement project.`,
61
66
  result.cloudflareRequired
62
67
  ? 'Run `hs-x connect cloudflare` before deploy.'
63
68
  : 'Use `hs-x deploy --hubspot-only` once HubSpot upload is wired.',
64
69
  ];
65
70
  }
71
+ function countMatches(contents, pattern) {
72
+ return [...contents.matchAll(pattern)].length;
73
+ }
74
+ function migrationOutstandingWork(files) {
75
+ let todoMarkers = 0;
76
+ let frontendServerlessCalls = 0;
77
+ const hasCompatibilityShim = files['src/app/cards/_hsx-migration-run-serverless.ts'] !== undefined;
78
+ for (const [file, contents] of Object.entries(files)) {
79
+ todoMarkers += countMatches(contents, /TODO\(migration\)/g);
80
+ if (!hasCompatibilityShim && file.startsWith('src/app/cards/') && /\.(?:t|j)sx?$/.test(file)) {
81
+ frontendServerlessCalls += countMatches(contents, /\brunServerlessFunction\s*\(/g);
82
+ }
83
+ }
84
+ return {
85
+ todoMarkers,
86
+ frontendServerlessCalls,
87
+ total: todoMarkers + frontendServerlessCalls,
88
+ };
89
+ }
66
90
  function isRecord(value) {
67
91
  return typeof value === 'object' && value !== null;
68
92
  }
@@ -73,6 +97,15 @@ Migrate a legacy HubSpot app (2023.2 / 2025.1 / 2025.2) to a 2026.03 HS-X
73
97
  project. The generated project is a NET-NEW dupe for testing in \`hs-x dev\`;
74
98
  your original app keeps running untouched until cutover.
75
99
 
100
+ Guided mode:
101
+ hs-x migrate In a terminal, choose a local project, live app, or cutover plan
102
+
103
+ The guided journey discovers hsproject.json and migration.json sources beneath
104
+ a workspace, inspects the selected source, previews its output, and asks for
105
+ confirmation (default No) before writing. It generates a test dupe only: it
106
+ never deploys or executes cutover. Press Esc to go back or Ctrl+C to cancel.
107
+ For CI, JSON, JSON-stream, or redirected input, use an explicit command below.
108
+
76
109
  Commands:
77
110
  hs-x migrate run [path] [--out <dir>] [--json] Detect, classify, and generate the dupe (the one command)
78
111
  hs-x migrate pull [--app-id <id>] [--json] Pull a live app's definition into migration.json (no --app-id: pick from a list)
@@ -85,6 +118,69 @@ Commands:
85
118
  Run hs-x migrate <subcommand> --help for details on each step.
86
119
  `;
87
120
  }
121
+ const liveMigratePromptDeps = {
122
+ isInteractive,
123
+ select: (options) => promptSelect({ ...options, allowBack: true }),
124
+ text: (options) => promptText({ ...options, allowBack: true }),
125
+ confirm: (options) => promptConfirm({ ...options, allowBack: true }),
126
+ };
127
+ /** Prompt-only seam: the root journey can be tested without filesystem or network effects. */
128
+ export async function selectMigrateJourney(deps = liveMigratePromptDeps) {
129
+ const selected = await deps.select({
130
+ message: 'Where are you migrating from?',
131
+ options: [
132
+ {
133
+ value: 'local',
134
+ label: 'Local legacy project',
135
+ description: 'Inspect source on disk and generate a test dupe',
136
+ },
137
+ {
138
+ value: 'live',
139
+ label: 'Live HubSpot app',
140
+ description: 'Choose an app, pull migration.json, then generate a test dupe',
141
+ },
142
+ {
143
+ value: 'cutover',
144
+ label: 'Plan cutover',
145
+ description: 'Plan the in-place cutover, or --execute the one-way migration',
146
+ },
147
+ ],
148
+ });
149
+ return selected === BACK ? undefined : selected;
150
+ }
151
+ export async function confirmMigrateWrite(message, deps = liveMigratePromptDeps) {
152
+ const confirmed = await deps.confirm({ message, default: false, allowBack: true });
153
+ if (confirmed === BACK)
154
+ return 'back';
155
+ return confirmed === true ? 'proceed' : 'cancel';
156
+ }
157
+ /**
158
+ * Discover legacy migration sources beneath a workspace. `null` means no
159
+ * marker was found (callers retain their existing `.` fallback); undefined is
160
+ * prompt cancellation, and BACK returns to the preceding guided menu.
161
+ */
162
+ export async function selectLocalMigrationSource(root, deps = liveMigratePromptDeps) {
163
+ const projects = await discoverLocalProjects({
164
+ root,
165
+ markers: ['hsproject.json', 'migration.json'],
166
+ });
167
+ const direct = projects.find((project) => project.relativePath === '.');
168
+ if (direct)
169
+ return '.';
170
+ if (projects.length === 0)
171
+ return null;
172
+ if (projects.length === 1)
173
+ return projects[0]?.relativePath ?? null;
174
+ return await deps.select({
175
+ message: 'Which local legacy project?',
176
+ options: projects.map((project) => ({
177
+ value: project.relativePath,
178
+ label: project.name,
179
+ description: `${project.relativePath} · ${project.marker}`,
180
+ })),
181
+ allowBack: true,
182
+ });
183
+ }
88
184
  export function migrateSubcommandHelpText(sub) {
89
185
  switch (sub) {
90
186
  case 'run':
@@ -100,7 +196,8 @@ are listed with doc links; they gate CUTOVER, not the dupe — test it in
100
196
  \`hs-x dev\` first. Re-run safely after resolving findings.
101
197
 
102
198
  Arguments:
103
- path Legacy source (default: current directory)
199
+ path Legacy source. When omitted, terminals discover
200
+ workspace sources; scripts use the current directory.
104
201
 
105
202
  Flags:
106
203
  --out <dir> Output directory (default: migrated-app)
@@ -123,26 +220,35 @@ HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID).
123
220
  Flags:
124
221
  --app-id <id> HubSpot app id (omit to pick interactively)
125
222
  --app-name <name> Override the app name recorded in migration.json
126
- --out <file> Output file (default: migration.json)
223
+ --out <file> Output file (default: migration.json; existing files are never overwritten)
127
224
  --cwd <dir> Resolve paths relative to <dir>
128
225
  --json Emit JSON result (requires --app-id)
129
226
  `;
130
227
  case 'cutover':
131
228
  return `hs-x migrate cutover [path]
132
229
 
133
- Print the DRY-RUN cutover plan: the ordered steps that migrate the ORIGINAL
134
- app in place to 2026.03 — keeping its app id, installs, OAuth grants, and
135
- listing — once the dupe has proven the migration in \`hs-x dev\`.
230
+ Plan or perform the in-place cutover of the ORIGINAL app to 2026.03
231
+ keeping its app id, installs, OAuth grants, and listing — once the dupe has
232
+ proven the migration in \`hs-x dev\`.
136
233
 
137
- The plan is honest about the one-way door (no downgrade from 2026.03) and
138
- lists every open decision (external receivers, endpoint contracts,
139
- marketplace listing) that gates execution. Execution itself never runs
140
- without an explicit human go; --execute is refused in this build.
234
+ Bare, it prints the DRY-RUN plan: the ordered steps, the one-way door (no
235
+ downgrade from 2026.03), and every open decision (external receivers,
236
+ endpoint contracts, marketplace listing) that gates execution.
237
+
238
+ With --execute it performs the cutover, once no open decisions remain. It is
239
+ a ONE-WAY change, so it asks you to type the project name to confirm (or pass
240
+ --yes in automation; a non-TTY run without --yes is refused). 2025.2 sources
241
+ take the config-only path (platformVersion bump + in-place re-upload, rolled
242
+ back if the build fails); 2023.2/2025.1 sources run HubSpot's interactive
243
+ \`hs project migrate\`, which needs a terminal to answer its feature prompts.
141
244
 
142
245
  Arguments:
143
- path The ORIGINAL legacy source (default: current directory)
246
+ path The ORIGINAL legacy source. When omitted, terminals
247
+ discover workspace sources; scripts use the current directory.
144
248
 
145
249
  Flags:
250
+ --execute Perform the cutover (default: dry-run plan only)
251
+ --yes Skip the type-to-confirm prompt (automation)
146
252
  --json Emit the plan as JSON
147
253
  --cwd <dir> Resolve paths relative to <dir>
148
254
  `;
@@ -154,8 +260,8 @@ automatically, which need human review, and whether the replacement app needs
154
260
  the Cloudflare runtime.
155
261
 
156
262
  Arguments:
157
- path Migration input JSON file, or a directory
158
- containing migration.json
263
+ path A project directory (hsproject.json tree), a
264
+ migration.json file, or a directory containing one
159
265
 
160
266
  Flags:
161
267
  --cwd <dir> Resolve <path> relative to <dir>
@@ -170,8 +276,8 @@ Summarize migration readiness: detected features, automatable vs human-review
170
276
  finding counts, runtime requirements, and suggested next steps.
171
277
 
172
278
  Arguments:
173
- path Migration input JSON file, or a directory
174
- containing migration.json
279
+ path A project directory (hsproject.json tree), a
280
+ migration.json file, or a directory containing one
175
281
 
176
282
  Flags:
177
283
  --cwd <dir> Resolve <path> relative to <dir>
@@ -185,8 +291,8 @@ input, then validate the generated project. Fails (exit 1) if the input still
185
291
  has human-review findings — resolve those first (see hs-x migrate inspect).
186
292
 
187
293
  Arguments:
188
- path Migration input JSON file, or a directory
189
- containing migration.json
294
+ path A project directory (hsproject.json tree), a
295
+ migration.json file, or a directory containing one
190
296
 
191
297
  Flags:
192
298
  --project <dir> Output directory (default: migrated-app)
@@ -238,7 +344,9 @@ function readMigrationInputEff(path) {
238
344
  return Effect.gen(function* () {
239
345
  const fs = yield* FileSystem.FileSystem;
240
346
  const pathSvc = yield* Path.Path;
241
- const info = yield* fs.stat(path).pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${path}: ${String(cause)}`)));
347
+ const info = yield* fs
348
+ .stat(path)
349
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${path}: ${String(cause)}`)));
242
350
  if (info.type === 'Directory') {
243
351
  const fixture = pathSvc.join(path, 'migration.json');
244
352
  const hasFixture = yield* fs.exists(fixture).pipe(Effect.orElseSucceed(() => false));
@@ -258,7 +366,9 @@ function readMigrationInputEff(path) {
258
366
  }
259
367
  }
260
368
  const file = info.type === 'Directory' ? pathSvc.join(path, 'migration.json') : path;
261
- const contents = yield* fs.readFileString(file).pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${file}: ${String(cause)}`)));
369
+ const contents = yield* fs
370
+ .readFileString(file)
371
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${file}: ${String(cause)}`)));
262
372
  return yield* Effect.try({
263
373
  try: () => JSON.parse(contents),
264
374
  catch: (cause) => cliError('HSX_E_INPUT_BAD_JSON', `Invalid JSON in ${file}: ${String(cause)}`),
@@ -272,9 +382,30 @@ function writeProjectFilesEff(root, files) {
272
382
  const pathSvc = yield* Path.Path;
273
383
  for (const [file, contents] of Object.entries(files)) {
274
384
  const target = pathSvc.join(root, file);
275
- yield* fs.makeDirectory(pathSvc.dirname(target), { recursive: true }).pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not create directory for ${target}: ${String(cause)}`)));
276
- yield* fs.writeFileString(target, contents, { flag: 'wx' }).pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not write ${target}: ${String(cause)}`)));
385
+ yield* fs
386
+ .makeDirectory(pathSvc.dirname(target), { recursive: true })
387
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not create directory for ${target}: ${String(cause)}`)));
388
+ yield* fs
389
+ .writeFileString(target, contents, { flag: 'wx' })
390
+ .pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not write ${target}: ${String(cause)}`)));
391
+ }
392
+ });
393
+ }
394
+ function resolveMigrationCommandPath(input) {
395
+ return Effect.gen(function* () {
396
+ if (Option.isSome(input.path))
397
+ return input.path.value;
398
+ const cwd = yield* Cwd;
399
+ const reporter = yield* Reporter;
400
+ const root = Option.getOrElse(input.cwd, () => cwd);
401
+ if (input.json || reporter.mode === 'ndjson' || !isInteractive())
402
+ return '.';
403
+ const selected = yield* Effect.promise(() => selectLocalMigrationSource(root));
404
+ if (selected === undefined || selected === BACK) {
405
+ yield* reporter.info('Cancelled.');
406
+ return yield* Effect.fail(exitWith(130));
277
407
  }
408
+ return selected ?? '.';
278
409
  });
279
410
  }
280
411
  function migrateInspectBody(opts) {
@@ -435,12 +566,14 @@ function migrateRunBody(opts) {
435
566
  const outputRoot = pathSvc.resolve(root, Option.getOrUndefined(opts.out) ?? 'migrated-app');
436
567
  yield* writeProjectFilesEff(outputRoot, generated.files);
437
568
  const validation = yield* Effect.promise(() => validateProject({ root: outputRoot }));
569
+ const outstandingWork = migrationOutstandingWork(generated.files);
438
570
  const response = {
439
571
  ok: validation.ok,
440
572
  command: 'migrate run',
441
573
  outputRoot,
442
574
  createdFiles: Object.keys(generated.files).sort(),
443
575
  inspect: result,
576
+ outstandingWork,
444
577
  validation,
445
578
  };
446
579
  if (opts.json) {
@@ -450,9 +583,16 @@ function migrateRunBody(opts) {
450
583
  yield* reporter.header(pathSvc.basename(outputRoot));
451
584
  const genStep = yield* reporter.step('Generated migrated HS-X project (the dupe)');
452
585
  yield* genStep.ok(`${response.createdFiles.length} files ${outputRoot}`);
453
- if (result.automatable.length > 0) {
454
- yield* reporter.info(`Ported automatically:`);
455
- for (const finding of result.automatable)
586
+ const scaffoldedStubs = result.automatable.filter((finding) => finding.code === 'serverless.app-function.portable');
587
+ const portedAutomatically = result.automatable.filter((finding) => finding.code !== 'serverless.app-function.portable');
588
+ if (portedAutomatically.length > 0) {
589
+ yield* reporter.info('Ported automatically:');
590
+ for (const finding of portedAutomatically)
591
+ yield* reporter.info(` - ${finding.message}`);
592
+ }
593
+ if (scaffoldedStubs.length > 0) {
594
+ yield* reporter.info('Scaffolded (stub — port body):');
595
+ for (const finding of scaffoldedStubs)
456
596
  yield* reporter.info(` - ${finding.message}`);
457
597
  }
458
598
  if (result.manualWork.length > 0) {
@@ -471,11 +611,22 @@ function migrateRunBody(opts) {
471
611
  yield* reporter.error(d.code ?? 'diagnostic', d.message ?? JSON.stringify(d));
472
612
  }
473
613
  }
614
+ if (outstandingWork.total > 0) {
615
+ yield* reporter.info('');
616
+ yield* reporter.info(`Outstanding migration work: ${outstandingWork.todoMarkers} TODO marker(s), ${outstandingWork.frontendServerlessCalls} frontend serverless call(s) (${outstandingWork.total} total).`);
617
+ yield* reporter.info('The dupe is scaffolded for testing; it is not production-ready yet.');
618
+ }
474
619
  yield* reporter.info('');
475
620
  yield* reporter.info('Next steps:');
476
621
  yield* reporter.info(` cd ${pathSvc.relative(root, outputRoot) || '.'} && bun install`);
622
+ if (opts.guided) {
623
+ yield* reporter.info(' hs-x check # validate the generated project again after edits');
624
+ }
477
625
  yield* reporter.info(' hs-x dev # test every migrated capability locally');
478
626
  yield* reporter.info(' hs-x deploy # upload the dupe as a NET-NEW app when ready');
627
+ if (opts.guided && opts.originalSource) {
628
+ yield* reporter.info(` hs-x migrate cutover ${opts.originalSource} # review the dry-run cutover plan`);
629
+ }
479
630
  yield* reporter.done(validation.ok ? 'Migrated' : 'Failed', validation.ok ? 0 : 1);
480
631
  }
481
632
  if (!validation.ok) {
@@ -570,7 +721,7 @@ function swapCardsArgv(opts) {
570
721
  argv.push('--user-id', opts.userId.value);
571
722
  return argv;
572
723
  }
573
- const migrateRunPathArg = Args.text({ name: 'path' }).pipe(Args.withDefault('.'));
724
+ const migrateRunPathArg = Args.text({ name: 'path' }).pipe(Args.optional);
574
725
  const migrateOutOption = Options.text('out').pipe(Options.optional);
575
726
  /**
576
727
  * `hs-x migrate cutover [path]` — DRY-RUN ONLY today (locked decision #2:
@@ -579,43 +730,134 @@ const migrateOutOption = Options.text('out').pipe(Options.optional);
579
730
  * in place, plus the open decisions gating execution.
580
731
  */
581
732
  /**
582
- * Cutover EXECUTE (2026-06-10 authorization): may run ONLY against net-new
583
- * throwaway apps on the owner's developer account 46993937 never client
584
- * apps. 2025.2 sources execute the config-only path (platformVersion bump +
585
- * in-place re-upload of the ORIGINAL tree, same app id). 2023.2/2025.1
586
- * (`hs project migrate` semantics) stay gated until that API path is
587
- * grounded and rehearsed.
733
+ * Not every open decision blocks an in-place cutover. Cutover re-uploads the
734
+ * SAME source under a new platform version (2025.2), or runs HubSpot's own
735
+ * `hs project migrate` (2023.2/2025.1), so "keep forwarding?" / "confirm this
736
+ * surface" findings are advisory surfaced, then covered by the type-to-confirm.
737
+ * Only findings that mean the source is missing something required (a URL,
738
+ * entrypoint, or label) or carries an unsupported/empty component genuinely
739
+ * block: those can't be resolved by proceeding.
740
+ */
741
+ function isBlockingCutoverDecision(code) {
742
+ return (code === 'migration.input.unsupported' ||
743
+ code.startsWith('component.unsupported.') ||
744
+ code.endsWith('.missing') ||
745
+ code.endsWith('.empty'));
746
+ }
747
+ /**
748
+ * `confirmCutover` — the human checkpoint. Cutover is a one-way, in-place
749
+ * change to the ORIGINAL app, so execution requires the operator to type the
750
+ * project name back (or pass --yes in automation). It never runs unattended by
751
+ * default: a non-TTY run without --yes is refused rather than assumed.
752
+ */
753
+ function confirmCutover(input) {
754
+ const { reporter, projectName, yes } = input;
755
+ return Effect.gen(function* () {
756
+ if (yes) {
757
+ yield* reporter.info(`--yes: skipping the type-to-confirm for "${projectName}".`);
758
+ return;
759
+ }
760
+ if (!isInteractive()) {
761
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_CONFIRM_REQUIRED', 'Cutover is a one-way, in-place change to the original app. Re-run in an interactive terminal to confirm, or pass --yes to skip the prompt in automation.'));
762
+ }
763
+ yield* reporter.info(`ONE-WAY: "${projectName}" cannot downgrade from 2026.03 after cutover.`);
764
+ const answer = yield* Effect.tryPromise({
765
+ try: () => promptText({ message: 'Type the project name to confirm cutover' }),
766
+ catch: () => cliError('HSX_E_INTERNAL', 'Could not read confirmation input.'),
767
+ });
768
+ if (answer === undefined || answer.trim() !== projectName) {
769
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_CONFIRM_MISMATCH', `Confirmation "${answer?.trim() ?? ''}" did not match the project name "${projectName}". Cutover aborted; nothing changed.`));
770
+ }
771
+ });
772
+ }
773
+ /**
774
+ * Cutover EXECUTE: migrates the ORIGINAL app in place to 2026.03, keeping its
775
+ * app id, installs, OAuth grants, and listing — a one-way door. Gated by (1) no
776
+ * open decisions in the plan, (2) an explicit human confirmation (type the
777
+ * project name, or --yes), and (3) a supported source era:
778
+ * • 2025.2 — config-only: bump platformVersion → 2026.03 and re-upload in
779
+ * place (rolled back if the build fails).
780
+ * • 2023.2 / 2025.1 — HubSpot's own interactive `hs project migrate` (needs a
781
+ * real terminal to answer its feature-confirmation prompts).
782
+ * Non-project legacy apps (`hs app migrate`) are out of scope for cutover.
588
783
  */
589
- const CUTOVER_REHEARSAL_DEVELOPER_ACCOUNT = '46993937';
590
784
  function executeCutover(input) {
591
785
  const { opts, sourcePath, reporter } = input;
592
786
  return Effect.gen(function* () {
593
787
  const fs = yield* FileSystem.FileSystem;
594
788
  const developerAccountId = process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID ?? '';
595
- if (developerAccountId !== CUTOVER_REHEARSAL_DEVELOPER_ACCOUNT) {
596
- return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Cutover execution is authorized only for throwaway apps on developer account ${CUTOVER_REHEARSAL_DEVELOPER_ACCOUNT} (got ${developerAccountId || 'none'}). Real-app cutover needs an explicit per-app human go.`));
597
- }
598
789
  const detected = yield* Effect.tryPromise({
599
790
  try: () => detectMigrationSource(sourcePath),
600
791
  catch: (cause) => cliError('HSX_E_INTERNAL', `Could not inspect ${sourcePath}: ${String(cause)}`),
601
792
  });
602
793
  const classification = classifyMigrationInput(detected.input);
603
794
  const plan = planCutover(detected, classification);
604
- if (plan.openDecisions.length > 0) {
605
- return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `${plan.openDecisions.length} open decision(s) gate execution: ${plan.openDecisions
795
+ const blockingDecisions = plan.openDecisions.filter((decision) => isBlockingCutoverDecision(decision.code));
796
+ const advisoryDecisions = plan.openDecisions.filter((decision) => !isBlockingCutoverDecision(decision.code));
797
+ if (blockingDecisions.length > 0) {
798
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `${blockingDecisions.length} blocking decision(s) prevent cutover: ${blockingDecisions
606
799
  .map((decision) => decision.code)
607
- .join(', ')}. Resolve them, re-run the dry-run, then execute.`));
800
+ .join(', ')}. The source is missing something cutover needs (a URL, entrypoint, or label) or carries an unsupported component — fix it in the source, re-run the dry-run, then execute.`));
608
801
  }
609
- if (plan.sourceVersion !== '2025.2') {
610
- return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Execute currently supports the 2025.2 config-only path; ${plan.sourceVersion} sources go through \`hs project migrate\` semantics, which stay human-driven until that leg is rehearsed.`));
802
+ // Advisory findings don't block an in-place cutover; surface them so the
803
+ // operator sees exactly what they're acknowledging at the confirm prompt.
804
+ for (const decision of advisoryDecisions) {
805
+ yield* reporter.info(`advisory [${decision.code}]: ${decision.message}`);
611
806
  }
612
- // 2025.2 config-only execute: bump platformVersion IN PLACE, re-upload
613
- // the original tree (same project name => same app id), watch the build.
807
+ // Cutover execute operates on the ORIGINAL project tree.
614
808
  const projectConfigPath = `${sourcePath}/hsproject.json`;
615
809
  const rawProject = yield* fs
616
810
  .readFileString(projectConfigPath)
617
- .pipe(Effect.mapError(() => cliError('HSX_E_INTERNAL', `Could not read ${projectConfigPath}.`)));
811
+ .pipe(Effect.mapError(() => cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Cutover execute needs the original project tree (hsproject.json) at ${sourcePath}; a migration.json input alone is not enough. Point at the ORIGINAL legacy app's source.`)));
618
812
  const projectJson = JSON.parse(rawProject);
813
+ const projectName = typeof projectJson.name === 'string' && projectJson.name.length > 0
814
+ ? projectJson.name
815
+ : 'migrated-app';
816
+ const yes = opts.yes ?? false;
817
+ // 2023.2 / 2025.1 → HubSpot's own interactive `hs project migrate`. It
818
+ // prompts to confirm the features it migrates, so it needs a real terminal;
819
+ // HS-X gates entry, then hands the wheel to HubSpot's CLI (inherited stdio).
820
+ if (plan.sourceVersion === '2023.2' || plan.sourceVersion === '2025.1') {
821
+ if (!isInteractive()) {
822
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_CONFIRM_REQUIRED', `Cutover for a ${plan.sourceVersion} project runs HubSpot's interactive \`hs project migrate\`, which prompts to confirm the features it migrates. Run it in an interactive terminal — --yes skips HS-X's own confirmation but cannot answer HubSpot's prompts.`));
823
+ }
824
+ yield* confirmCutover({ reporter, projectName, yes });
825
+ yield* reporter.info("Running HubSpot's project migration in place (hs project migrate → 2026.03). Answer its feature prompts to complete the one-way migration…");
826
+ const migrateExit = yield* Effect.tryPromise({
827
+ try: async () => {
828
+ const proc = Bun.spawn({
829
+ cmd: [
830
+ 'hs',
831
+ 'project',
832
+ 'migrate',
833
+ '--platform-version',
834
+ '2026.03',
835
+ ...(developerAccountId.length > 0 ? ['--account', developerAccountId] : []),
836
+ ],
837
+ cwd: sourcePath,
838
+ env: { ...process.env },
839
+ stdin: 'inherit',
840
+ stdout: 'inherit',
841
+ stderr: 'inherit',
842
+ });
843
+ return await proc.exited;
844
+ },
845
+ catch: (cause) => cliError('HSX_E_CUTOVER_EXECUTE_FAILED', `Could not run \`hs project migrate\` (${String(cause)}). Cutover drives HubSpot's own CLI — install it (npm i -g @hubspot/cli) and authenticate (hs accounts auth).`),
846
+ });
847
+ if (migrateExit !== 0) {
848
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_FAILED', `\`hs project migrate\` exited ${migrateExit}; the original app was not migrated (a failed or cancelled migration leaves your project as HubSpot found it).`));
849
+ }
850
+ yield* reporter.info(`Original ${plan.sourceVersion} app migrated to 2026.03 — app id, installs, and OAuth grants preserved.`);
851
+ yield* reporter.info('Note: serverless env vars declared via serverless.json `environment` are not migrated automatically — redefine them as secrets with `hs secret add`.');
852
+ yield* reporter.info('Next: verify capabilities, then deprecate/delete the dupe.');
853
+ return yield* reporter.done('Cutover executed');
854
+ }
855
+ if (plan.sourceVersion !== '2025.2') {
856
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Cutover execute covers project-based 2023.2, 2025.1, and 2025.2 sources; this source resolves to "${plan.sourceVersion}". A non-project legacy app migrates with HubSpot's own interactive \`hs app migrate\`, which cutover does not wrap.`));
857
+ }
858
+ // 2025.2 config-only execute: bump platformVersion IN PLACE, re-upload
859
+ // the original tree (same project name => same app id), watch the build.
860
+ yield* confirmCutover({ reporter, projectName, yes });
619
861
  const previousVersion = projectJson.platformVersion;
620
862
  projectJson.platformVersion = '2026.03';
621
863
  yield* fs
@@ -646,8 +888,7 @@ function executeCutover(input) {
646
888
  catch: (cause) => cliError('HSX_E_CUTOVER_EXECUTE_FAILED', `Could not run \`hs project upload\` (${String(cause)}). Cutover drives HubSpot's own CLI — install it (npm i -g @hubspot/cli) and authenticate (hs accounts auth).`),
647
889
  });
648
890
  const uploadOutput = `${result.stdout}\n${result.stderr}`;
649
- const buildLine = /Build #(\d+) succeeded/.exec(uploadOutput) ??
650
- /Built [\w-]+ #(\d+)/.exec(uploadOutput);
891
+ const buildLine = /Build #(\d+) succeeded/.exec(uploadOutput) ?? /Built [\w-]+ #(\d+)/.exec(uploadOutput);
651
892
  if (result.exitCode !== 0 || !buildLine) {
652
893
  // Roll the version bump back so a failed execute leaves the tree as found.
653
894
  projectJson.platformVersion = previousVersion;
@@ -693,31 +934,29 @@ function migrateCutoverBody(opts) {
693
934
  continue;
694
935
  yield* reporter.info(`${phase.toUpperCase()}:`);
695
936
  for (const step of phaseSteps) {
696
- yield* reporter.info(` - ${step.title}${step.gatedBy ? ` [gated: ${step.gatedBy}]` : ''}`);
937
+ const tag = step.gatedBy
938
+ ? ` [${isBlockingCutoverDecision(step.gatedBy) ? 'blocks' : 'confirm'}: ${step.gatedBy}]`
939
+ : '';
940
+ yield* reporter.info(` - ${step.title}${tag}`);
697
941
  yield* reporter.info(` ${step.detail}`);
698
942
  }
699
943
  yield* reporter.info('');
700
944
  }
701
945
  if (plan.openDecisions.length > 0) {
702
- yield* reporter.info(`${plan.openDecisions.length} open decision(s) gate execution — resolve them, re-run this plan, then request execution.`);
946
+ const blockingCount = plan.openDecisions.filter((decision) => isBlockingCutoverDecision(decision.code)).length;
947
+ yield* reporter.info(blockingCount > 0
948
+ ? `${blockingCount} of ${plan.openDecisions.length} decision(s) BLOCK execution (missing/unsupported) — fix them in the source, then re-run with --execute. The rest are advisory confirmations.`
949
+ : `${plan.openDecisions.length} advisory decision(s) to confirm; none block. Re-run with --execute to perform the one-way, in-place cutover — you confirm by typing the project name (or pass --yes in automation).`);
703
950
  }
704
951
  else {
705
- yield* reporter.info('No open decisions. Execution still requires an explicit human go (see LAUNCH-PLAN NEEDS HUMAN).');
952
+ yield* reporter.info('No open decisions. Re-run with --execute to perform the one-way, in-place cutover — you confirm by typing the project name (or pass --yes in automation).');
706
953
  }
707
954
  yield* reporter.done('Planned');
708
955
  });
709
956
  }
710
957
  const migrateExecuteOption = Options.boolean('execute').pipe(Options.withDefault(false));
711
- const migrateCutoverPathArg = Args.text({ name: 'path' }).pipe(Args.withDefault('.'));
712
- /**
713
- * `hs-x migrate pull --app-id <id>` — live-app pull (locked decision #4,
714
- * live half): fetch a UI-configured legacy app's definition via HubSpot
715
- * APIs (PAK auth) and write the migration.json input the classifier and
716
- * generator already consume. Endpoints grounded 2026-06-10:
717
- * cards GET /crm/extensions/cards-dev/2026-03/{appId}; webhooks
718
- * GET /webhooks/v3/{appId}/settings + /subscriptions; workflow actions
719
- * GET /automation/v4/actions/{appId}.
720
- */
958
+ const migrateYesOption = Options.boolean('yes').pipe(Options.withDefault(false));
959
+ const migrateCutoverPathArg = Args.text({ name: 'path' }).pipe(Args.optional);
721
960
  function migratePullBody(opts) {
722
961
  return Effect.gen(function* () {
723
962
  const cwd = yield* Cwd;
@@ -725,6 +964,11 @@ function migratePullBody(opts) {
725
964
  const fs = yield* FileSystem.FileSystem;
726
965
  const reporter = yield* Reporter;
727
966
  const root = Option.getOrElse(opts.cwd, () => cwd);
967
+ const outPath = pathSvc.resolve(root, opts.out);
968
+ const outputExists = yield* fs.exists(outPath).pipe(Effect.orElseSucceed(() => false));
969
+ if (outputExists) {
970
+ return yield* Effect.fail(cliError('HSX_E_INPUT_BAD_FIELD', `${outPath} already exists; refusing to overwrite migration input. Choose another file with --out <file> or move the existing file.`));
971
+ }
728
972
  const personalAccessKey = process.env.HSX_HUBSPOT_PAK;
729
973
  const portalId = process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID;
730
974
  if (!personalAccessKey || !portalId) {
@@ -820,10 +1064,11 @@ function migratePullBody(opts) {
820
1064
  },
821
1065
  catch: (cause) => cliError('HSX_E_INTERNAL', `Live pull failed: ${cause instanceof Error ? cause.message : String(cause)}`),
822
1066
  });
1067
+ const appName = Option.getOrElse(opts.appName, () => pickedAppName ?? `app-${appId}`);
823
1068
  const input = {
824
1069
  legacyApp: {
825
1070
  appId,
826
- name: Option.getOrElse(opts.appName, () => pickedAppName ?? `app-${appId}`),
1071
+ name: appName,
827
1072
  portalId,
828
1073
  },
829
1074
  };
@@ -831,8 +1076,8 @@ function migratePullBody(opts) {
831
1076
  const cardResults = isRecord(pulled.cards) && Array.isArray(pulled.cards.results)
832
1077
  ? pulled.cards.results.filter(isRecord)
833
1078
  : [];
834
- if (cardResults.length > 0) {
835
- const card = cardResults[0];
1079
+ const card = cardResults[0];
1080
+ if (card) {
836
1081
  const title = isRecord(card.title) ? '' : String(card.title ?? '');
837
1082
  const fetchData = isRecord(card.fetch) ? card.fetch : {};
838
1083
  input.legacyCard = {
@@ -885,21 +1130,24 @@ function migratePullBody(opts) {
885
1130
  }));
886
1131
  detected.push(`workflow actions ×${actionResults.length}`);
887
1132
  }
888
- const outPath = pathSvc.resolve(root, opts.out);
889
1133
  yield* fs
890
- .writeFileString(outPath, `${JSON.stringify(input, null, 2)}\n`)
891
- .pipe(Effect.mapError(() => cliError('HSX_E_INTERNAL', `Could not write ${outPath}.`)));
1134
+ .writeFileString(outPath, `${JSON.stringify(input, null, 2)}\n`, { flag: 'wx' })
1135
+ .pipe(Effect.mapError(() => cliError('HSX_E_INTERNAL', `Could not create ${outPath}; it may already exist. Choose another file with --out <file>.`)));
1136
+ const result = { appId, appName, outPath };
892
1137
  if (opts.json) {
893
1138
  yield* reporter.raw(`${JSON.stringify({ ok: true, command: 'migrate pull', appId, out: outPath, detected }, null, 2)}\n`);
894
- return;
1139
+ return result;
895
1140
  }
896
1141
  yield* reporter.header(`pulled app ${appId}`);
897
1142
  for (const warning of pulled.warnings) {
898
1143
  yield* reporter.info(`note: ${warning}`);
899
1144
  }
900
- yield* reporter.info(detected.length > 0 ? `Detected: ${detected.join(', ')}` : 'Nothing portable detected on this app.');
1145
+ yield* reporter.info(detected.length > 0
1146
+ ? `Detected: ${detected.join(', ')}`
1147
+ : 'Nothing portable detected on this app.');
901
1148
  yield* reporter.info(`Wrote ${outPath} — next: hs-x migrate run ${pathSvc.relative(cwd, outPath) || opts.out}`);
902
1149
  yield* reporter.done('Pulled');
1150
+ return result;
903
1151
  });
904
1152
  }
905
1153
  /**
@@ -944,6 +1192,173 @@ async function fetchMigratableApps(accessToken, portalId) {
944
1192
  }
945
1193
  return out;
946
1194
  }
1195
+ function migrateCancelled(reporter) {
1196
+ return Effect.gen(function* () {
1197
+ yield* reporter.info('Cancelled.');
1198
+ return yield* Effect.fail(exitWith(130));
1199
+ });
1200
+ }
1201
+ function renderGuidedPreview(input, sourcePath, outputPath) {
1202
+ const classification = classifyMigrationInput(input);
1203
+ let files = 0;
1204
+ try {
1205
+ files = Object.keys(generateMigratedHsxProject(input).files).length;
1206
+ }
1207
+ catch {
1208
+ // The existing run body owns the detailed generation failure. The preview
1209
+ // still gives the user the classification before asking for consent.
1210
+ }
1211
+ return `${renderMigrationInspect(classification).trimEnd()}\nPreview: generate ${files || 'no'} file${files === 1 ? '' : 's'} from ${sourcePath} into ${outputPath}.`;
1212
+ }
1213
+ function migrateRootBody(opts, deps = liveMigratePromptDeps) {
1214
+ return Effect.gen(function* () {
1215
+ const cwd = yield* Cwd;
1216
+ const pathSvc = yield* Path.Path;
1217
+ const fs = yield* FileSystem.FileSystem;
1218
+ const reporter = yield* Reporter;
1219
+ if (opts.json || reporter.mode === 'ndjson' || !deps.isInteractive()) {
1220
+ return yield* Effect.fail(cliError('HSX_E_INPUT_MISSING_VALUE', 'Bare `hs-x migrate` is a terminal guide. For automation use `hs-x migrate run <path> --out <dir>`, `hs-x migrate pull --app-id <id> --out <file>`, or `hs-x migrate cutover <path>`.'));
1221
+ }
1222
+ const root = Option.getOrElse(opts.cwd, () => cwd);
1223
+ yield* reporter.banner;
1224
+ yield* reporter.header('guided migration');
1225
+ for (;;) {
1226
+ const journey = yield* Effect.promise(() => selectMigrateJourney(deps));
1227
+ if (!journey)
1228
+ return yield* migrateCancelled(reporter);
1229
+ if (journey === 'cutover') {
1230
+ const suggestedSource = yield* Effect.promise(() => selectLocalMigrationSource(root, deps));
1231
+ if (suggestedSource === BACK)
1232
+ continue;
1233
+ if (suggestedSource === undefined)
1234
+ return yield* migrateCancelled(reporter);
1235
+ const source = yield* Effect.promise(() => deps.text({
1236
+ message: 'Original legacy source',
1237
+ default: suggestedSource ?? '.',
1238
+ allowBack: true,
1239
+ }));
1240
+ if (source === BACK)
1241
+ continue;
1242
+ if (source === undefined)
1243
+ return yield* migrateCancelled(reporter);
1244
+ return yield* migrateCutoverBody({
1245
+ path: source,
1246
+ cwd: Option.some(root),
1247
+ json: false,
1248
+ execute: false,
1249
+ yes: false,
1250
+ });
1251
+ }
1252
+ if (journey === 'live') {
1253
+ let pullOut = 'migration.json';
1254
+ for (;;) {
1255
+ const answer = yield* Effect.promise(() => deps.text({
1256
+ message: 'Save the pulled migration input as',
1257
+ default: pullOut,
1258
+ allowBack: true,
1259
+ }));
1260
+ if (answer === BACK)
1261
+ break;
1262
+ if (answer === undefined)
1263
+ return yield* migrateCancelled(reporter);
1264
+ pullOut = answer;
1265
+ const pullPath = pathSvc.resolve(root, pullOut);
1266
+ if (yield* fs.exists(pullPath).pipe(Effect.orElseSucceed(() => false))) {
1267
+ yield* reporter.warn('HSX_E_INPUT_BAD_FIELD', `${pullPath} already exists. Choose another file; HS-X never overwrites migration input.`);
1268
+ continue;
1269
+ }
1270
+ yield* reporter.info(`Preview: choose an eligible app from HubSpot and write its portable definition to ${pullPath}.`);
1271
+ const pullConfirmed = yield* Effect.promise(() => confirmMigrateWrite('Pull the live app and write this migration input?', deps));
1272
+ if (pullConfirmed === 'back')
1273
+ continue;
1274
+ if (pullConfirmed !== 'proceed')
1275
+ return yield* migrateCancelled(reporter);
1276
+ const pulled = yield* migratePullBody({
1277
+ appId: Option.none(),
1278
+ appName: Option.none(),
1279
+ out: pullOut,
1280
+ cwd: Option.some(root),
1281
+ json: false,
1282
+ });
1283
+ const input = yield* readMigrationInputEff(pulled.outPath);
1284
+ let output = 'migrated-app';
1285
+ for (;;) {
1286
+ const outputAnswer = yield* Effect.promise(() => deps.text({
1287
+ message: 'Generate the test dupe in',
1288
+ default: output,
1289
+ allowBack: true,
1290
+ }));
1291
+ if (outputAnswer === BACK)
1292
+ continue;
1293
+ if (outputAnswer === undefined)
1294
+ return yield* migrateCancelled(reporter);
1295
+ output = outputAnswer;
1296
+ const outputPath = pathSvc.resolve(root, output);
1297
+ yield* reporter.block(renderGuidedPreview(input, pulled.outPath, outputPath));
1298
+ const generateConfirmed = yield* Effect.promise(() => confirmMigrateWrite('Generate and validate this test dupe?', deps));
1299
+ if (generateConfirmed === 'back')
1300
+ continue;
1301
+ if (generateConfirmed !== 'proceed')
1302
+ return yield* migrateCancelled(reporter);
1303
+ return yield* migrateRunBody({
1304
+ path: pulled.outPath,
1305
+ out: Option.some(output),
1306
+ cwd: Option.some(root),
1307
+ json: false,
1308
+ guided: true,
1309
+ originalSource: pulled.outPath,
1310
+ });
1311
+ }
1312
+ }
1313
+ continue;
1314
+ }
1315
+ const suggestedSource = yield* Effect.promise(() => selectLocalMigrationSource(root, deps));
1316
+ if (suggestedSource === BACK)
1317
+ continue;
1318
+ if (suggestedSource === undefined)
1319
+ return yield* migrateCancelled(reporter);
1320
+ let source = suggestedSource ?? '.';
1321
+ for (;;) {
1322
+ const sourceAnswer = yield* Effect.promise(() => deps.text({ message: 'Local legacy source', default: source, allowBack: true }));
1323
+ if (sourceAnswer === BACK)
1324
+ break;
1325
+ if (sourceAnswer === undefined)
1326
+ return yield* migrateCancelled(reporter);
1327
+ source = sourceAnswer;
1328
+ const sourcePath = pathSvc.resolve(root, source);
1329
+ const input = yield* readMigrationInputEff(sourcePath);
1330
+ let output = 'migrated-app';
1331
+ for (;;) {
1332
+ const outputAnswer = yield* Effect.promise(() => deps.text({
1333
+ message: 'Generate the test dupe in',
1334
+ default: output,
1335
+ allowBack: true,
1336
+ }));
1337
+ if (outputAnswer === BACK)
1338
+ break;
1339
+ if (outputAnswer === undefined)
1340
+ return yield* migrateCancelled(reporter);
1341
+ output = outputAnswer;
1342
+ const outputPath = pathSvc.resolve(root, output);
1343
+ yield* reporter.block(renderGuidedPreview(input, sourcePath, outputPath));
1344
+ const confirmed = yield* Effect.promise(() => confirmMigrateWrite('Generate and validate this test dupe?', deps));
1345
+ if (confirmed === 'back')
1346
+ continue;
1347
+ if (confirmed !== 'proceed')
1348
+ return yield* migrateCancelled(reporter);
1349
+ return yield* migrateRunBody({
1350
+ path: source,
1351
+ out: Option.some(output),
1352
+ cwd: Option.some(root),
1353
+ json: false,
1354
+ guided: true,
1355
+ originalSource: source,
1356
+ });
1357
+ }
1358
+ }
1359
+ }
1360
+ });
1361
+ }
947
1362
  const migratePullAppIdOption = Options.text('app-id').pipe(Options.optional);
948
1363
  const migratePullNameOption = Options.text('app-name').pipe(Options.optional);
949
1364
  const migratePullOutOption = Options.text('out').pipe(Options.withDefault('migration.json'));
@@ -953,10 +1368,22 @@ const migratePullCmd = Command.make('pull', {
953
1368
  out: migratePullOutOption,
954
1369
  cwd: cwdOption,
955
1370
  json: jsonOption,
956
- }, (opts) => runHandler('migrate', { json: opts.json }, migratePullBody(opts)));
957
- const migrateCutoverCmd = Command.make('cutover', { path: migrateCutoverPathArg, cwd: cwdOption, json: jsonOption, execute: migrateExecuteOption }, (opts) => runHandler('migrate', { json: opts.json }, migrateCutoverBody(opts)));
958
- const migrateRunCmd = Command.make('run', { path: migrateRunPathArg, out: migrateOutOption, cwd: cwdOption, json: jsonOption }, (opts) => runHandler('migrate', { json: opts.json }, migrateRunBody(opts)));
959
- export const migrateCmd = Command.make('migrate').pipe(Command.withSubcommands([
1371
+ }, (opts) => runHandler('migrate', { json: opts.json }, Effect.asVoid(migratePullBody(opts))));
1372
+ const migrateCutoverCmd = Command.make('cutover', {
1373
+ path: migrateCutoverPathArg,
1374
+ cwd: cwdOption,
1375
+ json: jsonOption,
1376
+ execute: migrateExecuteOption,
1377
+ yes: migrateYesOption,
1378
+ }, (opts) => runHandler('migrate', { json: opts.json }, Effect.gen(function* () {
1379
+ const path = yield* resolveMigrationCommandPath(opts);
1380
+ yield* migrateCutoverBody({ ...opts, path });
1381
+ })));
1382
+ const migrateRunCmd = Command.make('run', { path: migrateRunPathArg, out: migrateOutOption, cwd: cwdOption, json: jsonOption }, (opts) => runHandler('migrate', { json: opts.json }, Effect.gen(function* () {
1383
+ const path = yield* resolveMigrationCommandPath(opts);
1384
+ yield* migrateRunBody({ ...opts, path });
1385
+ })));
1386
+ export const migrateCmd = Command.make('migrate', { cwd: cwdOption, json: jsonOption }, (opts) => runHandler('migrate', { json: opts.json }, migrateRootBody(opts))).pipe(Command.withSubcommands([
960
1387
  migrateRunCmd,
961
1388
  migratePullCmd,
962
1389
  migrateCutoverCmd,