@happier-dev/stack 0.1.0-preview.5.1

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 (439) hide show
  1. package/README.md +501 -0
  2. package/bin/hstack.mjs +348 -0
  3. package/docs/codex-mcp-resume.md +129 -0
  4. package/docs/edison.md +74 -0
  5. package/docs/forking-and-branding.md +189 -0
  6. package/docs/happy-development.md +22 -0
  7. package/docs/isolated-linux-vm.md +243 -0
  8. package/docs/menubar.md +244 -0
  9. package/docs/mobile-ios.md +322 -0
  10. package/docs/monorepo-migration.md +20 -0
  11. package/docs/paths-and-env.md +154 -0
  12. package/docs/remote-access.md +43 -0
  13. package/docs/server-flavors.md +147 -0
  14. package/docs/stacks.md +330 -0
  15. package/docs/tauri.md +60 -0
  16. package/docs/worktrees-and-forks.md +133 -0
  17. package/extras/swiftbar/auth-login.sh +29 -0
  18. package/extras/swiftbar/git-cache-refresh.sh +122 -0
  19. package/extras/swiftbar/hstack-term.sh +133 -0
  20. package/extras/swiftbar/hstack.5s.sh +296 -0
  21. package/extras/swiftbar/hstack.sh +35 -0
  22. package/extras/swiftbar/icons/happy-green.png +0 -0
  23. package/extras/swiftbar/icons/happy-orange.png +0 -0
  24. package/extras/swiftbar/icons/happy-red.png +0 -0
  25. package/extras/swiftbar/icons/logo-white.png +0 -0
  26. package/extras/swiftbar/install.sh +265 -0
  27. package/extras/swiftbar/lib/git.sh +629 -0
  28. package/extras/swiftbar/lib/icons.sh +92 -0
  29. package/extras/swiftbar/lib/render.sh +999 -0
  30. package/extras/swiftbar/lib/system.sh +244 -0
  31. package/extras/swiftbar/lib/utils.sh +717 -0
  32. package/extras/swiftbar/set-interval.sh +65 -0
  33. package/extras/swiftbar/set-server-flavor.sh +61 -0
  34. package/extras/swiftbar/wt-pr.sh +140 -0
  35. package/node_modules/@happier-dev/cli-common/README.md +6 -0
  36. package/node_modules/@happier-dev/cli-common/dist/index.d.ts +4 -0
  37. package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -0
  38. package/node_modules/@happier-dev/cli-common/dist/index.js +4 -0
  39. package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -0
  40. package/node_modules/@happier-dev/cli-common/dist/links/index.d.ts +18 -0
  41. package/node_modules/@happier-dev/cli-common/dist/links/index.d.ts.map +1 -0
  42. package/node_modules/@happier-dev/cli-common/dist/links/index.js +25 -0
  43. package/node_modules/@happier-dev/cli-common/dist/links/index.js.map +1 -0
  44. package/node_modules/@happier-dev/cli-common/dist/links.d.ts +2 -0
  45. package/node_modules/@happier-dev/cli-common/dist/links.d.ts.map +1 -0
  46. package/node_modules/@happier-dev/cli-common/dist/links.js +2 -0
  47. package/node_modules/@happier-dev/cli-common/dist/links.js.map +1 -0
  48. package/node_modules/@happier-dev/cli-common/dist/update/index.d.ts +67 -0
  49. package/node_modules/@happier-dev/cli-common/dist/update/index.d.ts.map +1 -0
  50. package/node_modules/@happier-dev/cli-common/dist/update/index.js +259 -0
  51. package/node_modules/@happier-dev/cli-common/dist/update/index.js.map +1 -0
  52. package/node_modules/@happier-dev/cli-common/dist/workspaces/index.d.ts +17 -0
  53. package/node_modules/@happier-dev/cli-common/dist/workspaces/index.d.ts.map +1 -0
  54. package/node_modules/@happier-dev/cli-common/dist/workspaces/index.js +80 -0
  55. package/node_modules/@happier-dev/cli-common/dist/workspaces/index.js.map +1 -0
  56. package/node_modules/@happier-dev/cli-common/package.json +26 -0
  57. package/package.json +77 -0
  58. package/scripts/auth.mjs +1829 -0
  59. package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +90 -0
  60. package/scripts/auth_copy_from_runCapture.integration.test.mjs +447 -0
  61. package/scripts/auth_help_cmd.test.mjs +28 -0
  62. package/scripts/auth_login_flow_in_tty.test.mjs +100 -0
  63. package/scripts/auth_login_force_default.test.mjs +66 -0
  64. package/scripts/auth_login_guided_server_no_expo.test.mjs +126 -0
  65. package/scripts/auth_login_method_override.test.mjs +67 -0
  66. package/scripts/auth_login_print_includes_configure_links.test.mjs +99 -0
  67. package/scripts/auth_status_server_validation.integration.test.mjs +140 -0
  68. package/scripts/build.mjs +266 -0
  69. package/scripts/bundleWorkspaceDeps.mjs +38 -0
  70. package/scripts/bundleWorkspaceDeps.test.mjs +77 -0
  71. package/scripts/ci.mjs +135 -0
  72. package/scripts/ci.test.mjs +50 -0
  73. package/scripts/cli-link.mjs +57 -0
  74. package/scripts/completion.mjs +395 -0
  75. package/scripts/contrib.mjs +333 -0
  76. package/scripts/daemon.mjs +1160 -0
  77. package/scripts/daemon.status_scope.test.mjs +51 -0
  78. package/scripts/daemon_cmd.mjs +26 -0
  79. package/scripts/daemon_dist_guard.test.mjs +171 -0
  80. package/scripts/daemon_invalid_auth_reseed_stack_name.integration.test.mjs +608 -0
  81. package/scripts/daemon_server_scoped_state.test.mjs +49 -0
  82. package/scripts/daemon_start_verification.integration.test.mjs +296 -0
  83. package/scripts/dev.mjs +545 -0
  84. package/scripts/doctor.mjs +340 -0
  85. package/scripts/doctor_cmd.test.mjs +22 -0
  86. package/scripts/doctor_ui_index_missing.test.mjs +37 -0
  87. package/scripts/eas.mjs +367 -0
  88. package/scripts/eas_platform_parsing.test.mjs +63 -0
  89. package/scripts/edison.mjs +1848 -0
  90. package/scripts/env.mjs +149 -0
  91. package/scripts/env_cmd.test.mjs +118 -0
  92. package/scripts/exit_cleanup_kills_detached_children_on_crash.integration.test.mjs +80 -0
  93. package/scripts/happier.mjs +82 -0
  94. package/scripts/import.mjs +1327 -0
  95. package/scripts/init.mjs +464 -0
  96. package/scripts/install.mjs +550 -0
  97. package/scripts/lint.mjs +177 -0
  98. package/scripts/menubar.mjs +202 -0
  99. package/scripts/migrate.mjs +318 -0
  100. package/scripts/mobile.mjs +353 -0
  101. package/scripts/mobile_dev_client.mjs +87 -0
  102. package/scripts/monorepo.mjs +2234 -0
  103. package/scripts/monorepo_port.apply.integration.test.mjs +680 -0
  104. package/scripts/monorepo_port.conflicts.integration.test.mjs +454 -0
  105. package/scripts/monorepo_port.validation.integration.test.mjs +486 -0
  106. package/scripts/orchestrated_stack_auth_flow.test.mjs +134 -0
  107. package/scripts/orchestrated_stack_auth_flow_resolve_port.test.mjs +98 -0
  108. package/scripts/orchestrated_stack_auth_flow_webapp_url.test.mjs +119 -0
  109. package/scripts/pack.mjs +257 -0
  110. package/scripts/pack.test.mjs +68 -0
  111. package/scripts/pglite_lock.integration.test.mjs +152 -0
  112. package/scripts/provision/linux-ubuntu-e2e.sh +132 -0
  113. package/scripts/provision/linux-ubuntu-review-pr.sh +66 -0
  114. package/scripts/provision/macos-lima-happy-vm.sh +192 -0
  115. package/scripts/provision/macos-lima-hstack-e2e.sh +100 -0
  116. package/scripts/release.mjs +53 -0
  117. package/scripts/release_binary_smoke.integration.test.mjs +159 -0
  118. package/scripts/review.mjs +1752 -0
  119. package/scripts/review_pr.mjs +435 -0
  120. package/scripts/run.mjs +561 -0
  121. package/scripts/run_script_with_stack_env.restart_port_reuse.test.mjs +30 -0
  122. package/scripts/self.mjs +465 -0
  123. package/scripts/self_host.mjs +9 -0
  124. package/scripts/self_host_binary_smoke.integration.test.mjs +94 -0
  125. package/scripts/self_host_runtime.mjs +883 -0
  126. package/scripts/self_host_runtime.test.mjs +82 -0
  127. package/scripts/self_host_systemd.real.integration.test.mjs +367 -0
  128. package/scripts/server_flavor.mjs +148 -0
  129. package/scripts/service.mjs +868 -0
  130. package/scripts/service_mode_help.test.mjs +27 -0
  131. package/scripts/setup.mjs +1324 -0
  132. package/scripts/setup_non_interactive_flag.test.mjs +60 -0
  133. package/scripts/setup_pr.mjs +605 -0
  134. package/scripts/setup_pr_orchestrated_auth_flow_util_import.test.mjs +117 -0
  135. package/scripts/stack/command_arguments.mjs +91 -0
  136. package/scripts/stack/copy_auth_from_stack.mjs +111 -0
  137. package/scripts/stack/delegated_script_commands.mjs +92 -0
  138. package/scripts/stack/help_text.mjs +110 -0
  139. package/scripts/stack/port_reservation.mjs +74 -0
  140. package/scripts/stack/repo_checkout_resolution.mjs +31 -0
  141. package/scripts/stack/run_script_with_stack_env.mjs +634 -0
  142. package/scripts/stack/stack_daemon_command.mjs +219 -0
  143. package/scripts/stack/stack_delegated_help.mjs +81 -0
  144. package/scripts/stack/stack_environment.mjs +151 -0
  145. package/scripts/stack/stack_environment.sanitization.test.mjs +75 -0
  146. package/scripts/stack/stack_happier_passthrough_command.mjs +63 -0
  147. package/scripts/stack/stack_info_snapshot.mjs +167 -0
  148. package/scripts/stack/stack_mobile_install_command.mjs +61 -0
  149. package/scripts/stack/stack_resume_command.mjs +76 -0
  150. package/scripts/stack/stack_stop_command.mjs +34 -0
  151. package/scripts/stack/stack_workspace_command.mjs +83 -0
  152. package/scripts/stack/transient_repo_overrides.mjs +29 -0
  153. package/scripts/stack.mjs +2388 -0
  154. package/scripts/stack_archive_cmd.integration.test.mjs +31 -0
  155. package/scripts/stack_audit_fix_light_env.test.mjs +129 -0
  156. package/scripts/stack_background_pinned_stack_json.test.mjs +81 -0
  157. package/scripts/stack_copy_auth_server_scoped.test.mjs +243 -0
  158. package/scripts/stack_daemon_cmd.integration.test.mjs +484 -0
  159. package/scripts/stack_eas_help.test.mjs +72 -0
  160. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +102 -0
  161. package/scripts/stack_env_cmd.test.mjs +107 -0
  162. package/scripts/stack_guided_login_bundle_error_parse.test.mjs +20 -0
  163. package/scripts/stack_guided_login_inner_invocation.test.mjs +46 -0
  164. package/scripts/stack_happy_cmd.integration.test.mjs +263 -0
  165. package/scripts/stack_info_snapshot_running_status.test.mjs +186 -0
  166. package/scripts/stack_interactive_monorepo_group.test.mjs +128 -0
  167. package/scripts/stack_monorepo_defaults.test.mjs +31 -0
  168. package/scripts/stack_monorepo_repo_dev_token.test.mjs +32 -0
  169. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +37 -0
  170. package/scripts/stack_new_name_normalize_cmd.test.mjs +38 -0
  171. package/scripts/stack_pr_name_normalize_cmd.test.mjs +84 -0
  172. package/scripts/stack_resume_cmd.integration.test.mjs +134 -0
  173. package/scripts/stack_server_flavors_defaults.test.mjs +64 -0
  174. package/scripts/stack_shorthand_cmd.integration.test.mjs +74 -0
  175. package/scripts/stack_stop_sweeps_legacy_infra_without_kind.integration.test.mjs +44 -0
  176. package/scripts/stack_stop_sweeps_when_runtime_missing.integration.test.mjs +42 -0
  177. package/scripts/stack_stop_sweeps_when_runtime_stale.integration.test.mjs +50 -0
  178. package/scripts/stack_wt_list.test.mjs +117 -0
  179. package/scripts/start_ui_required_default.test.mjs +63 -0
  180. package/scripts/stop.mjs +190 -0
  181. package/scripts/stopStackWithEnv_no_autosweep_when_runtime_missing.integration.test.mjs +95 -0
  182. package/scripts/swiftbar_git_monorepo_cmd.test.mjs +75 -0
  183. package/scripts/swiftbar_render_monorepo_wt_actions.integration.test.mjs +116 -0
  184. package/scripts/swiftbar_utils_cmd.test.mjs +92 -0
  185. package/scripts/swiftbar_wt_pr_backcompat.test.mjs +162 -0
  186. package/scripts/systemd_unit_info.test.mjs +24 -0
  187. package/scripts/tailscale.mjs +490 -0
  188. package/scripts/test_ci.mjs +36 -0
  189. package/scripts/test_cmd.mjs +274 -0
  190. package/scripts/test_cmd.test.mjs +133 -0
  191. package/scripts/test_integration.mjs +33 -0
  192. package/scripts/testkit/auth_testkit.mjs +121 -0
  193. package/scripts/testkit/doctor_testkit.mjs +68 -0
  194. package/scripts/testkit/monorepo_port_testkit.mjs +157 -0
  195. package/scripts/testkit/stack_archive_command_testkit.mjs +55 -0
  196. package/scripts/testkit/stack_new_monorepo_testkit.mjs +83 -0
  197. package/scripts/testkit/stack_script_command_testkit.mjs +27 -0
  198. package/scripts/testkit/stack_stop_sweeps_testkit.mjs +172 -0
  199. package/scripts/testkit/worktrees_monorepo_testkit.mjs +53 -0
  200. package/scripts/tools.mjs +70 -0
  201. package/scripts/tui.mjs +914 -0
  202. package/scripts/tui_stopStackForTuiExit_no_autosweep.integration.test.mjs +95 -0
  203. package/scripts/typecheck.mjs +178 -0
  204. package/scripts/ui_gateway.mjs +247 -0
  205. package/scripts/uninstall.mjs +179 -0
  206. package/scripts/utils/auth/credentials_paths.mjs +181 -0
  207. package/scripts/utils/auth/credentials_paths.test.mjs +187 -0
  208. package/scripts/utils/auth/daemon_gate.mjs +66 -0
  209. package/scripts/utils/auth/daemon_gate.test.mjs +116 -0
  210. package/scripts/utils/auth/decode_jwt_payload_unsafe.mjs +16 -0
  211. package/scripts/utils/auth/dev_key.mjs +163 -0
  212. package/scripts/utils/auth/files.mjs +56 -0
  213. package/scripts/utils/auth/guided_pr_auth.mjs +86 -0
  214. package/scripts/utils/auth/guided_stack_web_login.mjs +56 -0
  215. package/scripts/utils/auth/handy_master_secret.mjs +42 -0
  216. package/scripts/utils/auth/interactive_stack_auth.mjs +70 -0
  217. package/scripts/utils/auth/login_ux.mjs +105 -0
  218. package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +291 -0
  219. package/scripts/utils/auth/sources.mjs +28 -0
  220. package/scripts/utils/auth/stable_scope_id.mjs +91 -0
  221. package/scripts/utils/auth/stable_scope_id.test.mjs +51 -0
  222. package/scripts/utils/auth/stack_guided_login.mjs +438 -0
  223. package/scripts/utils/cli/arg_values.mjs +23 -0
  224. package/scripts/utils/cli/arg_values.test.mjs +43 -0
  225. package/scripts/utils/cli/args.mjs +17 -0
  226. package/scripts/utils/cli/cli.mjs +24 -0
  227. package/scripts/utils/cli/cli_registry.mjs +440 -0
  228. package/scripts/utils/cli/cwd_scope.mjs +158 -0
  229. package/scripts/utils/cli/cwd_scope.test.mjs +154 -0
  230. package/scripts/utils/cli/flags.mjs +17 -0
  231. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  232. package/scripts/utils/cli/normalize.mjs +16 -0
  233. package/scripts/utils/cli/prereqs.mjs +103 -0
  234. package/scripts/utils/cli/prereqs.test.mjs +33 -0
  235. package/scripts/utils/cli/progress.mjs +141 -0
  236. package/scripts/utils/cli/smoke_help.mjs +44 -0
  237. package/scripts/utils/cli/verbosity.mjs +11 -0
  238. package/scripts/utils/cli/wizard.mjs +139 -0
  239. package/scripts/utils/cli/wizard_promptSelect.test.mjs +44 -0
  240. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +132 -0
  241. package/scripts/utils/cli/wizard_worktree_slug.test.mjs +33 -0
  242. package/scripts/utils/crypto/tokens.mjs +14 -0
  243. package/scripts/utils/dev/daemon.mjs +232 -0
  244. package/scripts/utils/dev/daemon_watch_resilience.test.mjs +224 -0
  245. package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +35 -0
  246. package/scripts/utils/dev/expo_dev.mjs +478 -0
  247. package/scripts/utils/dev/expo_dev.test.mjs +89 -0
  248. package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +120 -0
  249. package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +60 -0
  250. package/scripts/utils/dev/server.mjs +180 -0
  251. package/scripts/utils/dev_auth_key.mjs +7 -0
  252. package/scripts/utils/edison/git_roots.mjs +30 -0
  253. package/scripts/utils/edison/git_roots.test.mjs +49 -0
  254. package/scripts/utils/env/config.mjs +52 -0
  255. package/scripts/utils/env/dotenv.mjs +32 -0
  256. package/scripts/utils/env/dotenv.test.mjs +32 -0
  257. package/scripts/utils/env/env.mjs +130 -0
  258. package/scripts/utils/env/env_file.mjs +98 -0
  259. package/scripts/utils/env/env_file.test.mjs +49 -0
  260. package/scripts/utils/env/env_local.mjs +25 -0
  261. package/scripts/utils/env/load_env_file.mjs +34 -0
  262. package/scripts/utils/env/read.mjs +30 -0
  263. package/scripts/utils/env/sandbox.mjs +13 -0
  264. package/scripts/utils/env/scrub_env.mjs +69 -0
  265. package/scripts/utils/env/scrub_env.test.mjs +102 -0
  266. package/scripts/utils/env/values.mjs +13 -0
  267. package/scripts/utils/expo/command.mjs +65 -0
  268. package/scripts/utils/expo/expo.mjs +139 -0
  269. package/scripts/utils/expo/expo_state_running.test.mjs +48 -0
  270. package/scripts/utils/expo/metro_ports.mjs +101 -0
  271. package/scripts/utils/expo/metro_ports.test.mjs +35 -0
  272. package/scripts/utils/fs/atomic_dir_swap.mjs +55 -0
  273. package/scripts/utils/fs/atomic_dir_swap.test.mjs +54 -0
  274. package/scripts/utils/fs/file_has_content.mjs +10 -0
  275. package/scripts/utils/fs/fs.mjs +11 -0
  276. package/scripts/utils/fs/json.mjs +25 -0
  277. package/scripts/utils/fs/ops.mjs +29 -0
  278. package/scripts/utils/fs/package_json.mjs +8 -0
  279. package/scripts/utils/fs/tail.mjs +12 -0
  280. package/scripts/utils/git/dev_checkout.mjs +127 -0
  281. package/scripts/utils/git/dev_checkout.test.mjs +115 -0
  282. package/scripts/utils/git/git.mjs +67 -0
  283. package/scripts/utils/git/parse_name_status_z.mjs +21 -0
  284. package/scripts/utils/git/refs.mjs +26 -0
  285. package/scripts/utils/git/worktrees.mjs +323 -0
  286. package/scripts/utils/git/worktrees_monorepo.test.mjs +60 -0
  287. package/scripts/utils/git/worktrees_pathstyle.test.mjs +53 -0
  288. package/scripts/utils/llm/assist.mjs +260 -0
  289. package/scripts/utils/llm/codex_exec.mjs +61 -0
  290. package/scripts/utils/llm/codex_exec.test.mjs +46 -0
  291. package/scripts/utils/llm/hstack_runner.mjs +59 -0
  292. package/scripts/utils/llm/tools.mjs +56 -0
  293. package/scripts/utils/llm/tools.test.mjs +67 -0
  294. package/scripts/utils/menubar/swiftbar.mjs +121 -0
  295. package/scripts/utils/menubar/swiftbar.test.mjs +85 -0
  296. package/scripts/utils/mobile/config.mjs +35 -0
  297. package/scripts/utils/mobile/dev_client_links.mjs +59 -0
  298. package/scripts/utils/mobile/identifiers.mjs +46 -0
  299. package/scripts/utils/mobile/identifiers.test.mjs +41 -0
  300. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  301. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +131 -0
  302. package/scripts/utils/net/bind_mode.mjs +39 -0
  303. package/scripts/utils/net/dns.mjs +10 -0
  304. package/scripts/utils/net/lan_ip.mjs +24 -0
  305. package/scripts/utils/net/ports.mjs +110 -0
  306. package/scripts/utils/net/tcp_forward.mjs +162 -0
  307. package/scripts/utils/net/url.mjs +30 -0
  308. package/scripts/utils/net/url.test.mjs +29 -0
  309. package/scripts/utils/paths/canonical_home.mjs +15 -0
  310. package/scripts/utils/paths/canonical_home.test.mjs +28 -0
  311. package/scripts/utils/paths/localhost_host.mjs +112 -0
  312. package/scripts/utils/paths/localhost_host.test.mjs +58 -0
  313. package/scripts/utils/paths/paths.mjs +302 -0
  314. package/scripts/utils/paths/paths_env_win32.test.mjs +36 -0
  315. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  316. package/scripts/utils/paths/paths_server_flavors.test.mjs +50 -0
  317. package/scripts/utils/paths/runtime.mjs +41 -0
  318. package/scripts/utils/pglite_lock.mjs +107 -0
  319. package/scripts/utils/proc/commands.mjs +33 -0
  320. package/scripts/utils/proc/exit_cleanup.mjs +57 -0
  321. package/scripts/utils/proc/happy_monorepo_deps.mjs +37 -0
  322. package/scripts/utils/proc/happy_monorepo_deps.test.mjs +89 -0
  323. package/scripts/utils/proc/ownership.mjs +217 -0
  324. package/scripts/utils/proc/ownership_killProcessGroupOwnedByStack.test.mjs +216 -0
  325. package/scripts/utils/proc/ownership_listPidsWithEnvNeedles.test.mjs +88 -0
  326. package/scripts/utils/proc/package_scripts.mjs +38 -0
  327. package/scripts/utils/proc/package_scripts.test.mjs +58 -0
  328. package/scripts/utils/proc/parallel.mjs +25 -0
  329. package/scripts/utils/proc/pids.mjs +11 -0
  330. package/scripts/utils/proc/pm.mjs +478 -0
  331. package/scripts/utils/proc/pm_spawn.integration.test.mjs +131 -0
  332. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +313 -0
  333. package/scripts/utils/proc/proc.mjs +331 -0
  334. package/scripts/utils/proc/proc.test.mjs +85 -0
  335. package/scripts/utils/proc/terminate.mjs +69 -0
  336. package/scripts/utils/proc/terminate.test.mjs +54 -0
  337. package/scripts/utils/proc/watch.mjs +63 -0
  338. package/scripts/utils/review/augment_runner_integration.test.mjs +105 -0
  339. package/scripts/utils/review/base_ref.mjs +82 -0
  340. package/scripts/utils/review/base_ref.test.mjs +89 -0
  341. package/scripts/utils/review/chunks.mjs +55 -0
  342. package/scripts/utils/review/chunks.test.mjs +107 -0
  343. package/scripts/utils/review/detached_worktree.mjs +61 -0
  344. package/scripts/utils/review/detached_worktree.test.mjs +61 -0
  345. package/scripts/utils/review/findings.mjs +278 -0
  346. package/scripts/utils/review/findings.test.mjs +203 -0
  347. package/scripts/utils/review/head_slice.mjs +132 -0
  348. package/scripts/utils/review/head_slice.test.mjs +117 -0
  349. package/scripts/utils/review/instructions/deep.md +20 -0
  350. package/scripts/utils/review/prompts.mjs +279 -0
  351. package/scripts/utils/review/prompts.test.mjs +77 -0
  352. package/scripts/utils/review/run_reviewers_safe.mjs +12 -0
  353. package/scripts/utils/review/run_reviewers_safe.test.mjs +45 -0
  354. package/scripts/utils/review/runners/augment.mjs +91 -0
  355. package/scripts/utils/review/runners/augment.test.mjs +64 -0
  356. package/scripts/utils/review/runners/claude.mjs +92 -0
  357. package/scripts/utils/review/runners/claude.test.mjs +47 -0
  358. package/scripts/utils/review/runners/coderabbit.mjs +105 -0
  359. package/scripts/utils/review/runners/coderabbit.test.mjs +32 -0
  360. package/scripts/utils/review/runners/codex.mjs +129 -0
  361. package/scripts/utils/review/runners/codex.test.mjs +115 -0
  362. package/scripts/utils/review/slice_mode.mjs +20 -0
  363. package/scripts/utils/review/slice_mode.test.mjs +69 -0
  364. package/scripts/utils/review/sliced_runner.mjs +39 -0
  365. package/scripts/utils/review/sliced_runner.test.mjs +57 -0
  366. package/scripts/utils/review/slices.mjs +140 -0
  367. package/scripts/utils/review/slices.test.mjs +41 -0
  368. package/scripts/utils/review/targets.mjs +23 -0
  369. package/scripts/utils/review/targets.test.mjs +31 -0
  370. package/scripts/utils/review/tool_home_seed.mjs +106 -0
  371. package/scripts/utils/review/tool_home_seed.test.mjs +124 -0
  372. package/scripts/utils/review/uncommitted_ops.mjs +77 -0
  373. package/scripts/utils/review/uncommitted_ops.test.mjs +117 -0
  374. package/scripts/utils/sandbox/review_pr_sandbox.mjs +105 -0
  375. package/scripts/utils/server/apply_server_light_env_defaults.mjs +14 -0
  376. package/scripts/utils/server/flavor_scripts.mjs +138 -0
  377. package/scripts/utils/server/flavor_scripts.test.mjs +115 -0
  378. package/scripts/utils/server/infra/happy_server_infra.mjs +444 -0
  379. package/scripts/utils/server/mobile_api_url.mjs +60 -0
  380. package/scripts/utils/server/mobile_api_url.test.mjs +58 -0
  381. package/scripts/utils/server/port.mjs +55 -0
  382. package/scripts/utils/server/prisma_import.mjs +36 -0
  383. package/scripts/utils/server/prisma_import.test.mjs +78 -0
  384. package/scripts/utils/server/server.mjs +109 -0
  385. package/scripts/utils/server/ui_build_check.mjs +37 -0
  386. package/scripts/utils/server/ui_build_check.test.mjs +70 -0
  387. package/scripts/utils/server/ui_env.mjs +13 -0
  388. package/scripts/utils/server/ui_env.test.mjs +57 -0
  389. package/scripts/utils/server/urls.mjs +100 -0
  390. package/scripts/utils/server/validate.mjs +60 -0
  391. package/scripts/utils/server/validate.test.mjs +76 -0
  392. package/scripts/utils/service/autostart_darwin.mjs +198 -0
  393. package/scripts/utils/service/autostart_darwin.test.mjs +49 -0
  394. package/scripts/utils/service/autostart_darwin_keepalive.test.mjs +19 -0
  395. package/scripts/utils/stack/cli_identities.mjs +29 -0
  396. package/scripts/utils/stack/context.mjs +19 -0
  397. package/scripts/utils/stack/dirs.mjs +26 -0
  398. package/scripts/utils/stack/editor_workspace.mjs +126 -0
  399. package/scripts/utils/stack/interactive_stack_config.mjs +266 -0
  400. package/scripts/utils/stack/interactive_stack_config.port_validation.test.mjs +93 -0
  401. package/scripts/utils/stack/interactive_stack_config.remote_validation.test.mjs +122 -0
  402. package/scripts/utils/stack/interactive_stack_config.stack_name_validation.test.mjs +76 -0
  403. package/scripts/utils/stack/interactive_stack_config_testkit.mjs +18 -0
  404. package/scripts/utils/stack/names.mjs +27 -0
  405. package/scripts/utils/stack/names.test.mjs +26 -0
  406. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  407. package/scripts/utils/stack/runtime_state.mjs +88 -0
  408. package/scripts/utils/stack/stacks.mjs +40 -0
  409. package/scripts/utils/stack/startup.mjs +370 -0
  410. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +119 -0
  411. package/scripts/utils/stack/startup_server_light_generate.test.mjs +20 -0
  412. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +79 -0
  413. package/scripts/utils/stack/startup_server_light_testkit.mjs +106 -0
  414. package/scripts/utils/stack/stop.mjs +284 -0
  415. package/scripts/utils/stack_context.mjs +1 -0
  416. package/scripts/utils/stack_runtime_state.mjs +1 -0
  417. package/scripts/utils/stacks.mjs +1 -0
  418. package/scripts/utils/tailscale/ip.mjs +116 -0
  419. package/scripts/utils/tauri/stack_overrides.mjs +22 -0
  420. package/scripts/utils/test/collect_test_files.mjs +29 -0
  421. package/scripts/utils/time/get_today_ymd.mjs +7 -0
  422. package/scripts/utils/tui/cleanup.mjs +38 -0
  423. package/scripts/utils/ui/ansi.mjs +47 -0
  424. package/scripts/utils/ui/browser.mjs +31 -0
  425. package/scripts/utils/ui/browser.test.mjs +56 -0
  426. package/scripts/utils/ui/clipboard.mjs +38 -0
  427. package/scripts/utils/ui/layout.mjs +44 -0
  428. package/scripts/utils/ui/qr.mjs +17 -0
  429. package/scripts/utils/ui/terminal_launcher.mjs +129 -0
  430. package/scripts/utils/ui/text.mjs +16 -0
  431. package/scripts/utils/update/auto_update_notice.mjs +93 -0
  432. package/scripts/utils/validate.mjs +5 -0
  433. package/scripts/where.mjs +138 -0
  434. package/scripts/worktrees.mjs +2174 -0
  435. package/scripts/worktrees_archive_cmd.integration.test.mjs +228 -0
  436. package/scripts/worktrees_cursor_monorepo_root.test.mjs +23 -0
  437. package/scripts/worktrees_list_specs_no_recurse.test.mjs +32 -0
  438. package/scripts/worktrees_monorepo_testkit.test.mjs +29 -0
  439. package/scripts/worktrees_monorepo_use_group.test.mjs +41 -0
@@ -0,0 +1,2234 @@
1
+ import './utils/env/env.mjs';
2
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+
6
+ import { parseArgs } from './utils/cli/args.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
+ import { pathExists } from './utils/fs/fs.mjs';
9
+ import { run, runCapture } from './utils/proc/proc.mjs';
10
+ import { happyMonorepoSubdirForComponent, isHappyMonorepoRoot } from './utils/paths/paths.mjs';
11
+ import { parseGithubPullRequest } from './utils/git/refs.mjs';
12
+ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
13
+ import { bold, cyan, dim, green, red, yellow } from './utils/ui/ansi.mjs';
14
+ import { clipboardAvailable, copyTextToClipboard } from './utils/ui/clipboard.mjs';
15
+ import { detectInstalledLlmTools } from './utils/llm/tools.mjs';
16
+ import { launchLlmAssistant } from './utils/llm/assist.mjs';
17
+ import { buildhstackRunnerShellSnippet } from './utils/llm/hstack_runner.mjs';
18
+
19
+ function usage() {
20
+ return [
21
+ '[monorepo] usage:',
22
+ ' hstack monorepo port --target=/abs/path/to/monorepo [--clone-target] [--target-repo=<git-url>] [--branch=port/<name>] [--base=<ref>] [--onto-current] [--dry-run] [--3way] [--skip-applied] [--continue-on-failure] [--json]',
23
+ ' hstack monorepo port guide [--target=/abs/path/to/monorepo] [--clone-target] [--target-repo=<git-url>] [--json]',
24
+ ' hstack monorepo port preflight --target=/abs/path/to/monorepo [--base=<ref>] [--3way] [--json]',
25
+ ' hstack monorepo port status [--target=/abs/path/to/monorepo] [--json]',
26
+ ' hstack monorepo port continue [--target=/abs/path/to/monorepo] [--json]',
27
+ ' hstack monorepo port llm --target=/abs/path/to/monorepo [--copy] [--launch] [--json]',
28
+ ' [--from-happy=/abs/path/to/old-happy --from-happy-base=<ref> --from-happy-ref=<ref>]',
29
+ ' [--from-happy-cli=/abs/path/to/old-happy-cli --from-happy-cli-base=<ref> --from-happy-cli-ref=<ref>]',
30
+ ' [--from-happy-server=/abs/path/to/old-happy-server --from-happy-server-base=<ref> --from-happy-server-ref=<ref>]',
31
+ '',
32
+ 'what it does:',
33
+ '- Best-effort ports commits from split repos into the Happier monorepo layout by applying patches into:',
34
+ ' - old happy (UI) -> apps/ui/ (or legacy: expo-app/)',
35
+ ' - old happy-cli (CLI) -> apps/cli/ (or legacy: cli/)',
36
+ ' - old happy-server -> apps/server/ (or legacy: server/)',
37
+ '',
38
+ 'notes:',
39
+ '- This preserves commit messages/authors (via `git format-patch` + `git am`).',
40
+ '- The target monorepo should already contain the "base" version of each subtree (typically a clean checkout of upstream/main).',
41
+ '- Already-applied patches are auto-skipped when detected (exact-match via reverse apply-check).',
42
+ '- Identical \"new file\" patches are auto-skipped when the target already contains the same file content.',
43
+ '- Conflicts may require manual resolution. If `git am` stops, fix conflicts then run:',
44
+ ' git am --continue',
45
+ ' or abort with:',
46
+ ' git am --abort',
47
+ '',
48
+ 'LLM tip:',
49
+ '- If you want an LLM to help resolve conflicts, run:',
50
+ ' hstack monorepo port llm --target=/abs/path/to/monorepo --launch',
51
+ ' or, if you prefer copy/paste:',
52
+ ' hstack monorepo port llm --target=/abs/path/to/monorepo --copy',
53
+ ' then paste the copied prompt into your LLM.',
54
+ ].join('\n');
55
+ }
56
+
57
+ async function git(cwd, args, options = {}) {
58
+ return await runCapture('git', args, { cwd, ...options });
59
+ }
60
+
61
+ async function gitOk(cwd, args) {
62
+ try {
63
+ await git(cwd, args);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ async function withTempDetachedWorktree({ repoRoot, ref, label }, fn) {
71
+ const root = await resolveGitRoot(repoRoot);
72
+ if (!root) throw new Error('[monorepo] failed to resolve git root for worktree');
73
+ const safeLabel = String(label ?? 'worktree')
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9._-]+/g, '-')
76
+ .replace(/^-+|-+$/g, '');
77
+ const tmp = await mkdtemp(join(tmpdir(), `happy-stacks-${safeLabel}-`));
78
+ const dir = join(tmp, 'wt');
79
+ const r = String(ref ?? '').trim();
80
+ if (!r) throw new Error('[monorepo] missing worktree ref');
81
+ try {
82
+ await runCapture('git', ['worktree', 'add', '--detach', dir, r], { cwd: root });
83
+ return await fn(dir);
84
+ } finally {
85
+ try {
86
+ await runCapture('git', ['worktree', 'remove', '--force', dir], { cwd: root });
87
+ await runCapture('git', ['worktree', 'prune'], { cwd: root });
88
+ } catch {
89
+ // ignore
90
+ }
91
+ await rm(tmp, { recursive: true, force: true }).catch(() => {});
92
+ }
93
+ }
94
+
95
+ async function resolveGitRoot(dir) {
96
+ const d = resolve(String(dir ?? '').trim());
97
+ if (!d) return '';
98
+ try {
99
+ return (await git(d, ['rev-parse', '--show-toplevel'])).trim();
100
+ } catch {
101
+ return '';
102
+ }
103
+ }
104
+
105
+ async function ensureCleanGitWorktree(repoRoot) {
106
+ const dirty = (await git(repoRoot, ['status', '--porcelain'])).trim();
107
+ if (dirty) {
108
+ throw new Error(`[monorepo] target repo is not clean: ${repoRoot}\n[monorepo] fix: commit/stash changes and re-run`);
109
+ }
110
+ }
111
+
112
+ async function ensureNoGitAmInProgress(repoRoot) {
113
+ try {
114
+ const rel = (await git(repoRoot, ['rev-parse', '--git-path', 'rebase-apply'])).trim();
115
+ if (!rel) return;
116
+ const p = rel.startsWith('/') ? rel : join(repoRoot, rel);
117
+ if (!(await pathExists(p))) return;
118
+ if ((await pathExists(join(p, 'applying'))) || (await pathExists(join(p, 'patch')))) {
119
+ throw new Error(
120
+ [
121
+ '[monorepo] a git am operation is already in progress in the target repo.',
122
+ '[monorepo] fix: resolve it first, then re-run.',
123
+ `- continue: git -C ${repoRoot} am --continue`,
124
+ `- abort: git -C ${repoRoot} am --abort`,
125
+ ].join('\n')
126
+ );
127
+ }
128
+ } catch (err) {
129
+ // If git isn't happy with --git-path for some reason, fail open; the later git am will fail anyway.
130
+ if (String(err?.message ?? '').includes('a git am operation is already in progress')) throw err;
131
+ }
132
+ }
133
+
134
+ async function isGitAmInProgress(repoRoot) {
135
+ try {
136
+ const rel = (await git(repoRoot, ['rev-parse', '--git-path', 'rebase-apply'])).trim();
137
+ if (!rel) return false;
138
+ const p = rel.startsWith('/') ? rel : join(repoRoot, rel);
139
+ if (!(await pathExists(p))) return false;
140
+ if ((await pathExists(join(p, 'applying'))) || (await pathExists(join(p, 'patch')))) return true;
141
+ return false;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ async function ensureBranch(repoRoot, branch) {
148
+ const b = String(branch ?? '').trim();
149
+ if (!b) return;
150
+ const exists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${b}`]);
151
+ if (exists) {
152
+ throw new Error(`[monorepo] target branch already exists: ${b}\n[monorepo] fix: pick a new --branch name`);
153
+ }
154
+ await git(repoRoot, ['checkout', '-b', b]);
155
+ }
156
+
157
+ async function resolveDefaultBaseRef(sourceRepoRoot) {
158
+ const candidates = ['upstream/main', 'origin/main', 'main', 'master'];
159
+ for (const c of candidates) {
160
+ if (await gitOk(sourceRepoRoot, ['rev-parse', '--verify', '--quiet', c])) {
161
+ return c;
162
+ }
163
+ }
164
+ return '';
165
+ }
166
+
167
+ async function resolveDefaultTargetBaseRef(targetRepoRoot) {
168
+ try {
169
+ const sym = (await git(targetRepoRoot, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])).trim();
170
+ const m = /^refs\/remotes\/origin\/(.+)$/.exec(sym);
171
+ if (m?.[1]) {
172
+ const ref = `origin/${m[1]}`;
173
+ if (await gitOk(targetRepoRoot, ['rev-parse', '--verify', '--quiet', ref])) {
174
+ return ref;
175
+ }
176
+ }
177
+ } catch {
178
+ // ignore
179
+ }
180
+ return await resolveDefaultBaseRef(targetRepoRoot);
181
+ }
182
+
183
+ async function resolveTargetRepoRootFromArgs({ kv }) {
184
+ const target = (kv.get('--target') ?? '').trim();
185
+ const targetHint = target || process.cwd();
186
+ const repoRoot = await resolveGitRoot(targetHint);
187
+ if (!repoRoot) {
188
+ throw new Error(`[monorepo] target is not a git repo: ${targetHint}`);
189
+ }
190
+ if (!isHappyMonorepoRoot(repoRoot)) {
191
+ throw new Error(
192
+ `[monorepo] target does not look like a Happier monorepo root ` +
193
+ `(missing apps/ui|apps/cli|apps/server or legacy expo-app/cli/server): ${repoRoot}`
194
+ );
195
+ }
196
+ return repoRoot;
197
+ }
198
+
199
+ function looksLikeUrlSpec(spec) {
200
+ const s = String(spec ?? '').trim();
201
+ if (!s) return false;
202
+ if (/^[a-z]+:\/\//i.test(s)) return true; // https://, file://, ssh://, etc
203
+ if (/^git@[^:]+:/.test(s)) return true; // git@github.com:owner/repo.git
204
+ if (/^github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(s)) return true;
205
+ return false;
206
+ }
207
+
208
+ function looksLikeGithubPullUrl(spec) {
209
+ const s = String(spec ?? '').trim();
210
+ return s.includes('github.com/') && s.includes('/pull/');
211
+ }
212
+
213
+ function safeSlug(s, { maxLen = 80 } = {}) {
214
+ return String(s ?? '')
215
+ .trim()
216
+ .toLowerCase()
217
+ .replace(/[^a-z0-9._-]+/g, '-')
218
+ .replace(/-+/g, '-')
219
+ .replace(/^-+|-+$/g, '')
220
+ .slice(0, maxLen || 80);
221
+ }
222
+
223
+ async function isEmptyDir(dir) {
224
+ try {
225
+ const entries = await readdir(dir);
226
+ return entries.length === 0;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ function gitNonInteractiveEnv() {
233
+ return {
234
+ ...process.env,
235
+ GIT_TERMINAL_PROMPT: '0',
236
+ };
237
+ }
238
+
239
+ async function resolvePortScratchDir(targetRepoRoot, rel) {
240
+ const p = await resolveGitPath(targetRepoRoot, rel);
241
+ if (!p) return '';
242
+ await mkdir(dirname(p), { recursive: true });
243
+ return p;
244
+ }
245
+
246
+ async function ensureClonedHappyMonorepo({ targetPath, repoUrl }) {
247
+ const dest = String(targetPath ?? '').trim();
248
+ if (!dest) throw new Error('[monorepo] clone-target: missing --target=<dir>');
249
+ const url = String(repoUrl ?? '').trim() || 'https://github.com/happier-dev/happier.git';
250
+
251
+ const exists = await pathExists(dest);
252
+ if (exists) {
253
+ // `git clone` refuses to clone into an existing directory. Allow deleting if empty.
254
+ if (!(await isEmptyDir(dest))) {
255
+ throw new Error(`[monorepo] clone-target: target exists and is not empty: ${dest}`);
256
+ }
257
+ await rm(dest, { recursive: true, force: true }).catch(() => {});
258
+ }
259
+ await mkdir(dirname(dest), { recursive: true });
260
+
261
+ await runCapture('git', ['clone', '--quiet', url, dest], { cwd: dirname(dest), env: gitNonInteractiveEnv() });
262
+ return dest;
263
+ }
264
+
265
+ async function resolveOrCloneTargetRepoRoot({ targetInput, targetArg, flags, kv, progress } = {}) {
266
+ const hint = String(targetInput ?? '').trim();
267
+ if (!hint) throw new Error('[monorepo] missing target');
268
+
269
+ const repoRoot = await resolveGitRoot(hint);
270
+ if (repoRoot) {
271
+ if (!isHappyMonorepoRoot(repoRoot)) {
272
+ throw new Error(
273
+ `[monorepo] target does not look like a Happier monorepo root ` +
274
+ `(missing apps/ui|apps/cli|apps/server or legacy expo-app/cli/server): ${repoRoot}`
275
+ );
276
+ }
277
+ return repoRoot;
278
+ }
279
+
280
+ // Not a git repo. If it doesn't exist and clone is requested, clone into it.
281
+ const exists = await pathExists(hint);
282
+ const wantsClone = flags?.has?.('--clone-target') || flags?.has?.('--clone');
283
+ if (!exists) {
284
+ if (!wantsClone) {
285
+ throw new Error(
286
+ `[monorepo] target does not exist: ${hint}\n` +
287
+ `[monorepo] tip: create it (git clone) or re-run with: --clone-target --target-repo=<git-url>`
288
+ );
289
+ }
290
+ if (!String(targetArg ?? '').trim()) {
291
+ throw new Error('[monorepo] --clone-target requires an explicit --target=<dir>');
292
+ }
293
+ const targetRepo = String(kv?.get?.('--target-repo') ?? '').trim();
294
+ const spin = progress?.spinner?.(`Cloning target monorepo into ${hint}`);
295
+ const cloned = await ensureClonedHappyMonorepo({ targetPath: hint, repoUrl: targetRepo || 'https://github.com/happier-dev/happier.git' });
296
+ spin?.succeed?.(`Cloned target monorepo (${hint})`);
297
+ const clonedRoot = await resolveGitRoot(cloned);
298
+ if (!clonedRoot || !isHappyMonorepoRoot(clonedRoot)) {
299
+ throw new Error(`[monorepo] cloned target does not look like a Happier monorepo root: ${cloned}`);
300
+ }
301
+ return clonedRoot;
302
+ }
303
+
304
+ // Exists but isn't a git repo.
305
+ throw new Error(`[monorepo] target is not a git repo: ${hint}`);
306
+ }
307
+
308
+ async function ensureRepoSpecCheckedOut({ targetRepoRoot, label, spec, desiredRef = '', progress } = {}) {
309
+ const raw = String(spec ?? '').trim();
310
+ if (!raw) return '';
311
+
312
+ // Local path fast path.
313
+ if (await pathExists(raw)) {
314
+ return raw;
315
+ }
316
+
317
+ if (!looksLikeUrlSpec(raw)) {
318
+ throw new Error(`[monorepo] ${label}: source path does not exist: ${raw}`);
319
+ }
320
+
321
+ const scratch = await resolvePortScratchDir(targetRepoRoot, 'happy-stacks/monorepo-port-sources');
322
+ if (!scratch) throw new Error('[monorepo] failed to resolve port scratch dir');
323
+
324
+ // GitHub PR URL: clone repo and fetch PR head into a detached checkout.
325
+ if (looksLikeGithubPullUrl(raw)) {
326
+ const pr = parseGithubPullRequest(raw);
327
+ if (!pr?.number || !pr.owner || !pr.repo) {
328
+ throw new Error(`[monorepo] ${label}: unable to parse GitHub PR URL: ${raw}`);
329
+ }
330
+ const repoUrl = `https://github.com/${pr.owner}/${pr.repo}.git`;
331
+ const key = safeSlug(`gh-${pr.owner}-${pr.repo}-pr-${pr.number}`, { maxLen: 90 }) || `pr-${pr.number}`;
332
+ const dir = join(scratch, `${label}-${key}`);
333
+
334
+ if (!(await pathExists(dir))) {
335
+ await mkdir(dirname(dir), { recursive: true });
336
+ const spin = progress?.spinner?.(`Cloning ${label} PR repo (${pr.owner}/${pr.repo}#${pr.number})`);
337
+ await runCapture('git', ['clone', '--quiet', repoUrl, dir], { cwd: dirname(dir), env: gitNonInteractiveEnv() });
338
+ spin?.succeed?.(`Cloned ${label} PR repo (${pr.owner}/${pr.repo}#${pr.number})`);
339
+ }
340
+
341
+ const prRef = `refs/pull/${pr.number}/head`;
342
+ const spinFetch = progress?.spinner?.(`Fetching ${label} PR head (${prRef})`);
343
+ await runCapture('git', ['fetch', '--quiet', 'origin', prRef], { cwd: dir, env: gitNonInteractiveEnv() });
344
+ await runCapture('git', ['checkout', '--quiet', 'FETCH_HEAD'], { cwd: dir, env: gitNonInteractiveEnv() });
345
+ spinFetch?.succeed?.(`Checked out ${label} PR head`);
346
+ return dir;
347
+ }
348
+
349
+ // Generic repo URL/path-like spec: clone it.
350
+ const key = safeSlug(raw, { maxLen: 90 }) || `${label}-${Date.now()}`;
351
+ const dir = join(scratch, `${label}-${key}`);
352
+ if (!(await pathExists(dir))) {
353
+ await mkdir(dirname(dir), { recursive: true });
354
+ const spin = progress?.spinner?.(`Cloning ${label} source repo`);
355
+ await runCapture('git', ['clone', '--quiet', raw, dir], { cwd: dirname(dir), env: gitNonInteractiveEnv() });
356
+ spin?.succeed?.(`Cloned ${label} source repo`);
357
+ }
358
+
359
+ // Best-effort: ensure the desired ref exists (if provided).
360
+ const ref = String(desiredRef ?? '').trim();
361
+ if (ref) {
362
+ const ok = await gitOk(dir, ['rev-parse', '--verify', '--quiet', ref]);
363
+ if (!ok) {
364
+ const spin = progress?.spinner?.(`Fetching ${label} ref (${ref})`);
365
+ await git(dir, ['fetch', '--quiet', 'origin', ref]).catch(() => {});
366
+ spin?.succeed?.(`Fetched ${label} ref (${ref})`);
367
+ }
368
+ }
369
+ return dir;
370
+ }
371
+
372
+ async function resolveGitPath(repoRoot, relPath) {
373
+ const rel = (await git(repoRoot, ['rev-parse', '--git-path', relPath])).trim();
374
+ if (!rel) return '';
375
+ return rel.startsWith('/') ? rel : join(repoRoot, rel);
376
+ }
377
+
378
+ function isTestTty() {
379
+ return String(process.env.HAPPIER_STACK_TEST_TTY ?? '').trim() === '1';
380
+ }
381
+
382
+ function shouldShowProgress({ json, silent = false } = {}) {
383
+ if (silent) return false;
384
+ if (json) return false;
385
+ return true;
386
+ }
387
+
388
+ function createProgressReporter({ enabled, label = '[monorepo]' } = {}) {
389
+ const on = Boolean(enabled);
390
+ const canSpin = on && isTty() && !isTestTty();
391
+ const frames = ['|', '/', '-', '\\'];
392
+
393
+ const line = (s) => {
394
+ // eslint-disable-next-line no-console
395
+ console.log(s);
396
+ };
397
+
398
+ const spinner = (text) => {
399
+ const msg = String(text ?? '').trim();
400
+ if (!on) {
401
+ return {
402
+ update: () => {},
403
+ succeed: () => {},
404
+ fail: () => {},
405
+ };
406
+ }
407
+
408
+ if (!canSpin) {
409
+ line(`${dim(label)} ${msg}`);
410
+ return {
411
+ update: () => {},
412
+ succeed: (doneText) => {
413
+ const done = String(doneText ?? '').trim();
414
+ if (done) line(`${green('✓')} ${done}`);
415
+ },
416
+ fail: (failText) => {
417
+ const fail = String(failText ?? '').trim();
418
+ if (fail) line(`${yellow('!')} ${fail}`);
419
+ },
420
+ };
421
+ }
422
+
423
+ let idx = 0;
424
+ let current = msg;
425
+ let active = true;
426
+
427
+ const render = () => {
428
+ if (!active) return;
429
+ const f = frames[idx % frames.length];
430
+ idx += 1;
431
+ try {
432
+ process.stdout.write(`\r${dim(label)} ${current} ${dim(f)} `);
433
+ } catch {
434
+ // ignore
435
+ }
436
+ };
437
+
438
+ // Initial render + keepalive.
439
+ render();
440
+ const t = setInterval(render, 120);
441
+
442
+ const stop = () => {
443
+ active = false;
444
+ try {
445
+ clearInterval(t);
446
+ } catch {
447
+ // ignore
448
+ }
449
+ try {
450
+ process.stdout.write('\r' + ' '.repeat(Math.min(140, current.length + String(label).length + 16)) + '\r');
451
+ } catch {
452
+ // ignore
453
+ }
454
+ };
455
+
456
+ return {
457
+ update: (nextText) => {
458
+ current = String(nextText ?? '').trim() || current;
459
+ render();
460
+ },
461
+ succeed: (doneText) => {
462
+ stop();
463
+ const done = String(doneText ?? '').trim();
464
+ if (done) line(`${green('✓')} ${done}`);
465
+ },
466
+ fail: (failText) => {
467
+ stop();
468
+ const fail = String(failText ?? '').trim();
469
+ if (fail) line(`${yellow('!')} ${fail}`);
470
+ },
471
+ };
472
+ };
473
+
474
+ return { spinner, line };
475
+ }
476
+
477
+ function section(title) {
478
+ // eslint-disable-next-line no-console
479
+ console.log('');
480
+ // eslint-disable-next-line no-console
481
+ console.log(bold(title));
482
+ }
483
+
484
+ function noteLine(s) {
485
+ // eslint-disable-next-line no-console
486
+ console.log(dim(s));
487
+ }
488
+
489
+ function summarizePreflightFailures(preflight) {
490
+ const fc = preflight?.firstConflict ?? null;
491
+ if (fc?.currentPatch?.subject) {
492
+ const files = Array.isArray(fc.currentPatch.files) ? fc.currentPatch.files : [];
493
+ const conflictFiles = Array.isArray(fc.conflictedFiles) ? fc.conflictedFiles : [];
494
+ const lines = [];
495
+ lines.push(`- first failing patch: ${cyan(fc.currentPatch.subject)}`);
496
+ if (files.length) {
497
+ lines.push(` - patch files: ${files.slice(0, 6).join(', ')}${files.length > 6 ? dim(', ...') : ''}`);
498
+ }
499
+ if (conflictFiles.length) {
500
+ lines.push(` - conflicted files: ${conflictFiles.slice(0, 6).join(', ')}${conflictFiles.length > 6 ? dim(', ...') : ''}`);
501
+ }
502
+ return lines;
503
+ }
504
+
505
+ // Fallback (older shape): summarize per-source failures if present.
506
+ const results = Array.isArray(preflight?.results) ? preflight.results : [];
507
+ const lines = [];
508
+ for (const r of results) {
509
+ const failed = r?.report?.failed ?? [];
510
+ if (!Array.isArray(failed) || failed.length === 0) continue;
511
+ const label = String(r.label ?? '').trim() || 'source';
512
+ const first = failed[0] ?? null;
513
+ if (!first) continue;
514
+
515
+ const subj = String(first.subject ?? '').replace(/^\[PATCH \d+\/\d+\]\s*/, '');
516
+ const kind = first.kind ? ` (${first.kind})` : '';
517
+ const paths = (first.paths ?? []).slice(0, 4).join(', ');
518
+
519
+ lines.push(`- ${cyan(label)}: first failing patch`);
520
+ lines.push(` - ${subj || first.patch}${kind}${paths ? ` → ${paths}` : ''}`);
521
+ }
522
+ return lines;
523
+ }
524
+
525
+ async function resolvePortPlanPath(targetRepoRoot) {
526
+ return await resolveGitPath(targetRepoRoot, 'happy-stacks/monorepo-port-plan.json');
527
+ }
528
+
529
+ async function writePortPlan(targetRepoRoot, plan) {
530
+ const p = await resolvePortPlanPath(targetRepoRoot);
531
+ if (!p) return '';
532
+ await mkdir(dirname(p), { recursive: true });
533
+ await writeFile(p, JSON.stringify(plan ?? null, null, 2) + '\n', 'utf-8');
534
+ return p;
535
+ }
536
+
537
+ async function readPortPlan(targetRepoRoot) {
538
+ const p = await resolvePortPlanPath(targetRepoRoot);
539
+ if (!p) return { path: '', plan: null };
540
+ if (!(await pathExists(p))) return { path: p, plan: null };
541
+ try {
542
+ const raw = await readFile(p, 'utf-8');
543
+ return { path: p, plan: JSON.parse(raw) };
544
+ } catch {
545
+ return { path: p, plan: null };
546
+ }
547
+ }
548
+
549
+ async function deletePortPlan(targetRepoRoot) {
550
+ const p = await resolvePortPlanPath(targetRepoRoot);
551
+ if (!p) return;
552
+ await rm(p, { force: true });
553
+ }
554
+
555
+ async function listConflictedFiles(repoRoot) {
556
+ const out = (await git(repoRoot, ['status', '--porcelain'])).trim();
557
+ if (!out) return [];
558
+ const files = [];
559
+ for (const line of out.split(/\r?\n/)) {
560
+ // Porcelain v1: XY <path>
561
+ // Unmerged states include: UU, AA, DD, AU, UA, DU, UD
562
+ const xy = line.slice(0, 2);
563
+ const isUnmerged = xy.includes('U') || xy === 'AA' || xy === 'DD';
564
+ if (!isUnmerged) continue;
565
+ const path = line.slice(3).trim();
566
+ if (path) files.push(path);
567
+ }
568
+ return Array.from(new Set(files)).sort();
569
+ }
570
+
571
+ function hasConflictMarkers(text) {
572
+ const s = String(text ?? '');
573
+ // Typical git conflict markers at the start of a line.
574
+ return /^(<<<<<<< |>>>>>>> |\|\|\|\|\|\|\| )/m.test(s);
575
+ }
576
+
577
+ async function listFilesWithConflictMarkers(repoRoot, files) {
578
+ const fs = Array.isArray(files) ? files : [];
579
+ const hits = [];
580
+ for (const f of fs) {
581
+ const p = join(repoRoot, f);
582
+ try {
583
+ // eslint-disable-next-line no-await-in-loop
584
+ const raw = await readFile(p, 'utf-8');
585
+ if (hasConflictMarkers(raw)) hits.push(f);
586
+ } catch {
587
+ // ignore unreadable files
588
+ }
589
+ }
590
+ return hits;
591
+ }
592
+
593
+ async function readGitAmStatus(targetRepoRoot) {
594
+ const inProgress = await isGitAmInProgress(targetRepoRoot);
595
+ const conflictedFiles = await listConflictedFiles(targetRepoRoot);
596
+
597
+ let currentPatch = null;
598
+ if (inProgress) {
599
+ try {
600
+ const raw = await git(targetRepoRoot, ['am', '--show-current-patch']);
601
+ const meta = parsePatchMeta(raw);
602
+ const diffs = extractUnifiedDiffs(raw);
603
+ const filesRaw = Array.from(new Set(diffs.map((d) => d.plusPath || d.bPath).filter(Boolean))).sort();
604
+ const files = [];
605
+ for (const f of filesRaw) {
606
+ // `git am --directory <subdir>` applies patches under a directory, but `--show-current-patch`
607
+ // still shows the original (unprefixed) paths. Best-effort map them to the monorepo layout.
608
+ // eslint-disable-next-line no-await-in-loop
609
+ if (await pathExists(join(targetRepoRoot, f))) {
610
+ files.push(f);
611
+ continue;
612
+ }
613
+ const candidates = [
614
+ `apps/ui/${f}`,
615
+ `apps/cli/${f}`,
616
+ `apps/server/${f}`,
617
+ `expo-app/${f}`,
618
+ `cli/${f}`,
619
+ `server/${f}`,
620
+ ];
621
+ let mapped = '';
622
+ for (const c of candidates) {
623
+ // eslint-disable-next-line no-await-in-loop
624
+ if (await pathExists(join(targetRepoRoot, c))) {
625
+ mapped = c;
626
+ break;
627
+ }
628
+ }
629
+ files.push(mapped || f);
630
+ }
631
+ currentPatch = { subject: meta.subject || '', fromSha: meta.fromSha || '', files, filesRaw };
632
+ } catch {
633
+ currentPatch = { subject: '', fromSha: '', files: [], filesRaw: [] };
634
+ }
635
+ }
636
+
637
+ return { inProgress, currentPatch, conflictedFiles };
638
+ }
639
+
640
+ async function formatPatchesToDir({ sourceRepoRoot, base, head, outDir, progressLabel = '', progress } = {}) {
641
+ const range = `${base}..${head}`;
642
+ const spin = progress?.spinner?.(
643
+ `Formatting patches${progressLabel ? ` (${progressLabel})` : ''} ${dim(`(${range})`)}`
644
+ );
645
+ await run('git', ['format-patch', '--quiet', '--output-directory', outDir, `${base}..${head}`], { cwd: sourceRepoRoot });
646
+ spin?.succeed?.(`Formatted patches${progressLabel ? ` (${progressLabel})` : ''}`);
647
+ const entries = await readdir(outDir, { withFileTypes: true });
648
+ const patches = entries
649
+ .filter((e) => e.isFile() && e.name.endsWith('.patch'))
650
+ .map((e) => join(outDir, e.name))
651
+ .sort();
652
+ return patches;
653
+ }
654
+
655
+ function parsePatchMeta(patchText) {
656
+ const lines = patchText.split(/\r?\n/);
657
+ let fromSha = '';
658
+ let subject = '';
659
+ for (const line of lines) {
660
+ if (!fromSha && line.startsWith('From ')) {
661
+ const parts = line.trim().split(/\s+/);
662
+ if (parts.length >= 2) fromSha = parts[1];
663
+ continue;
664
+ }
665
+ if (!subject && line.startsWith('Subject:')) {
666
+ subject = line.slice('Subject:'.length).trim();
667
+ continue;
668
+ }
669
+ if (fromSha && subject) break;
670
+ }
671
+ return { fromSha, subject };
672
+ }
673
+
674
+ function parseApplyErrorPaths(errText) {
675
+ const text = String(errText ?? '').trim();
676
+ if (!text) return { kind: 'unknown', paths: [] };
677
+ const paths = new Set();
678
+
679
+ for (const m of text.matchAll(/error:\s+(\S+):\s+already exists in working directory/g)) {
680
+ if (m?.[1]) paths.add(m[1]);
681
+ }
682
+ for (const m of text.matchAll(/error:\s+patch failed:\s+(\S+):\d+/g)) {
683
+ if (m?.[1]) paths.add(m[1]);
684
+ }
685
+ for (const m of text.matchAll(/error:\s+(\S+):\s+does not exist in index/g)) {
686
+ if (m?.[1]) paths.add(m[1]);
687
+ }
688
+ for (const m of text.matchAll(/error:\s+(\S+):\s+No such file or directory/g)) {
689
+ if (m?.[1]) paths.add(m[1]);
690
+ }
691
+
692
+ const kind = text.includes('already exists in working directory')
693
+ ? 'already_exists'
694
+ : text.includes('patch does not apply') || text.includes('patch failed:')
695
+ ? 'patch_failed'
696
+ : text.includes('does not exist in index') || text.includes('No such file or directory')
697
+ ? 'missing_path'
698
+ : 'unknown';
699
+
700
+ return { kind, paths: Array.from(paths) };
701
+ }
702
+
703
+ function extractUnifiedDiffs(patchText) {
704
+ const lines = patchText.split(/\r?\n/);
705
+ const diffs = [];
706
+ let i = 0;
707
+ while (i < lines.length) {
708
+ const line = lines[i];
709
+ if (!line.startsWith('diff --git ')) {
710
+ i += 1;
711
+ continue;
712
+ }
713
+
714
+ const m = /^diff --git a\/(.+?) b\/(.+?)$/.exec(line.trim());
715
+ const bPath = m?.[2] ?? '';
716
+ const diff = {
717
+ bPath,
718
+ plusPath: '',
719
+ isNewFile: false,
720
+ isDeletedFile: false,
721
+ isBinary: false,
722
+ noTrailingNewline: false,
723
+ addedLines: [],
724
+ };
725
+
726
+ i += 1;
727
+ let inHunk = false;
728
+ while (i < lines.length && !lines[i].startsWith('diff --git ')) {
729
+ const l = lines[i];
730
+ if (l.startsWith('new file mode')) diff.isNewFile = true;
731
+ if (l.startsWith('deleted file mode')) diff.isDeletedFile = true;
732
+ if (l.startsWith('GIT binary patch')) diff.isBinary = true;
733
+ if (l.startsWith('--- /dev/null')) diff.isNewFile = true;
734
+ if (l.startsWith('+++ /dev/null')) diff.isDeletedFile = true;
735
+ if (l.startsWith('+++ b/')) diff.plusPath = l.slice('+++ b/'.length).trim();
736
+ if (l.startsWith('@@ ')) inHunk = true;
737
+ if (inHunk) {
738
+ if (l === '\') {
739
+ diff.noTrailingNewline = true;
740
+ } else if (l.startsWith('+') && !l.startsWith('+++ ')) {
741
+ diff.addedLines.push(l.slice(1));
742
+ }
743
+ }
744
+ i += 1;
745
+ }
746
+
747
+ diffs.push(diff);
748
+ }
749
+ return diffs;
750
+ }
751
+
752
+ async function checkPureNewFilesAlreadyExistIdentically({ targetRepoRoot, directory, patchText }) {
753
+ const diffs = extractUnifiedDiffs(patchText);
754
+ if (!diffs.length) return { ok: false, paths: [] };
755
+
756
+ // Only safe to auto-skip if this patch contains *only* new files.
757
+ if (diffs.some((d) => !d.isNewFile || d.isDeletedFile || d.isBinary)) {
758
+ return { ok: false, paths: [] };
759
+ }
760
+
761
+ const prefix = directory ? `${directory}/` : '';
762
+ const paths = [];
763
+ for (const d of diffs) {
764
+ const rel = d.plusPath || d.bPath;
765
+ if (!rel) return { ok: false, paths: [] };
766
+
767
+ let expected = '';
768
+ if (d.addedLines.length > 0) {
769
+ expected = d.addedLines.join('\n') + '\n';
770
+ if (d.noTrailingNewline && expected.endsWith('\n')) {
771
+ expected = expected.slice(0, -1);
772
+ }
773
+ }
774
+
775
+ const full = join(targetRepoRoot, `${prefix}${rel}`);
776
+ let actual = '';
777
+ try {
778
+ // eslint-disable-next-line no-await-in-loop
779
+ actual = await readFile(full, 'utf-8');
780
+ } catch {
781
+ return { ok: false, paths: [] };
782
+ }
783
+ if (actual !== expected) {
784
+ return { ok: false, paths: [] };
785
+ }
786
+ paths.push(`${prefix}${rel}`);
787
+ }
788
+
789
+ return { ok: true, paths };
790
+ }
791
+
792
+ async function applyPatches({ targetRepoRoot, directory, patches, threeWay, skipApplied, continueOnFailure, quietGit, progress } = {}) {
793
+ if (!patches.length) {
794
+ return { applied: [], skippedAlreadyApplied: [], skippedAlreadyExistsIdentical: [], failed: [] };
795
+ }
796
+ const dirArgs = directory ? ['--directory', `${directory}/`] : [];
797
+ const applied = [];
798
+ const skippedAlreadyApplied = [];
799
+ const skippedAlreadyExistsIdentical = [];
800
+ const failed = [];
801
+ const total = patches.length;
802
+ const targetLabel = directory ? `${directory}/` : '.';
803
+ const spin = progress?.spinner?.(`Applying patches into ${targetLabel} ${dim(`(0/${total})`)}`);
804
+ let lastUpdateAt = 0;
805
+
806
+ for (let i = 0; i < patches.length; i++) {
807
+ const patch = patches[i];
808
+ if (spin?.update) {
809
+ const now = Date.now();
810
+ // Avoid hammering the terminal. Update at most ~5x/sec.
811
+ if (now - lastUpdateAt > 200) {
812
+ lastUpdateAt = now;
813
+ spin.update(`Applying patches into ${targetLabel} ${dim(`(${i + 1}/${total})`)}`);
814
+ }
815
+ }
816
+ const patchFile = basename(patch);
817
+ // eslint-disable-next-line no-await-in-loop
818
+ const patchText = await readFile(patch, 'utf-8');
819
+ const { fromSha, subject } = parsePatchMeta(patchText);
820
+ const entry = { patch: patchFile, fromSha, subject };
821
+
822
+ // Preflight check (fast-ish): is this patch clearly already present or a no-op?
823
+ let applyCheckErr = '';
824
+ let appliesCleanly = false;
825
+ try {
826
+ // eslint-disable-next-line no-await-in-loop
827
+ await runCapture('git', ['apply', '--check', ...dirArgs, patch], { cwd: targetRepoRoot });
828
+ appliesCleanly = true;
829
+ } catch (e) {
830
+ appliesCleanly = false;
831
+ applyCheckErr = String(e?.err ?? e?.message ?? e ?? '').trim();
832
+ }
833
+
834
+ if (!appliesCleanly) {
835
+ // Auto-skip identical "new file" patches when the target already contains the same content.
836
+ // This commonly happens when a commit was already folded into the monorepo history during migration.
837
+ // eslint-disable-next-line no-await-in-loop
838
+ const identical = await checkPureNewFilesAlreadyExistIdentically({ targetRepoRoot, directory, patchText });
839
+ if (identical.ok) {
840
+ skippedAlreadyExistsIdentical.push({ ...entry, paths: identical.paths });
841
+ continue;
842
+ }
843
+
844
+ // If the reverse patch applies, the change is already present.
845
+ //
846
+ // This is safe (it requires an exact match of the patch content) and avoids stopping early
847
+ // when the monorepo already includes some split-repo commits.
848
+ //
849
+ // `--skip-applied` is kept as a compatibility flag (and a hint to users), but the behavior is effectively always-on.
850
+ let reverseApplies = false;
851
+ try {
852
+ // eslint-disable-next-line no-await-in-loop
853
+ await runCapture('git', ['apply', '-R', '--check', ...dirArgs, patch], { cwd: targetRepoRoot });
854
+ reverseApplies = true;
855
+ } catch {
856
+ reverseApplies = false;
857
+ }
858
+ if (reverseApplies) {
859
+ skippedAlreadyApplied.push(entry);
860
+ continue;
861
+ }
862
+ }
863
+
864
+ // Apply with full mailinfo/commit metadata. This may succeed even when `git apply --check` fails (e.g. with --3way).
865
+ try {
866
+ const tryAm = async ({ use3way }) => {
867
+ const args = ['am', '--quiet', ...(use3way ? ['--3way'] : []), ...dirArgs, patch];
868
+ if (quietGit) {
869
+ // eslint-disable-next-line no-await-in-loop
870
+ await runCapture('git', args, { cwd: targetRepoRoot });
871
+ } else {
872
+ // eslint-disable-next-line no-await-in-loop
873
+ await run('git', args, { cwd: targetRepoRoot });
874
+ }
875
+ };
876
+
877
+ try {
878
+ // eslint-disable-next-line no-await-in-loop
879
+ await tryAm({ use3way: threeWay });
880
+ } catch (amErr) {
881
+ const amText = String(amErr?.err ?? amErr?.message ?? amErr ?? '').trim();
882
+ const ancestorFail =
883
+ threeWay &&
884
+ (amText.includes('could not build fake ancestor') || amText.includes('sha1 information is lacking or useless'));
885
+ if (!ancestorFail) {
886
+ throw amErr;
887
+ }
888
+
889
+ // `git am --3way` requires the blob(s) referenced by the patch to exist in the target repo's object database.
890
+ // When porting into a minimal or mismatched target, those blobs may not exist, causing a hard failure.
891
+ // Fall back to non-3way so users can resolve the patch manually.
892
+ // eslint-disable-next-line no-await-in-loop
893
+ await run('git', ['am', '--abort'], { cwd: targetRepoRoot, stdio: 'ignore' }).catch(() => {});
894
+ // eslint-disable-next-line no-await-in-loop
895
+ await tryAm({ use3way: false });
896
+ }
897
+ applied.push(entry);
898
+ } catch (e) {
899
+ const err = String(e?.err ?? e?.message ?? e ?? '').trim();
900
+ const applyMeta = parseApplyErrorPaths(applyCheckErr || '');
901
+ const amMeta = parseApplyErrorPaths(err || '');
902
+ failed.push({
903
+ ...entry,
904
+ applyCheckErr,
905
+ err,
906
+ kind: applyMeta.kind === 'unknown' ? amMeta.kind : applyMeta.kind,
907
+ paths: Array.from(new Set([...(applyMeta.paths ?? []), ...(amMeta.paths ?? [])])),
908
+ });
909
+ if (!continueOnFailure) {
910
+ throw new Error(
911
+ [
912
+ `[monorepo] failed applying patch: ${subject || patchFile}`,
913
+ fromSha ? `[monorepo] from: ${fromSha}` : '',
914
+ applyCheckErr ? `[monorepo] apply --check:\n${applyCheckErr}` : '',
915
+ err ? `[monorepo] git am:\n${err}` : '',
916
+ '[monorepo] fix: resolve conflicts then run `git am --continue` (or abort with `git am --abort`)',
917
+ ]
918
+ .filter(Boolean)
919
+ .join('\n')
920
+ );
921
+ }
922
+
923
+ // Best-effort mode: abort and continue.
924
+ // eslint-disable-next-line no-await-in-loop
925
+ await run('git', ['am', '--abort'], { cwd: targetRepoRoot, stdio: 'ignore' }).catch(() => {});
926
+ }
927
+ }
928
+
929
+ spin?.succeed?.(
930
+ `Applied patches into ${targetLabel} ${dim(`(applied=${applied.length} skipped=${skippedAlreadyApplied.length + skippedAlreadyExistsIdentical.length} failed=${failed.length})`)}`
931
+ );
932
+ return {
933
+ applied,
934
+ skippedAlreadyApplied,
935
+ skippedAlreadyExistsIdentical,
936
+ failed,
937
+ };
938
+ }
939
+
940
+ async function portOne({
941
+ label,
942
+ sourcePath,
943
+ sourceRef,
944
+ sourceBase,
945
+ targetRepoRoot,
946
+ targetSubdir,
947
+ dryRun,
948
+ threeWay,
949
+ skipApplied,
950
+ continueOnFailure,
951
+ quietGit,
952
+ progress,
953
+ }) {
954
+ const sourceRepoRoot = await resolveGitRoot(sourcePath);
955
+ if (!sourceRepoRoot) {
956
+ throw new Error(`[monorepo] ${label}: not a git repo: ${sourcePath}`);
957
+ }
958
+ const sourceIsMonorepo = isHappyMonorepoRoot(sourceRepoRoot);
959
+ // If the source is already a monorepo, its patches already contain `packages/happy-*/` (or legacy `expo-app/`, `cli/`, etc).
960
+ // In that case, applying with `--directory <subdir>/` would double-prefix paths.
961
+ const effectiveTargetSubdir = sourceIsMonorepo ? '' : targetSubdir;
962
+ const head = (await git(sourceRepoRoot, ['rev-parse', '--verify', sourceRef || 'HEAD'])).trim();
963
+ const baseRef = sourceBase || (await resolveDefaultBaseRef(sourceRepoRoot));
964
+ if (!baseRef) {
965
+ throw new Error(`[monorepo] ${label}: could not infer a base ref. Pass --${label}-base=<ref>.`);
966
+ }
967
+ const base = (await git(sourceRepoRoot, ['merge-base', baseRef, head])).trim();
968
+ if (!base) {
969
+ throw new Error(`[monorepo] ${label}: failed to compute merge-base for ${baseRef}..${head}`);
970
+ }
971
+ if (base === head) {
972
+ return { label, sourceRepoRoot, baseRef, head, patches: 0, skipped: true, reason: 'no commits to port' };
973
+ }
974
+
975
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-port-'));
976
+ try {
977
+ const patches = await formatPatchesToDir({
978
+ sourceRepoRoot,
979
+ base,
980
+ head,
981
+ outDir: tmp,
982
+ progressLabel: label,
983
+ progress,
984
+ });
985
+ if (dryRun) {
986
+ return {
987
+ label,
988
+ sourceRepoRoot,
989
+ sourceIsMonorepo,
990
+ baseRef,
991
+ head,
992
+ patches: patches.length,
993
+ skipped: false,
994
+ dryRun: true,
995
+ targetSubdir: effectiveTargetSubdir || null,
996
+ };
997
+ }
998
+ const res = await applyPatches({
999
+ targetRepoRoot,
1000
+ directory: effectiveTargetSubdir,
1001
+ patches,
1002
+ threeWay,
1003
+ skipApplied,
1004
+ continueOnFailure,
1005
+ quietGit,
1006
+ progress,
1007
+ });
1008
+ return {
1009
+ label,
1010
+ sourceRepoRoot,
1011
+ sourceIsMonorepo,
1012
+ baseRef,
1013
+ head,
1014
+ patches: patches.length,
1015
+ appliedPatches: res.applied.length,
1016
+ skippedAlreadyApplied: res.skippedAlreadyApplied.length,
1017
+ skippedAlreadyExistsIdentical: res.skippedAlreadyExistsIdentical.length,
1018
+ failedPatches: res.failed.length,
1019
+ report: res,
1020
+ skipped: false,
1021
+ targetSubdir: effectiveTargetSubdir || null,
1022
+ };
1023
+ } finally {
1024
+ await rm(tmp, { recursive: true, force: true }).catch(() => {});
1025
+ }
1026
+ }
1027
+
1028
+ async function cmdPortRun({ argv, flags, kv, json, silent = false }) {
1029
+ const targetArg = (kv.get('--target') ?? '').trim();
1030
+ const targetHint = targetArg || process.cwd();
1031
+ const progress = createProgressReporter({ enabled: shouldShowProgress({ json, silent }) });
1032
+ const targetRepoRoot = await resolveOrCloneTargetRepoRoot({ targetInput: targetHint, targetArg, flags, kv, progress });
1033
+
1034
+ // Prefer a clearer error message if the user is in the middle of conflict resolution.
1035
+ // (A git am session often makes the worktree dirty, which would otherwise trigger a generic "not clean" error.)
1036
+ await ensureNoGitAmInProgress(targetRepoRoot);
1037
+ await ensureCleanGitWorktree(targetRepoRoot);
1038
+
1039
+ const ontoCurrent = flags.has('--onto-current');
1040
+ const branchOverride = (kv.get('--branch') ?? '').trim();
1041
+ const baseOverride = (kv.get('--base') ?? '').trim();
1042
+ const dryRun = flags.has('--dry-run');
1043
+ const threeWay = flags.has('--3way');
1044
+ const skipApplied = flags.has('--skip-applied');
1045
+ const continueOnFailure = flags.has('--continue-on-failure');
1046
+ const quietGit = json;
1047
+ let baseRefUsed = null;
1048
+ let branchLabel = null;
1049
+ if (!dryRun) {
1050
+ if (ontoCurrent) {
1051
+ if (branchOverride) {
1052
+ throw new Error('[monorepo] --onto-current cannot be combined with --branch (it applies onto the currently checked-out branch)');
1053
+ }
1054
+ if (baseOverride) {
1055
+ throw new Error('[monorepo] --onto-current cannot be combined with --base (it does not checkout a base ref)');
1056
+ }
1057
+ baseRefUsed = null;
1058
+ branchLabel = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
1059
+ } else {
1060
+ const baseRef = baseOverride || (await resolveDefaultTargetBaseRef(targetRepoRoot));
1061
+ if (!baseRef) {
1062
+ throw new Error('[monorepo] could not infer a target base ref. Pass --base=<ref>.');
1063
+ }
1064
+ baseRefUsed = baseRef;
1065
+ const branch = branchOverride || `port/${Date.now()}`;
1066
+ branchLabel = branch;
1067
+ // Always start the port branch from a stable base (usually origin/main), rather than whatever is currently checked out.
1068
+ await git(targetRepoRoot, ['checkout', '--quiet', baseRef]);
1069
+ await ensureBranch(targetRepoRoot, branch);
1070
+ }
1071
+ } else {
1072
+ branchLabel = ontoCurrent ? 'onto-current' : branchOverride || `port/${Date.now()}`;
1073
+ }
1074
+
1075
+ const sources = [
1076
+ {
1077
+ label: 'from-happy',
1078
+ path: (kv.get('--from-happy') ?? '').trim(),
1079
+ ref: (kv.get('--from-happy-ref') ?? '').trim(),
1080
+ base: (kv.get('--from-happy-base') ?? '').trim(),
1081
+ subdir: happyMonorepoSubdirForComponent('happier-ui', { monorepoRoot: targetRepoRoot }) || 'apps/ui',
1082
+ },
1083
+ {
1084
+ label: 'from-happy-cli',
1085
+ path: (kv.get('--from-happy-cli') ?? '').trim(),
1086
+ ref: (kv.get('--from-happy-cli-ref') ?? '').trim(),
1087
+ base: (kv.get('--from-happy-cli-base') ?? '').trim(),
1088
+ subdir: happyMonorepoSubdirForComponent('happier-cli', { monorepoRoot: targetRepoRoot }) || 'apps/cli',
1089
+ },
1090
+ {
1091
+ label: 'from-happy-server',
1092
+ path: (kv.get('--from-happy-server') ?? '').trim(),
1093
+ ref: (kv.get('--from-happy-server-ref') ?? '').trim(),
1094
+ base: (kv.get('--from-happy-server-base') ?? '').trim(),
1095
+ subdir: happyMonorepoSubdirForComponent('happier-server', { monorepoRoot: targetRepoRoot }) || 'apps/server',
1096
+ },
1097
+ ].filter((s) => s.path);
1098
+
1099
+ if (!sources.length) {
1100
+ throw new Error('[monorepo] nothing to port. Provide at least one of: --from-happy, --from-happy-cli, --from-happy-server');
1101
+ }
1102
+
1103
+ // Already checked above (keep just one check so errors stay consistent).
1104
+
1105
+ const results = [];
1106
+ for (const s of sources) {
1107
+ // Allow sources to be local paths OR URL/PR specs (cloned into target/.git scratch).
1108
+ // eslint-disable-next-line no-await-in-loop
1109
+ const resolvedPath = await ensureRepoSpecCheckedOut({
1110
+ targetRepoRoot,
1111
+ label: s.label,
1112
+ spec: s.path,
1113
+ desiredRef: s.ref,
1114
+ progress,
1115
+ });
1116
+ // eslint-disable-next-line no-await-in-loop
1117
+ const r = await portOne({
1118
+ label: s.label,
1119
+ sourcePath: resolvedPath,
1120
+ sourceRef: s.ref,
1121
+ sourceBase: s.base,
1122
+ targetRepoRoot,
1123
+ targetSubdir: s.subdir,
1124
+ dryRun,
1125
+ threeWay,
1126
+ skipApplied,
1127
+ continueOnFailure,
1128
+ quietGit,
1129
+ progress,
1130
+ });
1131
+ results.push(r);
1132
+ }
1133
+
1134
+ const ok = dryRun || results.every((r) => (r.failedPatches ?? 0) === 0);
1135
+ const summary = dryRun
1136
+ ? `[monorepo] dry run complete (${branchLabel})`
1137
+ : ok
1138
+ ? `[monorepo] port complete (${branchLabel})`
1139
+ : `[monorepo] port complete with failures (${branchLabel})`;
1140
+ const failureDetails = (() => {
1141
+ if (json || dryRun || ok) return '';
1142
+ const lines = [];
1143
+ for (const r of results) {
1144
+ const report = r.report;
1145
+ const failed = report?.failed ?? [];
1146
+ if (!failed.length) continue;
1147
+ lines.push('');
1148
+ lines.push(`[monorepo] ${r.label}: failed patches (${failed.length})`);
1149
+ for (const f of failed.slice(0, 12)) {
1150
+ const subj = String(f.subject ?? '').replace(/^\[PATCH \d+\/\d+\]\s*/, '');
1151
+ const kind = f.kind ? ` (${f.kind})` : '';
1152
+ const paths = (f.paths ?? []).slice(0, 3).join(', ');
1153
+ lines.push(`- ${subj || f.patch}${kind}${paths ? ` -> ${paths}` : ''}`);
1154
+ }
1155
+ if (failed.length > 12) {
1156
+ lines.push(`- ...and ${failed.length - 12} more`);
1157
+ }
1158
+ }
1159
+ return lines.join('\n');
1160
+ })();
1161
+
1162
+ const hints = ok || json
1163
+ ? ''
1164
+ : [
1165
+ '',
1166
+ '[monorepo] next steps:',
1167
+ '- for a full machine-readable report: re-run with `--json`.',
1168
+ '- to resolve interactively (recommended): re-run without `--continue-on-failure` so it stops at the first conflict, then use `git am --continue`.',
1169
+ ].join('\n');
1170
+
1171
+ const data = { ok, targetRepoRoot, branch: branchLabel, ontoCurrent, dryRun, base: baseRefUsed, results };
1172
+ if (!silent) {
1173
+ printResult({
1174
+ json,
1175
+ data,
1176
+ text: json ? '' : `${summary}${failureDetails}${hints}`,
1177
+ });
1178
+ }
1179
+ return data;
1180
+ }
1181
+
1182
+ async function cmdPortStatus({ kv, json }) {
1183
+ const targetRepoRoot = await resolveTargetRepoRootFromArgs({ kv });
1184
+ const { inProgress, currentPatch, conflictedFiles } = await readGitAmStatus(targetRepoRoot);
1185
+ const branch = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
1186
+
1187
+ const text = (() => {
1188
+ if (json) return '';
1189
+ const lines = [];
1190
+ const okMark = inProgress ? yellow('!') : green('✓');
1191
+ lines.push(`${bold('[monorepo]')} ${bold('port status')} ${dim(`(${branch})`)} ${okMark}`);
1192
+ lines.push(`${dim('target:')} ${targetRepoRoot}`);
1193
+ lines.push(`${dim('git am in progress:')} ${inProgress ? yellow('yes') : green('no')}`);
1194
+ if (inProgress && currentPatch?.subject) {
1195
+ lines.push(`${dim('current patch:')} ${cyan(currentPatch.subject)}`);
1196
+ }
1197
+ if (inProgress && currentPatch?.files?.length) {
1198
+ lines.push(
1199
+ `${dim('patch files:')} ${currentPatch.files.slice(0, 6).join(', ')}${currentPatch.files.length > 6 ? dim(', ...') : ''}`
1200
+ );
1201
+ }
1202
+ if (conflictedFiles.length) {
1203
+ lines.push(`${yellow('conflicted files:')} ${dim(`(${conflictedFiles.length})`)}`);
1204
+ for (const f of conflictedFiles.slice(0, 20)) lines.push(` - ${f}`);
1205
+ if (conflictedFiles.length > 20) lines.push(` - ...and ${conflictedFiles.length - 20} more`);
1206
+ }
1207
+ if (inProgress) {
1208
+ lines.push('');
1209
+ lines.push(bold('[monorepo] next steps:'));
1210
+ lines.push(`- ${dim('resolve + stage:')} git -C ${targetRepoRoot} add <files>`);
1211
+ lines.push(`- ${dim('continue:')} git -C ${targetRepoRoot} am --continue`);
1212
+ lines.push(`- ${dim('skip patch:')} git -C ${targetRepoRoot} am --skip`);
1213
+ lines.push(`- ${dim('abort:')} git -C ${targetRepoRoot} am --abort`);
1214
+ lines.push(`- ${dim('helper:')} hstack monorepo port continue --target=${targetRepoRoot}`);
1215
+ }
1216
+ return lines.join('\n');
1217
+ })();
1218
+
1219
+ printResult({
1220
+ json,
1221
+ data: { ok: true, targetRepoRoot, branch, inProgress, currentPatch, conflictedFiles },
1222
+ text,
1223
+ });
1224
+ }
1225
+
1226
+ function buildPortLlmPromptText({ targetRepoRoot }) {
1227
+ const hs = buildhstackRunnerShellSnippet();
1228
+ return [
1229
+ 'You are an assistant helping the user port split-repo commits into the Happier monorepo.',
1230
+ '',
1231
+ hs,
1232
+ `Target monorepo root: ${targetRepoRoot}`,
1233
+ '',
1234
+ 'How to run the port:',
1235
+ `- guided (recommended): hs monorepo port guide --target=${targetRepoRoot}`,
1236
+ `- machine-readable report: hs monorepo port --target=${targetRepoRoot} --json`,
1237
+ '',
1238
+ 'If a conflict happens (git am in progress):',
1239
+ `- inspect state (JSON): hs monorepo port status --target=${targetRepoRoot} --json`,
1240
+ `- inspect state (text): hs monorepo port status --target=${targetRepoRoot}`,
1241
+ `- after fixing files: git -C ${targetRepoRoot} am --continue`,
1242
+ `- or via wrapper: hs monorepo port continue --target=${targetRepoRoot}`,
1243
+ `- to skip current patch: git -C ${targetRepoRoot} am --skip`,
1244
+ `- to abort: git -C ${targetRepoRoot} am --abort`,
1245
+ '',
1246
+ 'Instructions:',
1247
+ '- Prefer minimal conflict resolutions that preserve intent.',
1248
+ '- Conflicts are resolved one patch at a time (git am stops at the first conflict).',
1249
+ '- Do not “pre-resolve” hypothetical future conflicts; re-check status after each continue.',
1250
+ '- Keep changes scoped to apps/ui/, apps/cli/, apps/server/ (or legacy expo-app/, cli/, server/).',
1251
+ '- After each continue, re-check status until port completes.',
1252
+ ].join('\n');
1253
+ }
1254
+
1255
+ function buildPortGuideLlmPromptText({ targetRepoRoot, initialCommandArgs }) {
1256
+ const parts = Array.isArray(initialCommandArgs) ? initialCommandArgs : [];
1257
+ const cmd = ['hs', 'monorepo', ...parts.map((p) => String(p))].join(' ');
1258
+ return [
1259
+ 'You are an assistant helping the user port split-repo commits into the Happier monorepo.',
1260
+ '',
1261
+ buildhstackRunnerShellSnippet(),
1262
+ `Target monorepo root: ${targetRepoRoot}`,
1263
+ '',
1264
+ 'Goal:',
1265
+ '- Run the port command.',
1266
+ '- If conflicts occur, resolve them cleanly and continue until the port completes.',
1267
+ '',
1268
+ 'Important:',
1269
+ '- The port may already be running and stopped on a conflict (git am in progress).',
1270
+ `- If running "${cmd}" fails with "git am already in progress", do NOT retry it; use status/continue below.`,
1271
+ '',
1272
+ 'If the port is not started yet, start it (run exactly):',
1273
+ cmd,
1274
+ '',
1275
+ 'If it stops with conflicts:',
1276
+ `- Inspect status (JSON): hs monorepo port status --target=${targetRepoRoot} --json`,
1277
+ `- Resolve conflicted files`,
1278
+ `- Stage: git -C ${targetRepoRoot} add <files>`,
1279
+ `- Continue: hs monorepo port continue --target=${targetRepoRoot}`,
1280
+ '',
1281
+ 'Notes:',
1282
+ '- Conflicts are resolved one patch at a time (git am stops at the first conflict).',
1283
+ '- It’s common for later patches to fail “on paper” until the first conflict is resolved; don’t over-edit.',
1284
+ '',
1285
+ 'Repeat status/resolve/continue until it completes.',
1286
+ ].join('\n');
1287
+ }
1288
+
1289
+ async function cmdPortContinue({ kv, flags, json }) {
1290
+ const targetRepoRoot = await resolveTargetRepoRootFromArgs({ kv });
1291
+ const runAmContinue = async () => {
1292
+ const inProgressBefore = await isGitAmInProgress(targetRepoRoot);
1293
+ if (!inProgressBefore) return { ok: true, didRun: false };
1294
+ const stageWanted = flags?.has?.('--stage') === true || flags?.has?.('--stage-conflicts') === true;
1295
+ const { conflictedFiles, currentPatch } = await readGitAmStatus(targetRepoRoot);
1296
+
1297
+ const stageCandidates = conflictedFiles.length
1298
+ ? conflictedFiles
1299
+ : Array.isArray(currentPatch?.files)
1300
+ ? currentPatch.files.filter(Boolean)
1301
+ : [];
1302
+
1303
+ if (conflictedFiles.length) {
1304
+ if (!stageWanted) {
1305
+ const hint = [
1306
+ `${yellow('[monorepo]')} continue blocked: ${bold('files still need staging')}`,
1307
+ `[monorepo] git reports unmerged files (e.g. ${dim('UU')}). This usually means you resolved them in an editor but forgot ${bold('git add')}.`,
1308
+ `[monorepo] conflicted files: ${conflictedFiles.join(', ')}`,
1309
+ `[monorepo] next: git -C ${targetRepoRoot} add ${conflictedFiles.map((f) => JSON.stringify(f)).join(' ')}`,
1310
+ `[monorepo] then re-run: hstack monorepo port continue --target=${targetRepoRoot}`,
1311
+ `[monorepo] tip: you can also run: hstack monorepo port continue --target=${targetRepoRoot} --stage`,
1312
+ ].join('\n');
1313
+ printResult({
1314
+ json,
1315
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles, needsStage: true, currentPatch },
1316
+ text: json ? '' : hint,
1317
+ });
1318
+ process.exitCode = 1;
1319
+ return { ok: false, didRun: false };
1320
+ }
1321
+
1322
+ const markerHits = await listFilesWithConflictMarkers(targetRepoRoot, stageCandidates);
1323
+ if (markerHits.length) {
1324
+ const hint = [
1325
+ `${yellow('[monorepo]')} refusing to auto-stage: conflict markers still present`,
1326
+ `[monorepo] files: ${markerHits.join(', ')}`,
1327
+ `[monorepo] next: open the file(s), remove ${dim('<<<<<<< / ======= / >>>>>>>')} markers, then run:`,
1328
+ ` git -C ${targetRepoRoot} add ${markerHits.map((f) => JSON.stringify(f)).join(' ')}`,
1329
+ ` hstack monorepo port continue --target=${targetRepoRoot}`,
1330
+ ].join('\n');
1331
+ printResult({
1332
+ json,
1333
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles, conflictMarkers: markerHits },
1334
+ text: json ? '' : hint,
1335
+ });
1336
+ process.exitCode = 1;
1337
+ return { ok: false, didRun: false };
1338
+ }
1339
+
1340
+ await runCapture('git', ['add', '-A', '--', ...stageCandidates], { cwd: targetRepoRoot });
1341
+ } else if (stageWanted && stageCandidates.length) {
1342
+ const markerHits = await listFilesWithConflictMarkers(targetRepoRoot, stageCandidates);
1343
+ if (markerHits.length) {
1344
+ const hint = [
1345
+ `${yellow('[monorepo]')} refusing to auto-stage: conflict markers still present`,
1346
+ `[monorepo] files: ${markerHits.join(', ')}`,
1347
+ `[monorepo] next: open the file(s), remove ${dim('<<<<<<< / ======= / >>>>>>>')} markers, then run:`,
1348
+ ` git -C ${targetRepoRoot} add ${markerHits.map((f) => JSON.stringify(f)).join(' ')}`,
1349
+ ` hstack monorepo port continue --target=${targetRepoRoot}`,
1350
+ ].join('\n');
1351
+ printResult({
1352
+ json,
1353
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles, conflictMarkers: markerHits },
1354
+ text: json ? '' : hint,
1355
+ });
1356
+ process.exitCode = 1;
1357
+ return { ok: false, didRun: false };
1358
+ }
1359
+ await runCapture('git', ['add', '-A', '--', ...stageCandidates], { cwd: targetRepoRoot });
1360
+ }
1361
+ try {
1362
+ await runCapture('git', ['am', '--continue'], { cwd: targetRepoRoot });
1363
+ return { ok: true, didRun: true };
1364
+ } catch (err) {
1365
+ const conflictedFiles = await listConflictedFiles(targetRepoRoot);
1366
+ const stderr = String(err?.err ?? err?.message ?? err ?? '').trim();
1367
+ const hint = [
1368
+ `${red('[monorepo]')} continue failed (still conflicted).`,
1369
+ conflictedFiles.length ? `[monorepo] conflicted files: ${conflictedFiles.join(', ')}` : '',
1370
+ stderr ? `[monorepo] git:\n${stderr}` : '',
1371
+ `[monorepo] next: resolve, stage (${bold('git add')}), then re-run: hstack monorepo port continue --target=${targetRepoRoot}`,
1372
+ ]
1373
+ .filter(Boolean)
1374
+ .join('\n');
1375
+ printResult({
1376
+ json,
1377
+ data: { ok: false, targetRepoRoot, inProgress: true, conflictedFiles },
1378
+ text: json ? '' : hint,
1379
+ });
1380
+ process.exitCode = 1;
1381
+ return { ok: false, didRun: true };
1382
+ }
1383
+ };
1384
+
1385
+ // 1) If an am session is in progress, advance it.
1386
+ const amRes = await runAmContinue();
1387
+ if (!amRes.ok) return;
1388
+
1389
+ // 2) If we're no longer in an am session, and a guide plan exists, resume the port onto the current branch.
1390
+ const inProgressAfter = await isGitAmInProgress(targetRepoRoot);
1391
+ const branch = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
1392
+ if (!inProgressAfter) {
1393
+ const { plan } = await readPortPlan(targetRepoRoot);
1394
+ if (plan?.resumeArgv && Array.isArray(plan.resumeArgv)) {
1395
+ try {
1396
+ const resumeArgv = [...plan.resumeArgv];
1397
+ const { flags, kv } = parseArgs(resumeArgv);
1398
+ const jsonWanted = json || wantsJson(resumeArgv, { flags });
1399
+ await cmdPortRun({ argv: resumeArgv, flags, kv, json: jsonWanted, silent: json === true });
1400
+ await deletePortPlan(targetRepoRoot);
1401
+ } catch {
1402
+ // cmdPortRun prints its own conflict context; leave the plan file so the user can retry after resolving.
1403
+ process.exitCode = process.exitCode ?? 1;
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ const stillInProgress = await isGitAmInProgress(targetRepoRoot);
1409
+ printResult({
1410
+ json,
1411
+ data: { ok: !stillInProgress, targetRepoRoot, branch, inProgress: stillInProgress },
1412
+ text: json
1413
+ ? ''
1414
+ : stillInProgress
1415
+ ? `${yellow('[monorepo]')} continue paused (conflicts remain) ${dim(`(${branch})`)}`
1416
+ : `${green('[monorepo]')} continue complete ${dim(`(${branch})`)}`,
1417
+ });
1418
+ }
1419
+
1420
+ async function runPortPreflightData({ targetRepoRoot, baseRef, threeWay, sources }) {
1421
+ const srcs = Array.isArray(sources) ? sources : [];
1422
+ if (!targetRepoRoot) throw new Error('[monorepo] preflight: missing targetRepoRoot');
1423
+ if (!baseRef) throw new Error('[monorepo] preflight: missing baseRef');
1424
+ if (!srcs.length) {
1425
+ throw new Error('[monorepo] preflight: nothing to port. Provide at least one source.');
1426
+ }
1427
+
1428
+ return await withTempDetachedWorktree({ repoRoot: targetRepoRoot, ref: baseRef, label: 'monorepo-preflight' }, async (worktreeDir) => {
1429
+ const preflightArgv = [
1430
+ 'port',
1431
+ `--target=${worktreeDir}`,
1432
+ '--onto-current',
1433
+ '--json',
1434
+ ...(threeWay ? ['--3way'] : []),
1435
+ ...srcs.flatMap((s) => [
1436
+ `--${s.label}=${s.path}`,
1437
+ ...(s.base ? [`--${s.label}-base=${s.base}`] : []),
1438
+ ...(s.ref ? [`--${s.label}-ref=${s.ref}`] : []),
1439
+ ]),
1440
+ ];
1441
+ const parsed = parseArgs(preflightArgv);
1442
+ try {
1443
+ // Run silently; we only care about the returned JSON data.
1444
+ const data = await cmdPortRun({ argv: preflightArgv, flags: parsed.flags, kv: parsed.kv, json: true, silent: true });
1445
+ return {
1446
+ ok: true,
1447
+ targetRepoRoot,
1448
+ base: baseRef,
1449
+ threeWay: Boolean(threeWay),
1450
+ failedPatches: 0,
1451
+ sourcesWithFailures: 0,
1452
+ results: data?.results ?? [],
1453
+ firstConflict: null,
1454
+ };
1455
+ } catch (e) {
1456
+ const { inProgress, currentPatch, conflictedFiles } = await readGitAmStatus(worktreeDir);
1457
+ if (!inProgress) throw e;
1458
+ return {
1459
+ ok: false,
1460
+ targetRepoRoot,
1461
+ base: baseRef,
1462
+ threeWay: Boolean(threeWay),
1463
+ failedPatches: 1,
1464
+ sourcesWithFailures: 1,
1465
+ results: [],
1466
+ firstConflict: {
1467
+ currentPatch,
1468
+ conflictedFiles,
1469
+ },
1470
+ };
1471
+ }
1472
+ });
1473
+ }
1474
+
1475
+ async function cmdPortPreflight({ argv, flags, kv, json }) {
1476
+ const target = (kv.get('--target') ?? '').trim();
1477
+ if (!target) throw new Error('[monorepo] preflight: missing --target=/abs/path/to/monorepo');
1478
+
1479
+ const targetRepoRoot = await resolveGitRoot(target);
1480
+ if (!targetRepoRoot) throw new Error(`[monorepo] preflight: target is not a git repo: ${target}`);
1481
+ if (!isHappyMonorepoRoot(targetRepoRoot)) {
1482
+ throw new Error(`[monorepo] preflight: target is not a Happier monorepo root: ${targetRepoRoot}`);
1483
+ }
1484
+
1485
+ const threeWay = flags.has('--3way');
1486
+ const baseOverride = (kv.get('--base') ?? '').trim();
1487
+ const baseRef = baseOverride || (await resolveDefaultTargetBaseRef(targetRepoRoot));
1488
+ if (!baseRef) throw new Error('[monorepo] preflight: could not infer a target base ref. Pass --base=<ref>.');
1489
+
1490
+ const sources = [
1491
+ {
1492
+ label: 'from-happy',
1493
+ path: (kv.get('--from-happy') ?? '').trim(),
1494
+ ref: (kv.get('--from-happy-ref') ?? '').trim(),
1495
+ base: (kv.get('--from-happy-base') ?? '').trim(),
1496
+ },
1497
+ {
1498
+ label: 'from-happy-cli',
1499
+ path: (kv.get('--from-happy-cli') ?? '').trim(),
1500
+ ref: (kv.get('--from-happy-cli-ref') ?? '').trim(),
1501
+ base: (kv.get('--from-happy-cli-base') ?? '').trim(),
1502
+ },
1503
+ {
1504
+ label: 'from-happy-server',
1505
+ path: (kv.get('--from-happy-server') ?? '').trim(),
1506
+ ref: (kv.get('--from-happy-server-ref') ?? '').trim(),
1507
+ base: (kv.get('--from-happy-server-base') ?? '').trim(),
1508
+ },
1509
+ ].filter((s) => s.path);
1510
+
1511
+ if (!sources.length) {
1512
+ throw new Error('[monorepo] preflight: nothing to port. Provide at least one of: --from-happy, --from-happy-cli, --from-happy-server');
1513
+ }
1514
+
1515
+ const out = await runPortPreflightData({ targetRepoRoot, baseRef, threeWay, sources });
1516
+ const summary = out.ok
1517
+ ? `${green('[monorepo]')} preflight: no conflicts detected`
1518
+ : `${yellow('[monorepo]')} preflight: conflicts detected ${dim(`(${out.sourcesWithFailures} source(s); may cascade)`)}`;
1519
+
1520
+ printResult({
1521
+ json,
1522
+ data: out,
1523
+ text: json ? '' : summary,
1524
+ });
1525
+ }
1526
+
1527
+ async function cmdPortGuide({ kv, flags, json }) {
1528
+ if (!isTty()) {
1529
+ throw new Error('[monorepo] port guide requires a TTY. Re-run in an interactive terminal.');
1530
+ }
1531
+
1532
+ const targetDefault = (kv.get('--target') ?? '').trim() || process.cwd();
1533
+ await withRl(async (rl) => {
1534
+ // eslint-disable-next-line no-console
1535
+ console.log(
1536
+ [
1537
+ '',
1538
+ bold(`✨ ${cyan('hstack')} monorepo port ✨`),
1539
+ '',
1540
+ 'This wizard ports commits from split repos into the Happy monorepo layout:',
1541
+ `- ${cyan('happy')} → apps/ui/ (or legacy expo-app/)`,
1542
+ `- ${cyan('happy-cli')} → apps/cli/ (or legacy cli/)`,
1543
+ `- ${cyan('happy-server')} → apps/server/ (or legacy server/)`,
1544
+ '',
1545
+ bold('Notes:'),
1546
+ `- Uses ${cyan('git format-patch')} + ${cyan('git am')} (preserves author + messages)`,
1547
+ `- Stops on conflicts so you can resolve and continue`,
1548
+ '',
1549
+ ].join('\n')
1550
+ );
1551
+
1552
+ const targetArg = (kv.get('--target') ?? '').trim();
1553
+ const targetInput = targetArg || (await prompt(rl, 'Target monorepo path: ', { defaultValue: targetDefault })).trim();
1554
+ let targetRepoRoot = await resolveGitRoot(targetInput);
1555
+ if (!targetRepoRoot) {
1556
+ const wantsClone = flags?.has?.('--clone-target') || flags?.has?.('--clone');
1557
+ // If the target doesn't exist yet, default to cloning without asking (best UX for `--target=...`).
1558
+ const targetExists = await pathExists(targetInput);
1559
+ if (targetExists && !wantsClone) {
1560
+ const shouldClone =
1561
+ (await promptSelect(rl, {
1562
+ title:
1563
+ `${bold('Target directory is not a git repo')}\n` +
1564
+ `${dim('Do you want hstack to clone the monorepo into this directory?')}\n` +
1565
+ `${dim(targetInput)}`,
1566
+ options: [
1567
+ { label: `${green('yes (recommended)')} — clone the Happier monorepo into this directory`, value: true },
1568
+ { label: 'no — I will provide an existing monorepo checkout', value: false },
1569
+ ],
1570
+ defaultIndex: 0,
1571
+ })) === true;
1572
+ if (!shouldClone) {
1573
+ throw new Error(`[monorepo] invalid target (expected an existing Happier monorepo checkout): ${targetInput}`);
1574
+ }
1575
+ }
1576
+ const repoUrl = (kv.get('--target-repo') ?? '').trim() || 'https://github.com/happier-dev/happier.git';
1577
+ // eslint-disable-next-line no-console
1578
+ console.log(dim(`[monorepo] cloning target monorepo -> ${targetInput}`));
1579
+ await ensureClonedHappyMonorepo({ targetPath: targetInput, repoUrl });
1580
+ targetRepoRoot = await resolveGitRoot(targetInput);
1581
+ }
1582
+ if (!targetRepoRoot || !isHappyMonorepoRoot(targetRepoRoot)) {
1583
+ throw new Error(`[monorepo] invalid target (expected Happier monorepo root): ${targetInput}`);
1584
+ }
1585
+ const existingPlan = await readPortPlan(targetRepoRoot);
1586
+ if (existingPlan?.plan?.resumeArgv && Array.isArray(existingPlan.plan.resumeArgv)) {
1587
+ // Resume mode:
1588
+ // - do NOT require a clean worktree
1589
+ // - do NOT reject if git am is in progress
1590
+ // - do NOT re-prompt for options; use the stored plan
1591
+ // eslint-disable-next-line no-console
1592
+ console.log('');
1593
+ // eslint-disable-next-line no-console
1594
+ console.log(`${bold('[monorepo]')} guide: resuming existing port plan`);
1595
+
1596
+ const plan = existingPlan.plan;
1597
+ const resumeArgv = [...plan.resumeArgv];
1598
+ const initialArgv = Array.isArray(plan.initialArgv) ? [...plan.initialArgv] : null;
1599
+
1600
+ // Ensure we are on the intended branch if we can.
1601
+ try {
1602
+ const intended = String(plan.branch ?? '').trim();
1603
+ if (intended && intended !== 'HEAD') {
1604
+ const cur = (await git(targetRepoRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || 'HEAD';
1605
+ if (cur !== intended) {
1606
+ const exists = await gitOk(targetRepoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${intended}`]);
1607
+ if (exists) {
1608
+ await git(targetRepoRoot, ['checkout', '--quiet', intended]);
1609
+ }
1610
+ }
1611
+ }
1612
+ } catch {
1613
+ // ignore; we'll still rely on status/continue instructions
1614
+ }
1615
+
1616
+ const attemptArgv = initialArgv && !(await gitOk(targetRepoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${String(plan.branch ?? '').trim()}`]))
1617
+ ? initialArgv
1618
+ : resumeArgv;
1619
+
1620
+ // If git am is already in progress, jump directly to the conflict loop.
1621
+ const inProgress = await isGitAmInProgress(targetRepoRoot);
1622
+ const { flags: attemptFlags, kv: attemptKv } = parseArgs(resumeArgv);
1623
+ const allowAutoLlm = String(process.env.HAPPIER_STACK_DISABLE_LLM_AUTOEXEC ?? '').trim() !== '1';
1624
+ const canAutoLaunchLlm = allowAutoLlm && (await detectInstalledLlmTools({ onlyAutoExec: true })).length > 0;
1625
+ const preferredConflictMode = String(plan.preferredConflictMode ?? '').trim() || 'guided';
1626
+
1627
+ if (inProgress) {
1628
+ // eslint-disable-next-line no-console
1629
+ console.log('');
1630
+ // eslint-disable-next-line no-console
1631
+ console.log(`${yellow('[monorepo]')} guide: conflict detected`);
1632
+ // eslint-disable-next-line no-console
1633
+ console.log(dim('[monorepo] guide: waiting for conflict resolution'));
1634
+ // Reuse the same action loop as the main guide path.
1635
+ // eslint-disable-next-line no-await-in-loop
1636
+ while (await isGitAmInProgress(targetRepoRoot)) {
1637
+ await cmdPortStatus({ kv: attemptKv, json: false });
1638
+ const action = await promptSelect(rl, {
1639
+ title: bold('Resolve conflicts, then choose an action:'),
1640
+ options: [
1641
+ { label: `${green('continue')} (git am --continue)`, value: 'continue' },
1642
+ { label: `${green('stage + continue')} ${dim('(git add conflicted files, then continue)')}`, value: 'stage-continue' },
1643
+ { label: `${cyan('show status again')}`, value: 'status' },
1644
+ ...(canAutoLaunchLlm
1645
+ ? [{ label: `${green('launch LLM now')} ${dim('(recommended)')}`, value: 'llm-launch' }]
1646
+ : []),
1647
+ { label: `${cyan('llm prompt')} ${dim('(copy/paste)')}`, value: 'llm' },
1648
+ { label: `${yellow('skip current patch')} (git am --skip)`, value: 'skip' },
1649
+ { label: `${red('abort')} (git am --abort)`, value: 'abort' },
1650
+ { label: `${dim('quit guide (leave state as-is)')}`, value: 'quit' },
1651
+ ],
1652
+ defaultIndex: 0,
1653
+ });
1654
+ if (action === 'status') continue;
1655
+ if (action === 'llm-launch') {
1656
+ const promptText = buildPortLlmPromptText({ targetRepoRoot });
1657
+ // eslint-disable-next-line no-console
1658
+ console.log('');
1659
+ // eslint-disable-next-line no-console
1660
+ console.log(bold('[monorepo] launching LLM...'));
1661
+ const res = await launchLlmAssistant({
1662
+ rl,
1663
+ title: 'hstack port conflict',
1664
+ subtitle: 'Resolve current git am conflict',
1665
+ promptText,
1666
+ cwd: targetRepoRoot,
1667
+ });
1668
+ if (!res.ok) {
1669
+ // eslint-disable-next-line no-console
1670
+ console.log(`${yellow('!')} Could not auto-launch an LLM (${res.reason || 'unknown'}).`);
1671
+ }
1672
+ continue;
1673
+ }
1674
+ if (action === 'llm') {
1675
+ const llmFlags = new Set([...(attemptFlags ?? []), '--copy']);
1676
+ await cmdPortLlm({ kv: attemptKv, flags: llmFlags, json: false });
1677
+ continue;
1678
+ }
1679
+ if (action === 'abort') {
1680
+ await runCapture('git', ['am', '--abort'], { cwd: targetRepoRoot });
1681
+ await deletePortPlan(targetRepoRoot);
1682
+ throw new Error('[monorepo] guide aborted (git am --abort)');
1683
+ }
1684
+ if (action === 'skip') {
1685
+ await runCapture('git', ['am', '--skip'], { cwd: targetRepoRoot });
1686
+ continue;
1687
+ }
1688
+ if (action === 'quit') {
1689
+ throw new Error('[monorepo] guide stopped (git am still in progress). Run `hstack monorepo port status` / `... continue` to proceed.');
1690
+ }
1691
+ if (action === 'stage-continue') {
1692
+ const stageFlags = new Set([...(attemptFlags ?? []), '--stage']);
1693
+ await cmdPortContinue({ kv: attemptKv, flags: stageFlags, json: false });
1694
+ continue;
1695
+ }
1696
+ await cmdPortContinue({ kv: attemptKv, flags: attemptFlags, json: false });
1697
+ }
1698
+ // If am completed, fall through to resume remaining patches below.
1699
+ }
1700
+
1701
+ // If we're not in a conflict, just resume applying the remaining patches onto the current branch.
1702
+ try {
1703
+ const { flags: rFlags, kv: rKv } = parseArgs(attemptArgv);
1704
+ const jsonWanted = wantsJson(attemptArgv, { flags: rFlags });
1705
+ await cmdPortRun({ argv: attemptArgv, flags: rFlags, kv: rKv, json: jsonWanted });
1706
+ } catch (e) {
1707
+ const inProgressAfter = await isGitAmInProgress(targetRepoRoot);
1708
+ if (!inProgressAfter) throw e;
1709
+ // Once the port is paused on conflicts, the conflict loop above will handle it on the next rerun.
1710
+ throw e;
1711
+ }
1712
+
1713
+ await deletePortPlan(targetRepoRoot);
1714
+ // eslint-disable-next-line no-console
1715
+ console.log(`${green('[monorepo]')} guide complete`);
1716
+ return;
1717
+ }
1718
+
1719
+ await ensureCleanGitWorktree(targetRepoRoot);
1720
+ await ensureNoGitAmInProgress(targetRepoRoot);
1721
+
1722
+ const baseDefault = await resolveDefaultTargetBaseRef(targetRepoRoot);
1723
+ const baseArg = (kv.get('--base') ?? '').trim();
1724
+ // Don't prompt unless we truly can't infer.
1725
+ const base = baseArg || baseDefault || 'origin/main';
1726
+ if (!baseArg && !baseDefault) {
1727
+ throw new Error('[monorepo] could not infer a target base ref. Pass --base=<ref>.');
1728
+ }
1729
+
1730
+ const branchArg = (kv.get('--branch') ?? '').trim();
1731
+ const branch = branchArg || (await prompt(rl, 'New branch name: ', { defaultValue: `port/${Date.now()}` })).trim();
1732
+
1733
+ const use3wayArg = flags?.has?.('--3way') === true;
1734
+ const use3way = use3wayArg
1735
+ ? true
1736
+ : (await promptSelect(rl, {
1737
+ title: 'Use 3-way merge (recommended)?',
1738
+ options: [
1739
+ { label: 'yes (recommended)', value: true },
1740
+ { label: 'no', value: false },
1741
+ ],
1742
+ defaultIndex: 0,
1743
+ })) === true;
1744
+
1745
+ const fromHappyArg = (kv.get('--from-happy') ?? '').trim();
1746
+ const fromHappyRef = (kv.get('--from-happy-ref') ?? '').trim();
1747
+ const fromHappyCliArg = (kv.get('--from-happy-cli') ?? '').trim();
1748
+ const fromHappyServerArg = (kv.get('--from-happy-server') ?? '').trim();
1749
+ const hasAnySourceArg = Boolean(fromHappyArg || fromHappyCliArg || fromHappyServerArg);
1750
+
1751
+ const fromHappy =
1752
+ fromHappyArg ||
1753
+ (hasAnySourceArg
1754
+ ? ''
1755
+ : (await prompt(rl, 'Path or GitHub PR URL for old happy (UI) [optional]: ', { defaultValue: '' })).trim());
1756
+ const fromHappyBaseArg = (kv.get('--from-happy-base') ?? '').trim();
1757
+ let fromHappyBase = '';
1758
+ if (fromHappy) {
1759
+ if (fromHappyBaseArg) {
1760
+ fromHappyBase = fromHappyBaseArg;
1761
+ } else if (looksLikeUrlSpec(fromHappy)) {
1762
+ fromHappyBase = 'origin/main';
1763
+ } else {
1764
+ const root = await resolveGitRoot(fromHappy);
1765
+ fromHappyBase = (root && (await resolveDefaultBaseRef(root))) || '';
1766
+ }
1767
+ if (!fromHappyBase) {
1768
+ fromHappyBase = (await prompt(rl, 'old happy base ref: ', { defaultValue: 'upstream/main' })).trim();
1769
+ }
1770
+ }
1771
+
1772
+ const fromHappyCliRef = (kv.get('--from-happy-cli-ref') ?? '').trim();
1773
+ const fromHappyCli =
1774
+ fromHappyCliArg ||
1775
+ (hasAnySourceArg
1776
+ ? ''
1777
+ : (await prompt(rl, 'Path or GitHub PR URL for old happy-cli [optional]: ', { defaultValue: '' })).trim());
1778
+ const fromHappyCliBaseArg = (kv.get('--from-happy-cli-base') ?? '').trim();
1779
+ let fromHappyCliBase = '';
1780
+ if (fromHappyCli) {
1781
+ if (fromHappyCliBaseArg) {
1782
+ fromHappyCliBase = fromHappyCliBaseArg;
1783
+ } else if (looksLikeUrlSpec(fromHappyCli)) {
1784
+ fromHappyCliBase = 'origin/main';
1785
+ } else {
1786
+ const root = await resolveGitRoot(fromHappyCli);
1787
+ fromHappyCliBase = (root && (await resolveDefaultBaseRef(root))) || '';
1788
+ }
1789
+ if (!fromHappyCliBase) {
1790
+ fromHappyCliBase = (await prompt(rl, 'old happy-cli base ref: ', { defaultValue: 'upstream/main' })).trim();
1791
+ }
1792
+ }
1793
+
1794
+ const fromHappyServerRef = (kv.get('--from-happy-server-ref') ?? '').trim();
1795
+ const fromHappyServer =
1796
+ fromHappyServerArg ||
1797
+ (hasAnySourceArg
1798
+ ? ''
1799
+ : (await prompt(rl, 'Path or GitHub PR URL for old happy-server [optional]: ', { defaultValue: '' })).trim());
1800
+ const fromHappyServerBaseArg = (kv.get('--from-happy-server-base') ?? '').trim();
1801
+ let fromHappyServerBase = '';
1802
+ if (fromHappyServer) {
1803
+ if (fromHappyServerBaseArg) {
1804
+ fromHappyServerBase = fromHappyServerBaseArg;
1805
+ } else if (looksLikeUrlSpec(fromHappyServer)) {
1806
+ fromHappyServerBase = 'origin/main';
1807
+ } else {
1808
+ const root = await resolveGitRoot(fromHappyServer);
1809
+ fromHappyServerBase = (root && (await resolveDefaultBaseRef(root))) || '';
1810
+ }
1811
+ if (!fromHappyServerBase) {
1812
+ fromHappyServerBase = (await prompt(rl, 'old happy-server base ref: ', { defaultValue: 'upstream/main' })).trim();
1813
+ }
1814
+ }
1815
+
1816
+ if (!fromHappy && !fromHappyCli && !fromHappyServer) {
1817
+ throw new Error('[monorepo] guide: nothing to port. Provide at least one source path.');
1818
+ }
1819
+
1820
+ section('Plan');
1821
+ noteLine(`${dim('target:')} ${targetRepoRoot}`);
1822
+ noteLine(`${dim('base:')} ${base}`);
1823
+ noteLine(`${dim('branch:')} ${branch}`);
1824
+ noteLine(`${dim('3-way:')} ${use3way ? green('enabled') : yellow('disabled')}`);
1825
+ // eslint-disable-next-line no-console
1826
+ console.log('');
1827
+ // eslint-disable-next-line no-console
1828
+ console.log(bold('Sources:'));
1829
+ if (fromHappy) noteLine(`- ${cyan('happy')} ${fromHappy} ${dim(`(base=${fromHappyBase})`)}`);
1830
+ if (fromHappyCli) noteLine(`- ${cyan('happy-cli')} ${fromHappyCli} ${dim(`(base=${fromHappyCliBase})`)}`);
1831
+ if (fromHappyServer) noteLine(`- ${cyan('happy-server')} ${fromHappyServer} ${dim(`(base=${fromHappyServerBase})`)}`);
1832
+
1833
+ const sources = [
1834
+ ...(fromHappy ? [{ label: 'from-happy', path: fromHappy, base: fromHappyBase, ref: fromHappyRef }] : []),
1835
+ ...(fromHappyCli ? [{ label: 'from-happy-cli', path: fromHappyCli, base: fromHappyCliBase, ref: fromHappyCliRef }] : []),
1836
+ ...(fromHappyServer
1837
+ ? [{ label: 'from-happy-server', path: fromHappyServer, base: fromHappyServerBase, ref: fromHappyServerRef }]
1838
+ : []),
1839
+ ];
1840
+
1841
+ section('Preflight');
1842
+ const preflight = await runPortPreflightData({ targetRepoRoot, baseRef: base, threeWay: use3way, sources });
1843
+ // eslint-disable-next-line no-console
1844
+ console.log(
1845
+ preflight.ok
1846
+ ? `${green('[monorepo]')} preflight: no conflicts detected`
1847
+ : `${yellow('[monorepo]')} preflight: conflicts detected ${dim(`(${preflight.sourcesWithFailures} source(s); may cascade)`)}`
1848
+ );
1849
+ const previewLines = summarizePreflightFailures(preflight);
1850
+ if (previewLines.length) {
1851
+ section('First likely conflict (preview)');
1852
+ for (const l of previewLines) {
1853
+ // eslint-disable-next-line no-console
1854
+ console.log(l.startsWith(' ') ? dim(l) : l);
1855
+ }
1856
+ // eslint-disable-next-line no-console
1857
+ console.log('');
1858
+ // eslint-disable-next-line no-console
1859
+ console.log(
1860
+ dim(
1861
+ 'Tip: If the first patch fails, many later patches can fail in preflight too (cascading). ' +
1862
+ 'In the real port run, git am stops at the first conflict — resolve it first, then continue.'
1863
+ )
1864
+ );
1865
+ }
1866
+
1867
+ const allowAutoLlm = String(process.env.HAPPIER_STACK_DISABLE_LLM_AUTOEXEC ?? '').trim() !== '1';
1868
+ const canAutoLaunchLlm = allowAutoLlm && (await detectInstalledLlmTools({ onlyAutoExec: true })).length > 0;
1869
+ let preferredConflictMode = 'guided';
1870
+ if (!preflight.ok) {
1871
+ preferredConflictMode = await promptSelect(rl, {
1872
+ title:
1873
+ `${bold('Preflight detected conflicts')}\n` +
1874
+ `${dim('How do you want to proceed? (You can still change your mind later in the conflict loop.)')}`,
1875
+ options: [
1876
+ ...(canAutoLaunchLlm
1877
+ ? [{ label: `${green('LLM (recommended)')} — run the port and resolve conflicts automatically`, value: 'llm' }]
1878
+ : []),
1879
+ { label: `${cyan('guided')} — resolve conflicts manually as they occur`, value: 'guided' },
1880
+ { label: `${dim('quit')} — exit without starting the port`, value: 'quit' },
1881
+ ],
1882
+ defaultIndex: canAutoLaunchLlm ? 0 : 0,
1883
+ });
1884
+ }
1885
+ if (preferredConflictMode === 'quit') {
1886
+ throw new Error('[monorepo] guide cancelled (no changes made).');
1887
+ }
1888
+
1889
+ const baseSourceArgs = [
1890
+ ...(fromHappy ? [`--from-happy=${fromHappy}`, `--from-happy-base=${fromHappyBase}`, ...(fromHappyRef ? [`--from-happy-ref=${fromHappyRef}`] : [])] : []),
1891
+ ...(fromHappyCli
1892
+ ? [
1893
+ `--from-happy-cli=${fromHappyCli}`,
1894
+ `--from-happy-cli-base=${fromHappyCliBase}`,
1895
+ ...(fromHappyCliRef ? [`--from-happy-cli-ref=${fromHappyCliRef}`] : []),
1896
+ ]
1897
+ : []),
1898
+ ...(fromHappyServer
1899
+ ? [
1900
+ `--from-happy-server=${fromHappyServer}`,
1901
+ `--from-happy-server-base=${fromHappyServerBase}`,
1902
+ ...(fromHappyServerRef ? [`--from-happy-server-ref=${fromHappyServerRef}`] : []),
1903
+ ]
1904
+ : []),
1905
+ ];
1906
+
1907
+ const initialArgv = [
1908
+ 'port',
1909
+ `--target=${targetRepoRoot}`,
1910
+ `--branch=${branch}`,
1911
+ `--base=${base}`,
1912
+ ...(use3way ? ['--3way'] : []),
1913
+ ...baseSourceArgs,
1914
+ ...(json ? ['--json'] : []),
1915
+ ];
1916
+
1917
+ const resumeArgv = [
1918
+ 'port',
1919
+ `--target=${targetRepoRoot}`,
1920
+ '--onto-current',
1921
+ ...(use3way ? ['--3way'] : []),
1922
+ ...baseSourceArgs,
1923
+ ...(json ? ['--json'] : []),
1924
+ ];
1925
+
1926
+ await writePortPlan(targetRepoRoot, {
1927
+ version: 2,
1928
+ createdAt: new Date().toISOString(),
1929
+ targetRepoRoot,
1930
+ base,
1931
+ branch,
1932
+ use3way,
1933
+ preferredConflictMode,
1934
+ sources,
1935
+ initialArgv,
1936
+ resumeArgv,
1937
+ });
1938
+
1939
+ // eslint-disable-next-line no-console
1940
+ console.log('');
1941
+ // eslint-disable-next-line no-console
1942
+ console.log(`${bold('[monorepo]')} guide: starting port ${dim(`(${branch})`)}`);
1943
+
1944
+ let llmLaunched = false;
1945
+ let first = true;
1946
+ while (true) {
1947
+ const attemptArgv = first ? initialArgv : resumeArgv;
1948
+ first = false;
1949
+ const { flags: attemptFlags, kv: attemptKv } = parseArgs(attemptArgv);
1950
+ const jsonWanted = wantsJson(attemptArgv, { flags: attemptFlags });
1951
+ try {
1952
+ // eslint-disable-next-line no-await-in-loop
1953
+ await cmdPortRun({ argv: attemptArgv, flags: attemptFlags, kv: attemptKv, json: jsonWanted });
1954
+ break;
1955
+ } catch (e) {
1956
+ // If we stopped because of a git am conflict, drive an interactive resolution loop.
1957
+ // Otherwise, rethrow.
1958
+ // eslint-disable-next-line no-await-in-loop
1959
+ const inProgress = await isGitAmInProgress(targetRepoRoot);
1960
+ if (!inProgress) {
1961
+ throw e;
1962
+ }
1963
+
1964
+ // eslint-disable-next-line no-console
1965
+ console.log('');
1966
+ // eslint-disable-next-line no-console
1967
+ console.log(`${yellow('[monorepo]')} guide: conflict detected`);
1968
+ // eslint-disable-next-line no-console
1969
+ console.log(dim('[monorepo] guide: waiting for conflict resolution'));
1970
+
1971
+ while (await isGitAmInProgress(targetRepoRoot)) {
1972
+ // eslint-disable-next-line no-await-in-loop
1973
+ await cmdPortStatus({ kv: attemptKv, json: false });
1974
+
1975
+ if (preferredConflictMode === 'llm' && canAutoLaunchLlm && !llmLaunched) {
1976
+ const promptText = buildPortGuideLlmPromptText({ targetRepoRoot, initialCommandArgs: initialArgv });
1977
+ // eslint-disable-next-line no-console
1978
+ console.log('');
1979
+ // eslint-disable-next-line no-console
1980
+ console.log(bold('[monorepo] launching LLM to resolve conflicts...'));
1981
+ // eslint-disable-next-line no-await-in-loop
1982
+ const res = await launchLlmAssistant({
1983
+ rl,
1984
+ title: 'hstack monorepo port',
1985
+ subtitle: 'Resolve conflicts and complete the port',
1986
+ promptText,
1987
+ cwd: targetRepoRoot,
1988
+ allowRunHere: true,
1989
+ allowCopyOnly: true,
1990
+ });
1991
+ llmLaunched = true;
1992
+
1993
+ if (!res.ok) {
1994
+ // eslint-disable-next-line no-console
1995
+ console.log(`${yellow('!')} Could not auto-launch an LLM (${res.reason || 'unknown'}).`);
1996
+ } else if (res.mode === 'new-terminal') {
1997
+ // eslint-disable-next-line no-console
1998
+ console.log('');
1999
+ // eslint-disable-next-line no-console
2000
+ console.log(`${bold('Press Enter')} once the LLM finishes to re-check status.`);
2001
+ // eslint-disable-next-line no-await-in-loop
2002
+ await prompt(rl, '', { defaultValue: '' });
2003
+ } else if (res.mode === 'copy') {
2004
+ // eslint-disable-next-line no-console
2005
+ console.log('');
2006
+ // eslint-disable-next-line no-console
2007
+ console.log(`${bold('Press Enter')} once you finish running the prompt to re-check status.`);
2008
+ // eslint-disable-next-line no-await-in-loop
2009
+ await prompt(rl, '', { defaultValue: '' });
2010
+ }
2011
+ continue;
2012
+ }
2013
+
2014
+ const action = await promptSelect(rl, {
2015
+ title: bold('Resolve conflicts, then choose an action:'),
2016
+ options: [
2017
+ { label: `${green('continue')} (git am --continue)`, value: 'continue' },
2018
+ { label: `${green('stage + continue')} ${dim('(git add conflicted files, then continue)')}`, value: 'stage-continue' },
2019
+ { label: `${cyan('show status again')}`, value: 'status' },
2020
+ ...(canAutoLaunchLlm ? [{ label: `${green('launch LLM now')} ${dim('(recommended)')}`, value: 'llm-launch' }] : []),
2021
+ { label: `${cyan('llm prompt')} ${dim('(copy/paste)')}`, value: 'llm' },
2022
+ { label: `${yellow('skip current patch')} (git am --skip)`, value: 'skip' },
2023
+ { label: `${red('abort')} (git am --abort)`, value: 'abort' },
2024
+ { label: `${dim('quit guide (leave state as-is)')}`, value: 'quit' },
2025
+ ],
2026
+ defaultIndex: 0,
2027
+ });
2028
+
2029
+ if (action === 'status') {
2030
+ continue;
2031
+ }
2032
+ if (action === 'llm-launch') {
2033
+ const promptText = buildPortLlmPromptText({ targetRepoRoot });
2034
+ // eslint-disable-next-line no-console
2035
+ console.log('');
2036
+ // eslint-disable-next-line no-console
2037
+ console.log(bold('[monorepo] launching LLM...'));
2038
+ // eslint-disable-next-line no-await-in-loop
2039
+ const res = await launchLlmAssistant({
2040
+ rl,
2041
+ title: 'hstack port conflict',
2042
+ subtitle: 'Resolve current git am conflict',
2043
+ promptText,
2044
+ cwd: targetRepoRoot,
2045
+ });
2046
+ if (!res.ok) {
2047
+ // eslint-disable-next-line no-console
2048
+ console.log(`${yellow('!')} Could not auto-launch an LLM (${res.reason || 'unknown'}).`);
2049
+ }
2050
+ continue;
2051
+ }
2052
+ if (action === 'llm') {
2053
+ const llmFlags = new Set([...(attemptFlags ?? []), '--copy']);
2054
+ // eslint-disable-next-line no-await-in-loop
2055
+ await cmdPortLlm({ kv: attemptKv, flags: llmFlags, json: false });
2056
+ continue;
2057
+ }
2058
+ if (action === 'abort') {
2059
+ await runCapture('git', ['am', '--abort'], { cwd: targetRepoRoot });
2060
+ await deletePortPlan(targetRepoRoot);
2061
+ throw new Error('[monorepo] guide aborted (git am --abort)');
2062
+ }
2063
+ if (action === 'skip') {
2064
+ await runCapture('git', ['am', '--skip'], { cwd: targetRepoRoot });
2065
+ continue;
2066
+ }
2067
+ if (action === 'quit') {
2068
+ throw new Error('[monorepo] guide stopped (git am still in progress). Run `hstack monorepo port status` / `... continue` to proceed.');
2069
+ }
2070
+
2071
+ if (action === 'stage-continue') {
2072
+ const stageFlags = new Set([...(attemptFlags ?? []), '--stage']);
2073
+ // eslint-disable-next-line no-await-in-loop
2074
+ await cmdPortContinue({ kv: attemptKv, flags: stageFlags, json: false });
2075
+ continue;
2076
+ }
2077
+
2078
+ // continue
2079
+ // eslint-disable-next-line no-await-in-loop
2080
+ await cmdPortContinue({ kv: attemptKv, flags: attemptFlags, json: false });
2081
+ }
2082
+ }
2083
+ }
2084
+
2085
+ await deletePortPlan(targetRepoRoot);
2086
+ // eslint-disable-next-line no-console
2087
+ console.log(`${green('[monorepo]')} guide complete`);
2088
+ });
2089
+ }
2090
+
2091
+ async function cmdPortLlm({ kv, flags, json }) {
2092
+ const targetRepoRoot = await resolveTargetRepoRootFromArgs({ kv });
2093
+ const promptText = buildPortLlmPromptText({ targetRepoRoot });
2094
+ const tools = await detectInstalledLlmTools();
2095
+
2096
+ if (json) {
2097
+ printResult({ json, data: { targetRepoRoot, prompt: promptText, detectedTools: tools.map((t) => t.id) } });
2098
+ return;
2099
+ }
2100
+
2101
+ const wantsLaunch = flags?.has?.('--launch') || process.argv.includes('--launch');
2102
+ if (wantsLaunch) {
2103
+ const launched = await launchLlmAssistant({
2104
+ title: 'hstack monorepo port (LLM)',
2105
+ subtitle: 'Runs the port + resolves conflicts (one patch at a time).',
2106
+ promptText,
2107
+ cwd: targetRepoRoot,
2108
+ env: process.env,
2109
+ allowRunHere: true,
2110
+ allowCopyOnly: true,
2111
+ });
2112
+ if (!launched.ok) {
2113
+ // eslint-disable-next-line no-console
2114
+ console.log(dim(`[monorepo] LLM launch unavailable: ${launched.reason || 'unknown'}`));
2115
+ // fall through to printing the prompt
2116
+ } else if (launched.launched) {
2117
+ return;
2118
+ }
2119
+ }
2120
+
2121
+ // eslint-disable-next-line no-console
2122
+ console.log('');
2123
+ // eslint-disable-next-line no-console
2124
+ console.log(bold('[monorepo] LLM prompt (copy/paste):'));
2125
+ // eslint-disable-next-line no-console
2126
+ console.log('');
2127
+ // eslint-disable-next-line no-console
2128
+ console.log(promptText);
2129
+ if (tools.length) {
2130
+ // eslint-disable-next-line no-console
2131
+ console.log('');
2132
+ // eslint-disable-next-line no-console
2133
+ console.log(dim(`[monorepo] detected LLM CLIs: ${tools.map((t) => t.id).join(', ')}`));
2134
+ }
2135
+
2136
+ const wantsCopy = flags?.has?.('--copy') || process.argv.includes('--copy');
2137
+ if (wantsCopy && (await clipboardAvailable())) {
2138
+ const res = await copyTextToClipboard(promptText);
2139
+ // eslint-disable-next-line no-console
2140
+ console.log(res.ok ? green('✓ Copied to clipboard') : dim(`(Clipboard copy failed: ${res.reason || 'unknown'})`));
2141
+ } else if (wantsCopy) {
2142
+ // eslint-disable-next-line no-console
2143
+ console.log(dim('(Clipboard copy unavailable on this system)'));
2144
+ }
2145
+ }
2146
+
2147
+ async function main() {
2148
+ const argv = process.argv.slice(2);
2149
+ const helpSepIdx = argv.indexOf('--');
2150
+ const helpScopeArgv = helpSepIdx === -1 ? argv : argv.slice(0, helpSepIdx);
2151
+ const { flags, kv } = parseArgs(helpScopeArgv);
2152
+ const json = wantsJson(helpScopeArgv, { flags });
2153
+
2154
+ const positionals = helpScopeArgv.filter((a) => a && a !== '--' && !a.startsWith('-'));
2155
+ const cmd = positionals[0] || 'help';
2156
+ const sub = positionals[1] || '';
2157
+ const wantsHelpFlag = wantsHelp(helpScopeArgv, { flags });
2158
+
2159
+ const usageByPath = new Map([
2160
+ [
2161
+ 'port',
2162
+ ['hstack monorepo port --target=/abs/path/to/monorepo [--clone-target] [--target-repo=<git-url>] [--branch=port/<name>] [--base=<ref>] [--onto-current] [--dry-run] [--3way] [--skip-applied] [--continue-on-failure] [--json]'],
2163
+ ],
2164
+ ['port guide', ['hstack monorepo port guide [--target=/abs/path/to/monorepo] [--clone-target] [--target-repo=<git-url>] [--json]']],
2165
+ ['port preflight', ['hstack monorepo port preflight --target=/abs/path/to/monorepo [--base=<ref>] [--3way] [--json]']],
2166
+ ['port status', ['hstack monorepo port status [--target=/abs/path/to/monorepo] [--json]']],
2167
+ ['port continue', ['hstack monorepo port continue [--target=/abs/path/to/monorepo] [--json]']],
2168
+ [
2169
+ 'port llm',
2170
+ [
2171
+ 'hstack monorepo port llm --target=/abs/path/to/monorepo [--copy] [--launch] [--json]',
2172
+ ' [--from-happy=/abs/path/to/old-happy --from-happy-base=<ref> --from-happy-ref=<ref>]',
2173
+ ' [--from-happy-cli=/abs/path/to/old-happy-cli --from-happy-cli-base=<ref> --from-happy-cli-ref=<ref>]',
2174
+ ' [--from-happy-server=/abs/path/to/old-happy-server --from-happy-server-base=<ref> --from-happy-server-ref=<ref>]',
2175
+ ],
2176
+ ],
2177
+ ]);
2178
+
2179
+ if (wantsHelpFlag && cmd !== 'help') {
2180
+ const key = sub ? `${cmd} ${sub}` : cmd;
2181
+ const lines = usageByPath.get(key);
2182
+ if (lines?.length) {
2183
+ printResult({
2184
+ json,
2185
+ data: { ok: true, cmd, sub: sub || null, usage: lines[0] ?? null },
2186
+ text: [
2187
+ `[monorepo ${key}] usage:`,
2188
+ ...lines.map((l) => ` ${l}`),
2189
+ '',
2190
+ 'see also:',
2191
+ ' hstack monorepo --help',
2192
+ ].join('\n'),
2193
+ });
2194
+ return;
2195
+ }
2196
+ }
2197
+
2198
+ if (wantsHelpFlag || cmd === 'help') {
2199
+ printResult({ json, data: {}, text: usage() });
2200
+ return;
2201
+ }
2202
+
2203
+ if (cmd !== 'port') {
2204
+ throw new Error(`[monorepo] unknown subcommand: ${cmd} (expected: port)`);
2205
+ }
2206
+
2207
+ if (sub === 'status') {
2208
+ await cmdPortStatus({ kv, json });
2209
+ return;
2210
+ }
2211
+ if (sub === 'continue') {
2212
+ await cmdPortContinue({ kv, flags, json });
2213
+ return;
2214
+ }
2215
+ if (sub === 'preflight') {
2216
+ await cmdPortPreflight({ argv, flags, kv, json });
2217
+ return;
2218
+ }
2219
+ if (sub === 'guide') {
2220
+ await cmdPortGuide({ kv, flags, json });
2221
+ return;
2222
+ }
2223
+ if (sub === 'llm') {
2224
+ await cmdPortLlm({ kv, flags, json });
2225
+ return;
2226
+ }
2227
+
2228
+ await cmdPortRun({ argv, flags, kv, json });
2229
+ }
2230
+
2231
+ main().catch((err) => {
2232
+ console.error('[monorepo] failed:', err);
2233
+ process.exit(1);
2234
+ });