@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.
- package/README.md +501 -0
- package/bin/hstack.mjs +348 -0
- package/docs/codex-mcp-resume.md +129 -0
- package/docs/edison.md +74 -0
- package/docs/forking-and-branding.md +189 -0
- package/docs/happy-development.md +22 -0
- package/docs/isolated-linux-vm.md +243 -0
- package/docs/menubar.md +244 -0
- package/docs/mobile-ios.md +322 -0
- package/docs/monorepo-migration.md +20 -0
- package/docs/paths-and-env.md +154 -0
- package/docs/remote-access.md +43 -0
- package/docs/server-flavors.md +147 -0
- package/docs/stacks.md +330 -0
- package/docs/tauri.md +60 -0
- package/docs/worktrees-and-forks.md +133 -0
- package/extras/swiftbar/auth-login.sh +29 -0
- package/extras/swiftbar/git-cache-refresh.sh +122 -0
- package/extras/swiftbar/hstack-term.sh +133 -0
- package/extras/swiftbar/hstack.5s.sh +296 -0
- package/extras/swiftbar/hstack.sh +35 -0
- package/extras/swiftbar/icons/happy-green.png +0 -0
- package/extras/swiftbar/icons/happy-orange.png +0 -0
- package/extras/swiftbar/icons/happy-red.png +0 -0
- package/extras/swiftbar/icons/logo-white.png +0 -0
- package/extras/swiftbar/install.sh +265 -0
- package/extras/swiftbar/lib/git.sh +629 -0
- package/extras/swiftbar/lib/icons.sh +92 -0
- package/extras/swiftbar/lib/render.sh +999 -0
- package/extras/swiftbar/lib/system.sh +244 -0
- package/extras/swiftbar/lib/utils.sh +717 -0
- package/extras/swiftbar/set-interval.sh +65 -0
- package/extras/swiftbar/set-server-flavor.sh +61 -0
- package/extras/swiftbar/wt-pr.sh +140 -0
- package/node_modules/@happier-dev/cli-common/README.md +6 -0
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts +4 -0
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/index.js +4 -0
- package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.d.ts +18 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.js +25 -0
- package/node_modules/@happier-dev/cli-common/dist/links/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links.d.ts +2 -0
- package/node_modules/@happier-dev/cli-common/dist/links.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/links.js +2 -0
- package/node_modules/@happier-dev/cli-common/dist/links.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.d.ts +67 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.js +259 -0
- package/node_modules/@happier-dev/cli-common/dist/update/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.d.ts +17 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.js +80 -0
- package/node_modules/@happier-dev/cli-common/dist/workspaces/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/package.json +26 -0
- package/package.json +77 -0
- package/scripts/auth.mjs +1829 -0
- package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +90 -0
- package/scripts/auth_copy_from_runCapture.integration.test.mjs +447 -0
- package/scripts/auth_help_cmd.test.mjs +28 -0
- package/scripts/auth_login_flow_in_tty.test.mjs +100 -0
- package/scripts/auth_login_force_default.test.mjs +66 -0
- package/scripts/auth_login_guided_server_no_expo.test.mjs +126 -0
- package/scripts/auth_login_method_override.test.mjs +67 -0
- package/scripts/auth_login_print_includes_configure_links.test.mjs +99 -0
- package/scripts/auth_status_server_validation.integration.test.mjs +140 -0
- package/scripts/build.mjs +266 -0
- package/scripts/bundleWorkspaceDeps.mjs +38 -0
- package/scripts/bundleWorkspaceDeps.test.mjs +77 -0
- package/scripts/ci.mjs +135 -0
- package/scripts/ci.test.mjs +50 -0
- package/scripts/cli-link.mjs +57 -0
- package/scripts/completion.mjs +395 -0
- package/scripts/contrib.mjs +333 -0
- package/scripts/daemon.mjs +1160 -0
- package/scripts/daemon.status_scope.test.mjs +51 -0
- package/scripts/daemon_cmd.mjs +26 -0
- package/scripts/daemon_dist_guard.test.mjs +171 -0
- package/scripts/daemon_invalid_auth_reseed_stack_name.integration.test.mjs +608 -0
- package/scripts/daemon_server_scoped_state.test.mjs +49 -0
- package/scripts/daemon_start_verification.integration.test.mjs +296 -0
- package/scripts/dev.mjs +545 -0
- package/scripts/doctor.mjs +340 -0
- package/scripts/doctor_cmd.test.mjs +22 -0
- package/scripts/doctor_ui_index_missing.test.mjs +37 -0
- package/scripts/eas.mjs +367 -0
- package/scripts/eas_platform_parsing.test.mjs +63 -0
- package/scripts/edison.mjs +1848 -0
- package/scripts/env.mjs +149 -0
- package/scripts/env_cmd.test.mjs +118 -0
- package/scripts/exit_cleanup_kills_detached_children_on_crash.integration.test.mjs +80 -0
- package/scripts/happier.mjs +82 -0
- package/scripts/import.mjs +1327 -0
- package/scripts/init.mjs +464 -0
- package/scripts/install.mjs +550 -0
- package/scripts/lint.mjs +177 -0
- package/scripts/menubar.mjs +202 -0
- package/scripts/migrate.mjs +318 -0
- package/scripts/mobile.mjs +353 -0
- package/scripts/mobile_dev_client.mjs +87 -0
- package/scripts/monorepo.mjs +2234 -0
- package/scripts/monorepo_port.apply.integration.test.mjs +680 -0
- package/scripts/monorepo_port.conflicts.integration.test.mjs +454 -0
- package/scripts/monorepo_port.validation.integration.test.mjs +486 -0
- package/scripts/orchestrated_stack_auth_flow.test.mjs +134 -0
- package/scripts/orchestrated_stack_auth_flow_resolve_port.test.mjs +98 -0
- package/scripts/orchestrated_stack_auth_flow_webapp_url.test.mjs +119 -0
- package/scripts/pack.mjs +257 -0
- package/scripts/pack.test.mjs +68 -0
- package/scripts/pglite_lock.integration.test.mjs +152 -0
- package/scripts/provision/linux-ubuntu-e2e.sh +132 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +66 -0
- package/scripts/provision/macos-lima-happy-vm.sh +192 -0
- package/scripts/provision/macos-lima-hstack-e2e.sh +100 -0
- package/scripts/release.mjs +53 -0
- package/scripts/release_binary_smoke.integration.test.mjs +159 -0
- package/scripts/review.mjs +1752 -0
- package/scripts/review_pr.mjs +435 -0
- package/scripts/run.mjs +561 -0
- package/scripts/run_script_with_stack_env.restart_port_reuse.test.mjs +30 -0
- package/scripts/self.mjs +465 -0
- package/scripts/self_host.mjs +9 -0
- package/scripts/self_host_binary_smoke.integration.test.mjs +94 -0
- package/scripts/self_host_runtime.mjs +883 -0
- package/scripts/self_host_runtime.test.mjs +82 -0
- package/scripts/self_host_systemd.real.integration.test.mjs +367 -0
- package/scripts/server_flavor.mjs +148 -0
- package/scripts/service.mjs +868 -0
- package/scripts/service_mode_help.test.mjs +27 -0
- package/scripts/setup.mjs +1324 -0
- package/scripts/setup_non_interactive_flag.test.mjs +60 -0
- package/scripts/setup_pr.mjs +605 -0
- package/scripts/setup_pr_orchestrated_auth_flow_util_import.test.mjs +117 -0
- package/scripts/stack/command_arguments.mjs +91 -0
- package/scripts/stack/copy_auth_from_stack.mjs +111 -0
- package/scripts/stack/delegated_script_commands.mjs +92 -0
- package/scripts/stack/help_text.mjs +110 -0
- package/scripts/stack/port_reservation.mjs +74 -0
- package/scripts/stack/repo_checkout_resolution.mjs +31 -0
- package/scripts/stack/run_script_with_stack_env.mjs +634 -0
- package/scripts/stack/stack_daemon_command.mjs +219 -0
- package/scripts/stack/stack_delegated_help.mjs +81 -0
- package/scripts/stack/stack_environment.mjs +151 -0
- package/scripts/stack/stack_environment.sanitization.test.mjs +75 -0
- package/scripts/stack/stack_happier_passthrough_command.mjs +63 -0
- package/scripts/stack/stack_info_snapshot.mjs +167 -0
- package/scripts/stack/stack_mobile_install_command.mjs +61 -0
- package/scripts/stack/stack_resume_command.mjs +76 -0
- package/scripts/stack/stack_stop_command.mjs +34 -0
- package/scripts/stack/stack_workspace_command.mjs +83 -0
- package/scripts/stack/transient_repo_overrides.mjs +29 -0
- package/scripts/stack.mjs +2388 -0
- package/scripts/stack_archive_cmd.integration.test.mjs +31 -0
- package/scripts/stack_audit_fix_light_env.test.mjs +129 -0
- package/scripts/stack_background_pinned_stack_json.test.mjs +81 -0
- package/scripts/stack_copy_auth_server_scoped.test.mjs +243 -0
- package/scripts/stack_daemon_cmd.integration.test.mjs +484 -0
- package/scripts/stack_eas_help.test.mjs +72 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +102 -0
- package/scripts/stack_env_cmd.test.mjs +107 -0
- package/scripts/stack_guided_login_bundle_error_parse.test.mjs +20 -0
- package/scripts/stack_guided_login_inner_invocation.test.mjs +46 -0
- package/scripts/stack_happy_cmd.integration.test.mjs +263 -0
- package/scripts/stack_info_snapshot_running_status.test.mjs +186 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +128 -0
- package/scripts/stack_monorepo_defaults.test.mjs +31 -0
- package/scripts/stack_monorepo_repo_dev_token.test.mjs +32 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +37 -0
- package/scripts/stack_new_name_normalize_cmd.test.mjs +38 -0
- package/scripts/stack_pr_name_normalize_cmd.test.mjs +84 -0
- package/scripts/stack_resume_cmd.integration.test.mjs +134 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +64 -0
- package/scripts/stack_shorthand_cmd.integration.test.mjs +74 -0
- package/scripts/stack_stop_sweeps_legacy_infra_without_kind.integration.test.mjs +44 -0
- package/scripts/stack_stop_sweeps_when_runtime_missing.integration.test.mjs +42 -0
- package/scripts/stack_stop_sweeps_when_runtime_stale.integration.test.mjs +50 -0
- package/scripts/stack_wt_list.test.mjs +117 -0
- package/scripts/start_ui_required_default.test.mjs +63 -0
- package/scripts/stop.mjs +190 -0
- package/scripts/stopStackWithEnv_no_autosweep_when_runtime_missing.integration.test.mjs +95 -0
- package/scripts/swiftbar_git_monorepo_cmd.test.mjs +75 -0
- package/scripts/swiftbar_render_monorepo_wt_actions.integration.test.mjs +116 -0
- package/scripts/swiftbar_utils_cmd.test.mjs +92 -0
- package/scripts/swiftbar_wt_pr_backcompat.test.mjs +162 -0
- package/scripts/systemd_unit_info.test.mjs +24 -0
- package/scripts/tailscale.mjs +490 -0
- package/scripts/test_ci.mjs +36 -0
- package/scripts/test_cmd.mjs +274 -0
- package/scripts/test_cmd.test.mjs +133 -0
- package/scripts/test_integration.mjs +33 -0
- package/scripts/testkit/auth_testkit.mjs +121 -0
- package/scripts/testkit/doctor_testkit.mjs +68 -0
- package/scripts/testkit/monorepo_port_testkit.mjs +157 -0
- package/scripts/testkit/stack_archive_command_testkit.mjs +55 -0
- package/scripts/testkit/stack_new_monorepo_testkit.mjs +83 -0
- package/scripts/testkit/stack_script_command_testkit.mjs +27 -0
- package/scripts/testkit/stack_stop_sweeps_testkit.mjs +172 -0
- package/scripts/testkit/worktrees_monorepo_testkit.mjs +53 -0
- package/scripts/tools.mjs +70 -0
- package/scripts/tui.mjs +914 -0
- package/scripts/tui_stopStackForTuiExit_no_autosweep.integration.test.mjs +95 -0
- package/scripts/typecheck.mjs +178 -0
- package/scripts/ui_gateway.mjs +247 -0
- package/scripts/uninstall.mjs +179 -0
- package/scripts/utils/auth/credentials_paths.mjs +181 -0
- package/scripts/utils/auth/credentials_paths.test.mjs +187 -0
- package/scripts/utils/auth/daemon_gate.mjs +66 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +116 -0
- package/scripts/utils/auth/decode_jwt_payload_unsafe.mjs +16 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/auth/files.mjs +56 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +86 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +56 -0
- package/scripts/utils/auth/handy_master_secret.mjs +42 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +70 -0
- package/scripts/utils/auth/login_ux.mjs +105 -0
- package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +291 -0
- package/scripts/utils/auth/sources.mjs +28 -0
- package/scripts/utils/auth/stable_scope_id.mjs +91 -0
- package/scripts/utils/auth/stable_scope_id.test.mjs +51 -0
- package/scripts/utils/auth/stack_guided_login.mjs +438 -0
- package/scripts/utils/cli/arg_values.mjs +23 -0
- package/scripts/utils/cli/arg_values.test.mjs +43 -0
- package/scripts/utils/cli/args.mjs +17 -0
- package/scripts/utils/cli/cli.mjs +24 -0
- package/scripts/utils/cli/cli_registry.mjs +440 -0
- package/scripts/utils/cli/cwd_scope.mjs +158 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +154 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +103 -0
- package/scripts/utils/cli/prereqs.test.mjs +33 -0
- package/scripts/utils/cli/progress.mjs +141 -0
- package/scripts/utils/cli/smoke_help.mjs +44 -0
- package/scripts/utils/cli/verbosity.mjs +11 -0
- package/scripts/utils/cli/wizard.mjs +139 -0
- package/scripts/utils/cli/wizard_promptSelect.test.mjs +44 -0
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +132 -0
- package/scripts/utils/cli/wizard_worktree_slug.test.mjs +33 -0
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/dev/daemon.mjs +232 -0
- package/scripts/utils/dev/daemon_watch_resilience.test.mjs +224 -0
- package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +35 -0
- package/scripts/utils/dev/expo_dev.mjs +478 -0
- package/scripts/utils/dev/expo_dev.test.mjs +89 -0
- package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +120 -0
- package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +60 -0
- package/scripts/utils/dev/server.mjs +180 -0
- package/scripts/utils/dev_auth_key.mjs +7 -0
- package/scripts/utils/edison/git_roots.mjs +30 -0
- package/scripts/utils/edison/git_roots.test.mjs +49 -0
- package/scripts/utils/env/config.mjs +52 -0
- package/scripts/utils/env/dotenv.mjs +32 -0
- package/scripts/utils/env/dotenv.test.mjs +32 -0
- package/scripts/utils/env/env.mjs +130 -0
- package/scripts/utils/env/env_file.mjs +98 -0
- package/scripts/utils/env/env_file.test.mjs +49 -0
- package/scripts/utils/env/env_local.mjs +25 -0
- package/scripts/utils/env/load_env_file.mjs +34 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/sandbox.mjs +13 -0
- package/scripts/utils/env/scrub_env.mjs +69 -0
- package/scripts/utils/env/scrub_env.test.mjs +102 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +65 -0
- package/scripts/utils/expo/expo.mjs +139 -0
- package/scripts/utils/expo/expo_state_running.test.mjs +48 -0
- package/scripts/utils/expo/metro_ports.mjs +101 -0
- package/scripts/utils/expo/metro_ports.test.mjs +35 -0
- package/scripts/utils/fs/atomic_dir_swap.mjs +55 -0
- package/scripts/utils/fs/atomic_dir_swap.test.mjs +54 -0
- package/scripts/utils/fs/file_has_content.mjs +10 -0
- package/scripts/utils/fs/fs.mjs +11 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/dev_checkout.mjs +127 -0
- package/scripts/utils/git/dev_checkout.test.mjs +115 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/parse_name_status_z.mjs +21 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/git/worktrees.mjs +323 -0
- package/scripts/utils/git/worktrees_monorepo.test.mjs +60 -0
- package/scripts/utils/git/worktrees_pathstyle.test.mjs +53 -0
- package/scripts/utils/llm/assist.mjs +260 -0
- package/scripts/utils/llm/codex_exec.mjs +61 -0
- package/scripts/utils/llm/codex_exec.test.mjs +46 -0
- package/scripts/utils/llm/hstack_runner.mjs +59 -0
- package/scripts/utils/llm/tools.mjs +56 -0
- package/scripts/utils/llm/tools.test.mjs +67 -0
- package/scripts/utils/menubar/swiftbar.mjs +121 -0
- package/scripts/utils/menubar/swiftbar.test.mjs +85 -0
- package/scripts/utils/mobile/config.mjs +35 -0
- package/scripts/utils/mobile/dev_client_links.mjs +59 -0
- package/scripts/utils/mobile/identifiers.mjs +46 -0
- package/scripts/utils/mobile/identifiers.test.mjs +41 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +131 -0
- package/scripts/utils/net/bind_mode.mjs +39 -0
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +110 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +29 -0
- package/scripts/utils/paths/canonical_home.mjs +15 -0
- package/scripts/utils/paths/canonical_home.test.mjs +28 -0
- package/scripts/utils/paths/localhost_host.mjs +112 -0
- package/scripts/utils/paths/localhost_host.test.mjs +58 -0
- package/scripts/utils/paths/paths.mjs +302 -0
- package/scripts/utils/paths/paths_env_win32.test.mjs +36 -0
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +50 -0
- package/scripts/utils/paths/runtime.mjs +41 -0
- package/scripts/utils/pglite_lock.mjs +107 -0
- package/scripts/utils/proc/commands.mjs +33 -0
- package/scripts/utils/proc/exit_cleanup.mjs +57 -0
- package/scripts/utils/proc/happy_monorepo_deps.mjs +37 -0
- package/scripts/utils/proc/happy_monorepo_deps.test.mjs +89 -0
- package/scripts/utils/proc/ownership.mjs +217 -0
- package/scripts/utils/proc/ownership_killProcessGroupOwnedByStack.test.mjs +216 -0
- package/scripts/utils/proc/ownership_listPidsWithEnvNeedles.test.mjs +88 -0
- package/scripts/utils/proc/package_scripts.mjs +38 -0
- package/scripts/utils/proc/package_scripts.test.mjs +58 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/proc/pm.mjs +478 -0
- package/scripts/utils/proc/pm_spawn.integration.test.mjs +131 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +313 -0
- package/scripts/utils/proc/proc.mjs +331 -0
- package/scripts/utils/proc/proc.test.mjs +85 -0
- package/scripts/utils/proc/terminate.mjs +69 -0
- package/scripts/utils/proc/terminate.test.mjs +54 -0
- package/scripts/utils/proc/watch.mjs +63 -0
- package/scripts/utils/review/augment_runner_integration.test.mjs +105 -0
- package/scripts/utils/review/base_ref.mjs +82 -0
- package/scripts/utils/review/base_ref.test.mjs +89 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +107 -0
- package/scripts/utils/review/detached_worktree.mjs +61 -0
- package/scripts/utils/review/detached_worktree.test.mjs +61 -0
- package/scripts/utils/review/findings.mjs +278 -0
- package/scripts/utils/review/findings.test.mjs +203 -0
- package/scripts/utils/review/head_slice.mjs +132 -0
- package/scripts/utils/review/head_slice.test.mjs +117 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/prompts.mjs +279 -0
- package/scripts/utils/review/prompts.test.mjs +77 -0
- package/scripts/utils/review/run_reviewers_safe.mjs +12 -0
- package/scripts/utils/review/run_reviewers_safe.test.mjs +45 -0
- package/scripts/utils/review/runners/augment.mjs +91 -0
- package/scripts/utils/review/runners/augment.test.mjs +64 -0
- package/scripts/utils/review/runners/claude.mjs +92 -0
- package/scripts/utils/review/runners/claude.test.mjs +47 -0
- package/scripts/utils/review/runners/coderabbit.mjs +105 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +32 -0
- package/scripts/utils/review/runners/codex.mjs +129 -0
- package/scripts/utils/review/runners/codex.test.mjs +115 -0
- package/scripts/utils/review/slice_mode.mjs +20 -0
- package/scripts/utils/review/slice_mode.test.mjs +69 -0
- package/scripts/utils/review/sliced_runner.mjs +39 -0
- package/scripts/utils/review/sliced_runner.test.mjs +57 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +41 -0
- package/scripts/utils/review/targets.mjs +23 -0
- package/scripts/utils/review/targets.test.mjs +31 -0
- package/scripts/utils/review/tool_home_seed.mjs +106 -0
- package/scripts/utils/review/tool_home_seed.test.mjs +124 -0
- package/scripts/utils/review/uncommitted_ops.mjs +77 -0
- package/scripts/utils/review/uncommitted_ops.test.mjs +117 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +105 -0
- package/scripts/utils/server/apply_server_light_env_defaults.mjs +14 -0
- package/scripts/utils/server/flavor_scripts.mjs +138 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +115 -0
- package/scripts/utils/server/infra/happy_server_infra.mjs +444 -0
- package/scripts/utils/server/mobile_api_url.mjs +60 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +58 -0
- package/scripts/utils/server/port.mjs +55 -0
- package/scripts/utils/server/prisma_import.mjs +36 -0
- package/scripts/utils/server/prisma_import.test.mjs +78 -0
- package/scripts/utils/server/server.mjs +109 -0
- package/scripts/utils/server/ui_build_check.mjs +37 -0
- package/scripts/utils/server/ui_build_check.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +13 -0
- package/scripts/utils/server/ui_env.test.mjs +57 -0
- package/scripts/utils/server/urls.mjs +100 -0
- package/scripts/utils/server/validate.mjs +60 -0
- package/scripts/utils/server/validate.test.mjs +76 -0
- package/scripts/utils/service/autostart_darwin.mjs +198 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +49 -0
- package/scripts/utils/service/autostart_darwin_keepalive.test.mjs +19 -0
- package/scripts/utils/stack/cli_identities.mjs +29 -0
- package/scripts/utils/stack/context.mjs +19 -0
- package/scripts/utils/stack/dirs.mjs +26 -0
- package/scripts/utils/stack/editor_workspace.mjs +126 -0
- package/scripts/utils/stack/interactive_stack_config.mjs +266 -0
- package/scripts/utils/stack/interactive_stack_config.port_validation.test.mjs +93 -0
- package/scripts/utils/stack/interactive_stack_config.remote_validation.test.mjs +122 -0
- package/scripts/utils/stack/interactive_stack_config.stack_name_validation.test.mjs +76 -0
- package/scripts/utils/stack/interactive_stack_config_testkit.mjs +18 -0
- package/scripts/utils/stack/names.mjs +27 -0
- package/scripts/utils/stack/names.test.mjs +26 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +40 -0
- package/scripts/utils/stack/startup.mjs +370 -0
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +119 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +20 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +79 -0
- package/scripts/utils/stack/startup_server_light_testkit.mjs +106 -0
- package/scripts/utils/stack/stop.mjs +284 -0
- package/scripts/utils/stack_context.mjs +1 -0
- package/scripts/utils/stack_runtime_state.mjs +1 -0
- package/scripts/utils/stacks.mjs +1 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/tauri/stack_overrides.mjs +22 -0
- package/scripts/utils/test/collect_test_files.mjs +29 -0
- package/scripts/utils/time/get_today_ymd.mjs +7 -0
- package/scripts/utils/tui/cleanup.mjs +38 -0
- package/scripts/utils/ui/ansi.mjs +47 -0
- package/scripts/utils/ui/browser.mjs +31 -0
- package/scripts/utils/ui/browser.test.mjs +56 -0
- package/scripts/utils/ui/clipboard.mjs +38 -0
- package/scripts/utils/ui/layout.mjs +44 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/terminal_launcher.mjs +129 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/update/auto_update_notice.mjs +93 -0
- package/scripts/utils/validate.mjs +5 -0
- package/scripts/where.mjs +138 -0
- package/scripts/worktrees.mjs +2174 -0
- package/scripts/worktrees_archive_cmd.integration.test.mjs +228 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +23 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +32 -0
- package/scripts/worktrees_monorepo_testkit.test.mjs +29 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +41 -0
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { 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 { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
9
|
+
import { run, runCapture } from './utils/proc/proc.mjs';
|
|
10
|
+
import { banner, bullets, cmd as cmdFmt, kv as kvFmt, sectionTitle } from './utils/ui/layout.mjs';
|
|
11
|
+
import { bold, cyan, dim, green, yellow } from './utils/ui/ansi.mjs';
|
|
12
|
+
import { coerceHappyMonorepoRootFromPath, getComponentRepoDir, getRootDir, getWorkspaceDir, isHappyMonorepoRoot, resolveStackEnvPath } from './utils/paths/paths.mjs';
|
|
13
|
+
import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
14
|
+
import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
|
|
15
|
+
import { sanitizeStackName } from './utils/stack/names.mjs';
|
|
16
|
+
import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
17
|
+
import { readEnvObjectFromFile } from './utils/env/read.mjs';
|
|
18
|
+
import { clipboardAvailable, copyTextToClipboard } from './utils/ui/clipboard.mjs';
|
|
19
|
+
import { detectInstalledLlmTools } from './utils/llm/tools.mjs';
|
|
20
|
+
import { launchLlmAssistant } from './utils/llm/assist.mjs';
|
|
21
|
+
import { buildhstackRunnerShellSnippet } from './utils/llm/hstack_runner.mjs';
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
return [
|
|
25
|
+
'[import] usage:',
|
|
26
|
+
' hstack import',
|
|
27
|
+
' hstack import inspect [--happy=<path|url>] [--happy-cli=<path|url>] [--happy-server=<path|url>] [--happy-server-light=<path|url>] [--yes] [--json]',
|
|
28
|
+
' hstack import apply --stack=<name> [--server=happy-server|happy-server-light] [--happy=<path|url>] [--happy-ref=<ref>] [--happy-cli=<path|url>] [--happy-cli-ref=<ref>] [--happy-server=<path|url>] [--happy-server-ref=<ref>] [--happy-server-light=<path|url>] [--happy-server-light-ref=<ref>] [--yes] [--json]',
|
|
29
|
+
' hstack import migrate [--stack=<name>]',
|
|
30
|
+
' hstack import llm [--mode=import|migrate] [--stack=<name>] [--copy] [--launch]',
|
|
31
|
+
' hstack import [--json]',
|
|
32
|
+
'',
|
|
33
|
+
'What it does:',
|
|
34
|
+
'- imports legacy split repos (happy / happy-cli / happy-server) into hstack by pinning stack component paths',
|
|
35
|
+
'- optionally ports commits into the Happier monorepo layout via `hstack monorepo port`',
|
|
36
|
+
'',
|
|
37
|
+
'Notes:',
|
|
38
|
+
'- This is for users who still have split repos/branches/PRs (pre-monorepo).',
|
|
39
|
+
'- Migration uses `git format-patch` + `git am` and may require conflict resolution.',
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function gitRoot(dir) {
|
|
44
|
+
const d = resolve(String(dir ?? '').trim());
|
|
45
|
+
if (!d) return '';
|
|
46
|
+
try {
|
|
47
|
+
return (await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd: d })).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function gitBranch(dir) {
|
|
54
|
+
try {
|
|
55
|
+
const b = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir })).trim();
|
|
56
|
+
return b && b !== 'HEAD' ? b : 'detached';
|
|
57
|
+
} catch {
|
|
58
|
+
return 'unknown';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function gitDirty(dir) {
|
|
63
|
+
try {
|
|
64
|
+
return Boolean((await runCapture('git', ['status', '--porcelain'], { cwd: dir })).trim());
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function gitOk(cwd, args) {
|
|
71
|
+
try {
|
|
72
|
+
await runCapture('git', args, { cwd });
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function listGitWorktrees(repoRoot) {
|
|
80
|
+
// `git worktree list --porcelain` includes the current worktree plus any additional worktrees.
|
|
81
|
+
const out = await runCapture('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
|
82
|
+
const lines = out.split(/\r?\n/);
|
|
83
|
+
const entries = [];
|
|
84
|
+
let cur = null;
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (!line.trim()) continue;
|
|
87
|
+
if (line.startsWith('worktree ')) {
|
|
88
|
+
if (cur) entries.push(cur);
|
|
89
|
+
cur = { path: line.slice('worktree '.length).trim(), branch: '', head: '' };
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!cur) continue;
|
|
93
|
+
if (line.startsWith('branch ')) {
|
|
94
|
+
const ref = line.slice('branch '.length).trim();
|
|
95
|
+
cur.branch = ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\//, '');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (line.startsWith('HEAD ')) {
|
|
99
|
+
cur.head = line.slice('HEAD '.length).trim();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (line.startsWith('detached')) {
|
|
103
|
+
cur.branch = 'detached';
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (cur) entries.push(cur);
|
|
108
|
+
|
|
109
|
+
// Normalize: ensure current worktree comes first and paths are absolute.
|
|
110
|
+
const normalized = entries
|
|
111
|
+
.map((e) => ({ ...e, path: resolve(e.path) }))
|
|
112
|
+
.filter((e) => e.path);
|
|
113
|
+
return normalized;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function listLocalBranches(repoRoot) {
|
|
117
|
+
try {
|
|
118
|
+
const out = await runCapture('git', ['for-each-ref', 'refs/heads', '--format=%(refname:short)'], { cwd: repoRoot });
|
|
119
|
+
return out
|
|
120
|
+
.split(/\r?\n/)
|
|
121
|
+
.map((s) => s.trim())
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.sort();
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function looksLikeGitUrl(raw) {
|
|
130
|
+
const s = String(raw ?? '').trim();
|
|
131
|
+
if (!s) return false;
|
|
132
|
+
if (s.startsWith('git@')) return true;
|
|
133
|
+
if (s.startsWith('https://') || s.startsWith('http://')) return true;
|
|
134
|
+
if (s.endsWith('.git')) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function repoNameFromGitUrl(raw) {
|
|
139
|
+
const s = String(raw ?? '').trim();
|
|
140
|
+
if (!s) return 'repo';
|
|
141
|
+
// Examples:
|
|
142
|
+
// - https://github.com/org/name.git
|
|
143
|
+
// - git@github.com:org/name.git
|
|
144
|
+
const m = s.match(/[:/]+([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
145
|
+
if (m?.[2]) return m[2];
|
|
146
|
+
const tail = s.split('/').filter(Boolean).pop() ?? 'repo';
|
|
147
|
+
return tail.replace(/\.git$/, '') || 'repo';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function resolveRepoRootFromPathOrUrl({ rootDir, label, raw, rl }) {
|
|
151
|
+
const input = String(raw ?? '').trim();
|
|
152
|
+
if (!input) return '';
|
|
153
|
+
|
|
154
|
+
if (!looksLikeGitUrl(input)) {
|
|
155
|
+
const r = await gitRoot(input);
|
|
156
|
+
if (!r) throw new Error(`[import] ${label}: not a git repo: ${input}`);
|
|
157
|
+
return r;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Git URL: clone into the hstack workspace so it can be pinned reliably.
|
|
161
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
162
|
+
const repoName = repoNameFromGitUrl(input);
|
|
163
|
+
const targetDir = join(workspaceDir, 'imports', 'repos', label, sanitizeSlugPart(repoName));
|
|
164
|
+
|
|
165
|
+
if (existsSync(join(targetDir, '.git'))) {
|
|
166
|
+
const reuse = await promptSelect(rl, {
|
|
167
|
+
title: `${bold(label)}\n${dim(`Repo already cloned at ${targetDir}.`)}`,
|
|
168
|
+
options: [
|
|
169
|
+
{ label: `reuse existing clone (default)`, value: 'reuse' },
|
|
170
|
+
{ label: `fetch latest (${dim('git fetch --all')})`, value: 'fetch' },
|
|
171
|
+
],
|
|
172
|
+
defaultIndex: 0,
|
|
173
|
+
});
|
|
174
|
+
if (reuse === 'fetch') {
|
|
175
|
+
await run('git', ['fetch', '--all', '--prune'], { cwd: targetDir }).catch(() => {});
|
|
176
|
+
}
|
|
177
|
+
const r = await gitRoot(targetDir);
|
|
178
|
+
if (!r) throw new Error(`[import] ${label}: expected git repo at ${targetDir} (missing)`);
|
|
179
|
+
return r;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await mkdir(join(targetDir, '..'), { recursive: true });
|
|
183
|
+
// eslint-disable-next-line no-console
|
|
184
|
+
console.log(dim(`Cloning ${label}: ${input} -> ${targetDir}`));
|
|
185
|
+
await run('git', ['clone', input, targetDir], { cwd: workspaceDir });
|
|
186
|
+
const r = await gitRoot(targetDir);
|
|
187
|
+
if (!r) throw new Error(`[import] ${label}: clone succeeded but repo root not found: ${targetDir}`);
|
|
188
|
+
return r;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function resolveRepoRootFromPathOrUrlNonInteractive({ rootDir, label, raw, yes, fetch }) {
|
|
192
|
+
const input = String(raw ?? '').trim();
|
|
193
|
+
if (!input) return '';
|
|
194
|
+
|
|
195
|
+
if (!looksLikeGitUrl(input)) {
|
|
196
|
+
const r = await gitRoot(input);
|
|
197
|
+
if (!r) throw new Error(`[import] ${label}: not a git repo: ${input}`);
|
|
198
|
+
return r;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!yes) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`[import] ${label}: got a git URL but non-interactive mode cannot prompt.\n` +
|
|
204
|
+
`[import] re-run with --yes to allow cloning into the hstack workspace.\n` +
|
|
205
|
+
`[import] url: ${input}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
210
|
+
const repoName = repoNameFromGitUrl(input);
|
|
211
|
+
const targetDir = join(workspaceDir, 'imports', 'repos', label, sanitizeSlugPart(repoName));
|
|
212
|
+
|
|
213
|
+
if (existsSync(join(targetDir, '.git'))) {
|
|
214
|
+
if (fetch) {
|
|
215
|
+
await run('git', ['fetch', '--all', '--prune'], { cwd: targetDir }).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
const r = await gitRoot(targetDir);
|
|
218
|
+
if (!r) throw new Error(`[import] ${label}: expected git repo at ${targetDir} (missing)`);
|
|
219
|
+
return r;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await mkdir(join(targetDir, '..'), { recursive: true });
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log(dim(`Cloning ${label}: ${input} -> ${targetDir}`));
|
|
225
|
+
await run('git', ['clone', input, targetDir], { cwd: workspaceDir });
|
|
226
|
+
const r = await gitRoot(targetDir);
|
|
227
|
+
if (!r) throw new Error(`[import] ${label}: clone succeeded but repo root not found: ${targetDir}`);
|
|
228
|
+
return r;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function ensureWorktreeForRef({ rootDir, componentLabel, repoRoot, ref }) {
|
|
232
|
+
const r = String(ref ?? '').trim();
|
|
233
|
+
if (!r) return '';
|
|
234
|
+
|
|
235
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
236
|
+
const safeRef = sanitizeSlugPart(r);
|
|
237
|
+
const componentSlug = String(componentLabel ?? '')
|
|
238
|
+
.trim()
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.replace(/[^a-z0-9]+/g, '-');
|
|
241
|
+
const targetDir = join(workspaceDir, 'imports', 'worktrees', componentSlug, safeRef);
|
|
242
|
+
|
|
243
|
+
await mkdir(join(targetDir, '..'), { recursive: true });
|
|
244
|
+
if (existsSync(targetDir)) return targetDir;
|
|
245
|
+
|
|
246
|
+
if (!(await gitOk(repoRoot, ['rev-parse', '--verify', '--quiet', r]))) {
|
|
247
|
+
await run('git', ['fetch', '--all', '--prune'], { cwd: repoRoot }).catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// eslint-disable-next-line no-console
|
|
251
|
+
console.log(dim(`Creating worktree: ${repoRoot} -> ${targetDir} (${r})`));
|
|
252
|
+
|
|
253
|
+
// Important:
|
|
254
|
+
// A normal clone has a branch checked out in its "main worktree" already.
|
|
255
|
+
// `git worktree add <dir> <branch>` fails if `<branch>` is currently checked out anywhere.
|
|
256
|
+
//
|
|
257
|
+
// To make `hstack import apply` robust for typical contributor setups,
|
|
258
|
+
// create a dedicated, uniquely named branch under the source repo when the ref is a local branch.
|
|
259
|
+
const isLocalBranch = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${r}`]);
|
|
260
|
+
if (isLocalBranch) {
|
|
261
|
+
const importPrefix = `hs-import/${componentSlug}/${safeRef}`;
|
|
262
|
+
let importBranch = importPrefix;
|
|
263
|
+
let i = 0;
|
|
264
|
+
// eslint-disable-next-line no-constant-condition
|
|
265
|
+
while (true) {
|
|
266
|
+
// eslint-disable-next-line no-await-in-loop
|
|
267
|
+
const exists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${importBranch}`]);
|
|
268
|
+
if (!exists) break;
|
|
269
|
+
i += 1;
|
|
270
|
+
importBranch = `${importPrefix}-${i}`;
|
|
271
|
+
if (i > 50) {
|
|
272
|
+
throw new Error(`[import] could not find a free import branch name for ${r}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
await run('git', ['worktree', 'add', '-b', importBranch, targetDir, r], { cwd: repoRoot });
|
|
276
|
+
} else {
|
|
277
|
+
// Commit SHA / tag / remote ref: keep it detached to avoid consuming/locking branches.
|
|
278
|
+
await run('git', ['worktree', 'add', '--detach', targetDir, r], { cwd: repoRoot });
|
|
279
|
+
}
|
|
280
|
+
return targetDir;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function resolveDefaultTargetBaseRef(repoRoot) {
|
|
284
|
+
// Prefer refs/remotes/origin/HEAD when available.
|
|
285
|
+
try {
|
|
286
|
+
const sym = (await runCapture('git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd: repoRoot })).trim();
|
|
287
|
+
const m = /^refs\/remotes\/origin\/(.+)$/.exec(sym);
|
|
288
|
+
if (m?.[1]) {
|
|
289
|
+
const ref = `origin/${m[1]}`;
|
|
290
|
+
if (await gitOk(repoRoot, ['rev-parse', '--verify', '--quiet', ref])) {
|
|
291
|
+
return ref;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Fallback candidates.
|
|
299
|
+
for (const c of ['upstream/main', 'origin/main', 'main', 'master']) {
|
|
300
|
+
// eslint-disable-next-line no-await-in-loop
|
|
301
|
+
if (await gitOk(repoRoot, ['rev-parse', '--verify', '--quiet', c])) return c;
|
|
302
|
+
}
|
|
303
|
+
return '';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function createMonorepoPortWorktree({ rootDir, monorepoRepoRoot, slug, baseRef }) {
|
|
307
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
308
|
+
const safe = sanitizeSlugPart(slug || 'port');
|
|
309
|
+
const dir = join(workspaceDir, 'imports', 'monorepo-worktrees', safe);
|
|
310
|
+
|
|
311
|
+
await mkdir(join(dir, '..'), { recursive: true });
|
|
312
|
+
if (existsSync(dir)) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[import] monorepo worktree path already exists: ${dir}\n` +
|
|
315
|
+
`[import] fix: delete it, or pick a different port branch/slug`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const ref = String(baseRef ?? '').trim() || (await resolveDefaultTargetBaseRef(monorepoRepoRoot)) || 'main';
|
|
320
|
+
// eslint-disable-next-line no-console
|
|
321
|
+
console.log(dim(`Creating monorepo worktree: ${monorepoRepoRoot} -> ${dir} (${ref})`));
|
|
322
|
+
await run('git', ['worktree', 'add', dir, ref], { cwd: monorepoRepoRoot });
|
|
323
|
+
return dir;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function chooseCheckoutPathForRepo({ rl, rootDir, componentLabel, repoRoot, repoHintLabel }) {
|
|
327
|
+
const worktrees = await listGitWorktrees(repoRoot);
|
|
328
|
+
const branches = await listLocalBranches(repoRoot);
|
|
329
|
+
|
|
330
|
+
const currentBranch = await gitBranch(repoRoot);
|
|
331
|
+
const currentDirty = await gitDirty(repoRoot);
|
|
332
|
+
const current = worktrees.find((w) => resolve(w.path) === resolve(repoRoot));
|
|
333
|
+
|
|
334
|
+
const options = [];
|
|
335
|
+
if (current) {
|
|
336
|
+
options.push({
|
|
337
|
+
value: { kind: 'path', path: current.path },
|
|
338
|
+
label: `${cyan('current')} — ${dim(current.branch || currentBranch)}${currentDirty ? ` ${yellow('(dirty)')}` : ''} ${dim(current.path)}`,
|
|
339
|
+
});
|
|
340
|
+
} else {
|
|
341
|
+
options.push({
|
|
342
|
+
value: { kind: 'path', path: repoRoot },
|
|
343
|
+
label: `${cyan('current')} — ${dim(currentBranch)}${currentDirty ? ` ${yellow('(dirty)')}` : ''} ${dim(repoRoot)}`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const others = worktrees.filter((w) => resolve(w.path) !== resolve(repoRoot));
|
|
348
|
+
for (const w of others.slice(0, 25)) {
|
|
349
|
+
options.push({
|
|
350
|
+
value: { kind: 'path', path: w.path },
|
|
351
|
+
label: `${cyan('worktree')} — ${dim(w.branch || 'detached')} ${dim(w.path)}`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (branches.length) {
|
|
355
|
+
options.push({ value: { kind: 'branch' }, label: `${cyan('other branch')} — create a new worktree under your hstack workspace` });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const picked = await promptSelect(rl, {
|
|
359
|
+
title:
|
|
360
|
+
`${bold(componentLabel)}\n` +
|
|
361
|
+
`${dim(`Pick which checkout to import from this repo${repoHintLabel ? ` (${repoHintLabel})` : ''}.`)}`,
|
|
362
|
+
options,
|
|
363
|
+
defaultIndex: 0,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
if (picked?.kind === 'path') {
|
|
367
|
+
return { path: picked.path, branch: await gitBranch(picked.path) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Branch -> create worktree under workspace (recommended).
|
|
371
|
+
const branch = await promptSelect(rl, {
|
|
372
|
+
title: `${bold(componentLabel)}\n${dim('Pick a branch to import (we will create a dedicated worktree for it).')}`,
|
|
373
|
+
options: branches.slice(0, 80).map((b) => ({ label: b, value: b })),
|
|
374
|
+
defaultIndex: 0,
|
|
375
|
+
});
|
|
376
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
377
|
+
const safe = sanitizeSlugPart(String(branch ?? 'branch'));
|
|
378
|
+
const componentSlug = componentLabel.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
379
|
+
const targetDir = join(workspaceDir, 'imports', 'worktrees', componentSlug, safe);
|
|
380
|
+
|
|
381
|
+
// eslint-disable-next-line no-console
|
|
382
|
+
console.log(dim(`Creating worktree: ${repoRoot} -> ${targetDir} (${branch})`));
|
|
383
|
+
|
|
384
|
+
// Create only the parent directory; git worktree add expects the target dir to NOT exist.
|
|
385
|
+
await mkdir(join(targetDir, '..'), { recursive: true });
|
|
386
|
+
if (existsSync(targetDir)) {
|
|
387
|
+
throw new Error(`[import] worktree path already exists: ${targetDir}\n[import] fix: delete it or pick a different branch name/slug`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Same reasoning as ensureWorktreeForRef(): avoid trying to check out the exact same branch in 2 worktrees.
|
|
391
|
+
const importPrefix = `hs-import/${componentSlug}/${safe}`;
|
|
392
|
+
let importBranch = importPrefix;
|
|
393
|
+
let i = 0;
|
|
394
|
+
// eslint-disable-next-line no-constant-condition
|
|
395
|
+
while (true) {
|
|
396
|
+
// eslint-disable-next-line no-await-in-loop
|
|
397
|
+
const exists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${importBranch}`]);
|
|
398
|
+
if (!exists) break;
|
|
399
|
+
i += 1;
|
|
400
|
+
importBranch = `${importPrefix}-${i}`;
|
|
401
|
+
if (i > 50) throw new Error(`[import] could not find a free import branch name for ${branch}`);
|
|
402
|
+
}
|
|
403
|
+
await run('git', ['worktree', 'add', '-b', importBranch, targetDir, branch], { cwd: repoRoot });
|
|
404
|
+
|
|
405
|
+
return { path: targetDir, branch: importBranch };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function ensureStackExists({ rootDir, stackName, serverComponent }) {
|
|
409
|
+
const name = sanitizeStackName(stackName);
|
|
410
|
+
if (!name) throw new Error('[import] invalid stack name');
|
|
411
|
+
if (stackExistsSync(name)) return name;
|
|
412
|
+
await run(process.execPath, [join(rootDir, 'scripts/stack.mjs'), 'new', name, `--server=${serverComponent}`], { cwd: rootDir });
|
|
413
|
+
return name;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function pinStackComponentDirs({ stackName, pins }) {
|
|
417
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
418
|
+
const roots = new Set();
|
|
419
|
+
for (const [, path] of Object.entries(pins)) {
|
|
420
|
+
const p = String(path ?? '').trim();
|
|
421
|
+
if (!p) continue;
|
|
422
|
+
const root = coerceHappyMonorepoRootFromPath(p) ?? p;
|
|
423
|
+
roots.add(root);
|
|
424
|
+
}
|
|
425
|
+
const unique = Array.from(roots).filter(Boolean);
|
|
426
|
+
if (!unique.length) return envPath;
|
|
427
|
+
if (unique.length > 1) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`[import] multiple repo roots detected; hstack is monorepo-only.\n` +
|
|
430
|
+
unique.map((r) => `- ${r}`).join('\n') +
|
|
431
|
+
`\nFix: pass paths/URLs that all resolve to the same Happier monorepo checkout/worktree.`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
await ensureEnvFileUpdated({ envPath, updates: [{ key: 'HAPPIER_STACK_REPO_DIR', value: unique[0] }] });
|
|
435
|
+
return envPath;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function resolveDefaultMonorepoRoot({ rootDir }) {
|
|
439
|
+
const repoDir = getComponentRepoDir(rootDir, 'happy');
|
|
440
|
+
if (repoDir && existsSync(repoDir) && isHappyMonorepoRoot(repoDir)) return repoDir;
|
|
441
|
+
return '';
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function runMonorepoPort({ rootDir, targetMonorepoRoot, sources, branch, dryRun }) {
|
|
445
|
+
// Use guided mode only when we expect conflicts (or when the user wants it).
|
|
446
|
+
const args = [
|
|
447
|
+
'port',
|
|
448
|
+
...(dryRun ? [] : ['guide']),
|
|
449
|
+
`--target=${targetMonorepoRoot}`,
|
|
450
|
+
`--branch=${branch}`,
|
|
451
|
+
'--3way',
|
|
452
|
+
...(dryRun ? ['--dry-run'] : []),
|
|
453
|
+
];
|
|
454
|
+
if (sources.happy) args.push(`--from-happy=${sources.happy}`);
|
|
455
|
+
if (sources['happy-cli']) args.push(`--from-happy-cli=${sources['happy-cli']}`);
|
|
456
|
+
if (sources['happy-server']) args.push(`--from-happy-server=${sources['happy-server']}`);
|
|
457
|
+
await run(process.execPath, [join(rootDir, 'scripts/monorepo.mjs'), ...args], { cwd: rootDir });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function runMonorepoPortRun({ rootDir, targetMonorepoRoot, sources, branch }) {
|
|
461
|
+
const args = ['port', `--target=${targetMonorepoRoot}`, `--branch=${branch}`, '--3way', '--json'];
|
|
462
|
+
if (sources.happy) args.push(`--from-happy=${sources.happy}`);
|
|
463
|
+
if (sources['happy-cli']) args.push(`--from-happy-cli=${sources['happy-cli']}`);
|
|
464
|
+
if (sources['happy-server']) args.push(`--from-happy-server=${sources['happy-server']}`);
|
|
465
|
+
const out = await runCapture(process.execPath, [join(rootDir, 'scripts/monorepo.mjs'), ...args], { cwd: rootDir });
|
|
466
|
+
return JSON.parse(String(out ?? '').trim() || '{}');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function runMonorepoPortPreflight({ rootDir, targetMonorepoRoot, sources }) {
|
|
470
|
+
const args = ['port', 'preflight', `--target=${targetMonorepoRoot}`, '--3way', '--json'];
|
|
471
|
+
if (sources.happy) args.push(`--from-happy=${sources.happy}`);
|
|
472
|
+
if (sources['happy-cli']) args.push(`--from-happy-cli=${sources['happy-cli']}`);
|
|
473
|
+
if (sources['happy-server']) args.push(`--from-happy-server=${sources['happy-server']}`);
|
|
474
|
+
const out = await runCapture(process.execPath, [join(rootDir, 'scripts/monorepo.mjs'), ...args], { cwd: rootDir });
|
|
475
|
+
return JSON.parse(String(out ?? '').trim() || '{}');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function summarizePreflightFailures(preflight) {
|
|
479
|
+
const results = Array.isArray(preflight?.results) ? preflight.results : [];
|
|
480
|
+
const lines = [];
|
|
481
|
+
for (const r of results) {
|
|
482
|
+
const failed = r?.report?.failed ?? [];
|
|
483
|
+
if (!Array.isArray(failed) || failed.length === 0) continue;
|
|
484
|
+
const label = String(r.label ?? '').trim() || 'source';
|
|
485
|
+
lines.push(`- ${cyan(label)}: ${failed.length} failed patch(es)`);
|
|
486
|
+
for (const f of failed.slice(0, 5)) {
|
|
487
|
+
const subj = String(f.subject ?? '').replace(/^\[PATCH \d+\/\d+\]\s*/, '');
|
|
488
|
+
const kind = f.kind ? ` (${f.kind})` : '';
|
|
489
|
+
const paths = (f.paths ?? []).slice(0, 3).join(', ');
|
|
490
|
+
lines.push(` - ${subj || f.patch}${kind}${paths ? ` → ${paths}` : ''}`);
|
|
491
|
+
}
|
|
492
|
+
if (failed.length > 5) lines.push(` - ...and ${failed.length - 5} more`);
|
|
493
|
+
}
|
|
494
|
+
return lines;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function summarizePins(pins) {
|
|
498
|
+
const lines = [];
|
|
499
|
+
for (const [k, v] of Object.entries(pins)) {
|
|
500
|
+
if (!v) continue;
|
|
501
|
+
lines.push(`- ${dim(k)}: ${v}`);
|
|
502
|
+
}
|
|
503
|
+
return lines;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function readPinnedComponentDirFromEnvObject(envObj, component) {
|
|
507
|
+
void component;
|
|
508
|
+
const raw = (envObj?.HAPPIER_STACK_REPO_DIR ?? '').toString().trim();
|
|
509
|
+
return raw || '';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function buildLlmPromptForImport() {
|
|
513
|
+
const hs = buildhstackRunnerShellSnippet();
|
|
514
|
+
return [
|
|
515
|
+
'You are an assistant helping the user migrate legacy Happy split repos into hstack.',
|
|
516
|
+
'',
|
|
517
|
+
hs,
|
|
518
|
+
'Goals:',
|
|
519
|
+
'- Import legacy split repos (happy / happy-cli / happy-server) into a stack in hstack.',
|
|
520
|
+
'- Optionally migrate commits into the Happier monorepo layout (packages/happy-* or legacy expo-app/cli/server).',
|
|
521
|
+
'',
|
|
522
|
+
'How to proceed:',
|
|
523
|
+
'1) Run the guided import wizard:',
|
|
524
|
+
' - `hs import`',
|
|
525
|
+
'',
|
|
526
|
+
'Non-interactive (LLM-friendly) variant:',
|
|
527
|
+
'- Inspect candidate repos/worktrees/branches (JSON):',
|
|
528
|
+
' - `hs import inspect --happy=<path|url> --happy-cli=<path|url> --happy-server=<path|url> --yes --json`',
|
|
529
|
+
'- Apply pins to a stack (no prompts):',
|
|
530
|
+
' - `hs import apply --stack=<name> --server=happy-server-light --happy=<path|url> --happy-ref=<ref> --happy-cli=<path|url> --happy-cli-ref=<ref> --happy-server=<path|url> --happy-server-ref=<ref> --yes`',
|
|
531
|
+
'2) If you want to migrate an existing imported stack later:',
|
|
532
|
+
' - `hs import migrate --stack=<stack>`',
|
|
533
|
+
'',
|
|
534
|
+
'Conflict handling (monorepo port):',
|
|
535
|
+
'- Prefer guided mode: `hs monorepo port guide --target=<monorepo-root>`',
|
|
536
|
+
'- For machine-readable state, use:',
|
|
537
|
+
' - `hs monorepo port status --target=<monorepo-root> --json`',
|
|
538
|
+
' - `hs monorepo port continue --target=<monorepo-root>`',
|
|
539
|
+
'',
|
|
540
|
+
'Important:',
|
|
541
|
+
'- A “stack” is an isolated runtime (ports + data + env) under ~/.happy/stacks/<name>.',
|
|
542
|
+
'- Import pins stack component paths (it does not rewrite history).',
|
|
543
|
+
'- Migration uses git format-patch + git am and may require resolving conflicts.',
|
|
544
|
+
].join('\n');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function buildLlmPromptForMigrate({ stackName }) {
|
|
548
|
+
const hs = buildhstackRunnerShellSnippet();
|
|
549
|
+
return [
|
|
550
|
+
'You are an assistant helping the user migrate an existing hstack stack to the monorepo.',
|
|
551
|
+
'',
|
|
552
|
+
hs,
|
|
553
|
+
`Target stack: ${stackName || '<stack>'}`,
|
|
554
|
+
'',
|
|
555
|
+
'Goal:',
|
|
556
|
+
'- Port the stack’s pinned split-repo commits into a monorepo worktree (Happier layout).',
|
|
557
|
+
'- Create a new monorepo stack by default (keep the legacy stack intact).',
|
|
558
|
+
'',
|
|
559
|
+
'Command:',
|
|
560
|
+
`- hs import migrate --stack=${stackName || '<stack>'}`,
|
|
561
|
+
'',
|
|
562
|
+
'Conflict handling:',
|
|
563
|
+
'- This uses `hs monorepo port guide` which pauses on conflicts.',
|
|
564
|
+
'- To inspect machine-readably: `hs monorepo port status --target=<monorepo-root> --json`',
|
|
565
|
+
].join('\n');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function buildMonorepoMigrationPrompt({ targetMonorepoRoot, branch, sources }) {
|
|
569
|
+
const args = [
|
|
570
|
+
`hs monorepo port --target=${targetMonorepoRoot} --branch=${branch} --3way`,
|
|
571
|
+
sources.happy ? `--from-happy=${sources.happy}` : '',
|
|
572
|
+
sources['happy-cli'] ? `--from-happy-cli=${sources['happy-cli']}` : '',
|
|
573
|
+
sources['happy-server'] ? `--from-happy-server=${sources['happy-server']}` : '',
|
|
574
|
+
]
|
|
575
|
+
.filter(Boolean)
|
|
576
|
+
.join(' \\\n+ ');
|
|
577
|
+
|
|
578
|
+
return [
|
|
579
|
+
'You are an assistant helping the user migrate split-repo commits into the Happy monorepo layout.',
|
|
580
|
+
'',
|
|
581
|
+
buildhstackRunnerShellSnippet(),
|
|
582
|
+
`Target monorepo worktree: ${targetMonorepoRoot}`,
|
|
583
|
+
`Port branch: ${branch}`,
|
|
584
|
+
'',
|
|
585
|
+
'Goal:',
|
|
586
|
+
'- Run the port command.',
|
|
587
|
+
'- If conflicts occur, resolve them cleanly and continue until complete.',
|
|
588
|
+
'',
|
|
589
|
+
'Start the port:',
|
|
590
|
+
args,
|
|
591
|
+
'',
|
|
592
|
+
'If it stops with conflicts:',
|
|
593
|
+
`- Inspect: hs monorepo port status --target=${targetMonorepoRoot} --json`,
|
|
594
|
+
`- Resolve conflicted files (keep changes scoped to packages/happy-*/ or legacy expo-app/, cli/, server/)`,
|
|
595
|
+
`- Stage: git -C ${targetMonorepoRoot} add <files>`,
|
|
596
|
+
`- Continue: hs monorepo port continue --target=${targetMonorepoRoot}`,
|
|
597
|
+
'',
|
|
598
|
+
'Repeat status/resolve/continue until ok.',
|
|
599
|
+
].join('\n');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function cmdLlm({ argv }) {
|
|
603
|
+
const { flags, kv } = parseArgs(argv);
|
|
604
|
+
const json = wantsJson(argv, { flags });
|
|
605
|
+
const mode = (kv.get('--mode') ?? '').trim().toLowerCase() || 'import';
|
|
606
|
+
const stackName = sanitizeStackName((kv.get('--stack') ?? '').trim());
|
|
607
|
+
const promptText = mode === 'migrate' ? buildLlmPromptForMigrate({ stackName }) : buildLlmPromptForImport();
|
|
608
|
+
const tools = await detectInstalledLlmTools();
|
|
609
|
+
|
|
610
|
+
if (json) {
|
|
611
|
+
printResult({ json, data: { mode, stack: stackName || null, prompt: promptText, detectedTools: tools.map((t) => t.id) } });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const wantsLaunch = flags.has('--launch');
|
|
616
|
+
if (wantsLaunch) {
|
|
617
|
+
const launched = await launchLlmAssistant({
|
|
618
|
+
title: 'hstack import/migrate (LLM)',
|
|
619
|
+
subtitle: 'Guides import and/or runs the monorepo port + conflict resolution.',
|
|
620
|
+
promptText,
|
|
621
|
+
cwd: rootDir,
|
|
622
|
+
env: process.env,
|
|
623
|
+
allowRunHere: true,
|
|
624
|
+
allowCopyOnly: true,
|
|
625
|
+
});
|
|
626
|
+
if (launched.ok && launched.launched) return;
|
|
627
|
+
if (!launched.ok) {
|
|
628
|
+
// eslint-disable-next-line no-console
|
|
629
|
+
console.log(dim(`[import] LLM launch unavailable: ${launched.reason || 'unknown'}`));
|
|
630
|
+
}
|
|
631
|
+
// fall through to printing the prompt
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// eslint-disable-next-line no-console
|
|
635
|
+
console.log('');
|
|
636
|
+
// eslint-disable-next-line no-console
|
|
637
|
+
console.log(banner('LLM prompt', { subtitle: 'Copy-paste this into your LLM to drive import/migration.' }));
|
|
638
|
+
// eslint-disable-next-line no-console
|
|
639
|
+
console.log(promptText);
|
|
640
|
+
if (tools.length) {
|
|
641
|
+
// eslint-disable-next-line no-console
|
|
642
|
+
console.log('');
|
|
643
|
+
// eslint-disable-next-line no-console
|
|
644
|
+
console.log(sectionTitle('Detected LLM CLIs'));
|
|
645
|
+
// eslint-disable-next-line no-console
|
|
646
|
+
console.log(
|
|
647
|
+
bullets(tools.map((t) => `- ${dim(t.id)}: ${t.label}${t.note ? ` ${dim(`— ${t.note}`)}` : ''}`))
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const wantsCopy = flags.has('--copy');
|
|
652
|
+
if (wantsCopy && (await clipboardAvailable())) {
|
|
653
|
+
const res = await copyTextToClipboard(promptText);
|
|
654
|
+
// eslint-disable-next-line no-console
|
|
655
|
+
console.log(res.ok ? green('✓ Copied to clipboard') : dim(`(Clipboard copy failed: ${res.reason || 'unknown'})`));
|
|
656
|
+
} else if (wantsCopy) {
|
|
657
|
+
// eslint-disable-next-line no-console
|
|
658
|
+
console.log(dim('(Clipboard copy unavailable on this system)'));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function cmdInspect({ rootDir, argv }) {
|
|
663
|
+
const { flags, kv } = parseArgs(argv);
|
|
664
|
+
const json = wantsJson(argv, { flags });
|
|
665
|
+
const yes = flags.has('--yes');
|
|
666
|
+
const fetch = flags.has('--fetch');
|
|
667
|
+
|
|
668
|
+
const inputs = {
|
|
669
|
+
happy: (kv.get('--happy') ?? '').toString().trim(),
|
|
670
|
+
'happy-cli': (kv.get('--happy-cli') ?? '').toString().trim(),
|
|
671
|
+
'happy-server': (kv.get('--happy-server') ?? '').toString().trim(),
|
|
672
|
+
'happy-server-light': (kv.get('--happy-server-light') ?? '').toString().trim(),
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
const repos = {};
|
|
676
|
+
for (const [label, raw] of Object.entries(inputs)) {
|
|
677
|
+
if (!raw) continue;
|
|
678
|
+
// eslint-disable-next-line no-await-in-loop
|
|
679
|
+
const repoRoot = await resolveRepoRootFromPathOrUrlNonInteractive({ rootDir, label, raw, yes, fetch });
|
|
680
|
+
// eslint-disable-next-line no-await-in-loop
|
|
681
|
+
const branch = await gitBranch(repoRoot);
|
|
682
|
+
// eslint-disable-next-line no-await-in-loop
|
|
683
|
+
const dirty = await gitDirty(repoRoot);
|
|
684
|
+
// eslint-disable-next-line no-await-in-loop
|
|
685
|
+
const worktrees = await listGitWorktrees(repoRoot);
|
|
686
|
+
// eslint-disable-next-line no-await-in-loop
|
|
687
|
+
const branches = await listLocalBranches(repoRoot);
|
|
688
|
+
repos[label] = { input: raw, repoRoot, branch, dirty, worktrees, branches };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
printResult({ json, data: { repos } });
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function cmdApply({ rootDir, argv }) {
|
|
695
|
+
const { flags, kv } = parseArgs(argv);
|
|
696
|
+
const json = wantsJson(argv, { flags });
|
|
697
|
+
const yes = flags.has('--yes');
|
|
698
|
+
const fetch = flags.has('--fetch');
|
|
699
|
+
|
|
700
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
701
|
+
const stackFromPos = positionals[1] || '';
|
|
702
|
+
const stackName = sanitizeStackName(((kv.get('--stack') ?? stackFromPos) || '').toString().trim());
|
|
703
|
+
if (!stackName) throw new Error('[import] apply: missing --stack=<name>');
|
|
704
|
+
|
|
705
|
+
const serverComponent = String(kv.get('--server') ?? 'happy-server-light').trim() || 'happy-server-light';
|
|
706
|
+
if (!['happy-server', 'happy-server-light'].includes(serverComponent)) {
|
|
707
|
+
throw new Error(`[import] apply: invalid --server=${serverComponent} (expected happy-server or happy-server-light)`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const spec = {
|
|
711
|
+
happy: { raw: String(kv.get('--happy') ?? '').trim(), ref: String(kv.get('--happy-ref') ?? '').trim() },
|
|
712
|
+
'happy-cli': { raw: String(kv.get('--happy-cli') ?? '').trim(), ref: String(kv.get('--happy-cli-ref') ?? '').trim() },
|
|
713
|
+
'happy-server': { raw: String(kv.get('--happy-server') ?? '').trim(), ref: String(kv.get('--happy-server-ref') ?? '').trim() },
|
|
714
|
+
'happy-server-light': {
|
|
715
|
+
raw: String(kv.get('--happy-server-light') ?? '').trim(),
|
|
716
|
+
ref: String(kv.get('--happy-server-light-ref') ?? '').trim(),
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const pins = {};
|
|
721
|
+
for (const [label, { raw, ref }] of Object.entries(spec)) {
|
|
722
|
+
if (!raw) continue;
|
|
723
|
+
// eslint-disable-next-line no-await-in-loop
|
|
724
|
+
const repoRoot = await resolveRepoRootFromPathOrUrlNonInteractive({ rootDir, label, raw, yes, fetch });
|
|
725
|
+
// eslint-disable-next-line no-await-in-loop
|
|
726
|
+
const worktreePath = ref ? await ensureWorktreeForRef({ rootDir, componentLabel: label, repoRoot, ref }) : '';
|
|
727
|
+
pins[label] = worktreePath || repoRoot;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const ensured = await ensureStackExists({ rootDir, stackName, serverComponent });
|
|
731
|
+
const envPath = await pinStackComponentDirs({ stackName: ensured, pins });
|
|
732
|
+
|
|
733
|
+
if (json) {
|
|
734
|
+
printResult({ json, data: { ok: true, stackName: ensured, serverComponent, envPath, pins } });
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// eslint-disable-next-line no-console
|
|
739
|
+
console.log('');
|
|
740
|
+
// eslint-disable-next-line no-console
|
|
741
|
+
console.log(banner('Import applied', { subtitle: 'Pinned the provided checkouts into the target stack env file.' }));
|
|
742
|
+
// eslint-disable-next-line no-console
|
|
743
|
+
console.log(kvFmt('stack', ensured));
|
|
744
|
+
// eslint-disable-next-line no-console
|
|
745
|
+
console.log(kvFmt('server', serverComponent));
|
|
746
|
+
// eslint-disable-next-line no-console
|
|
747
|
+
console.log(kvFmt('env', envPath));
|
|
748
|
+
// eslint-disable-next-line no-console
|
|
749
|
+
console.log('');
|
|
750
|
+
// eslint-disable-next-line no-console
|
|
751
|
+
console.log(sectionTitle('Pinned components'));
|
|
752
|
+
// eslint-disable-next-line no-console
|
|
753
|
+
console.log(bullets(summarizePins(pins)));
|
|
754
|
+
// eslint-disable-next-line no-console
|
|
755
|
+
console.log('');
|
|
756
|
+
// eslint-disable-next-line no-console
|
|
757
|
+
console.log(sectionTitle('Next'));
|
|
758
|
+
// eslint-disable-next-line no-console
|
|
759
|
+
console.log(bullets([cmdFmt(`hstack stack dev ${ensured}`), cmdFmt(`hstack import migrate --stack=${ensured}`)]));
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function cmdMigrateStack({ rootDir, argv }) {
|
|
763
|
+
const { flags, kv } = parseArgs(argv);
|
|
764
|
+
const json = wantsJson(argv, { flags });
|
|
765
|
+
const interactive = isTty() && !json;
|
|
766
|
+
if (!interactive) {
|
|
767
|
+
throw new Error('[import] migrate is interactive-only (TTY required).');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
await withRl(async (rl) => {
|
|
771
|
+
// eslint-disable-next-line no-console
|
|
772
|
+
console.log('');
|
|
773
|
+
// eslint-disable-next-line no-console
|
|
774
|
+
console.log(
|
|
775
|
+
banner('Migrate a stack to monorepo', {
|
|
776
|
+
subtitle: 'Port the stack’s split-repo commits into a monorepo worktree, then (recommended) create a new monorepo stack.',
|
|
777
|
+
})
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const allStacks = await listAllStackNames();
|
|
781
|
+
const providedStack = (kv.get('--stack') ?? '').trim() || argv.filter((a) => !a.startsWith('--'))[1]?.trim() || '';
|
|
782
|
+
|
|
783
|
+
const stackName = providedStack
|
|
784
|
+
? sanitizeStackName(providedStack)
|
|
785
|
+
: await promptSelect(rl, {
|
|
786
|
+
title: `${bold('Pick a stack to migrate')}\n${dim('We will read its component pins and port those repos into the monorepo layout.')}`,
|
|
787
|
+
options: allStacks.map((s) => ({ label: s, value: s })),
|
|
788
|
+
defaultIndex: allStacks.includes('main') ? Math.max(0, allStacks.indexOf('main') - 1) : 0,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
if (!stackName) throw new Error('[import] missing stack name');
|
|
792
|
+
if (!stackExistsSync(stackName)) {
|
|
793
|
+
throw new Error(`[import] stack does not exist: ${stackName}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
797
|
+
const envObj = await readEnvObjectFromFile(envPath);
|
|
798
|
+
const pins = {
|
|
799
|
+
happy: readPinnedComponentDirFromEnvObject(envObj, 'happy'),
|
|
800
|
+
'happy-cli': readPinnedComponentDirFromEnvObject(envObj, 'happy-cli'),
|
|
801
|
+
'happy-server': readPinnedComponentDirFromEnvObject(envObj, 'happy-server'),
|
|
802
|
+
'happy-server-light': readPinnedComponentDirFromEnvObject(envObj, 'happy-server-light'),
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const hasAnyPins = Object.values(pins).some(Boolean);
|
|
806
|
+
if (!hasAnyPins) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`[import] stack ${stackName} does not have any pinned component dirs.\n` +
|
|
809
|
+
`[import] Fix: run ${cmdFmt('hstack import')} to create an imported stack first, then re-run migrate.`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// eslint-disable-next-line no-console
|
|
814
|
+
console.log('');
|
|
815
|
+
// eslint-disable-next-line no-console
|
|
816
|
+
console.log(sectionTitle('Detected pins'));
|
|
817
|
+
// eslint-disable-next-line no-console
|
|
818
|
+
console.log(bullets(summarizePins(pins)));
|
|
819
|
+
|
|
820
|
+
// Choose monorepo target repo to base the worktree on.
|
|
821
|
+
const defaultMonorepo = await resolveDefaultMonorepoRoot({ rootDir });
|
|
822
|
+
let monorepoRepoRoot = defaultMonorepo;
|
|
823
|
+
if (!monorepoRepoRoot) {
|
|
824
|
+
const raw = await prompt(rl, `Monorepo repo path or URL (Happier): `, { defaultValue: '' });
|
|
825
|
+
const r = raw.trim() ? await resolveRepoRootFromPathOrUrl({ rootDir, label: 'happy-monorepo', raw, rl }) : '';
|
|
826
|
+
if (!r || !isHappyMonorepoRoot(r)) {
|
|
827
|
+
throw new Error('[import] target is not a Happier monorepo root (missing apps/ui|apps/cli|apps/server).');
|
|
828
|
+
}
|
|
829
|
+
monorepoRepoRoot = r;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const portBranchDefault = `port/${sanitizeSlugPart(stackName)}`;
|
|
833
|
+
const portBranchRaw = await prompt(rl, `Monorepo port branch (default: ${portBranchDefault}): `, { defaultValue: portBranchDefault });
|
|
834
|
+
const branch = String(portBranchRaw ?? '').trim() || portBranchDefault;
|
|
835
|
+
|
|
836
|
+
// Recommended: do the port into a dedicated worktree so we don't disturb your main monorepo checkout.
|
|
837
|
+
const worktreeMode = await promptSelect(rl, {
|
|
838
|
+
title: `${bold('Where should the port be applied?')}\n${dim('Recommended: create a dedicated monorepo worktree for this port branch.')}`,
|
|
839
|
+
options: [
|
|
840
|
+
{ label: `create a dedicated monorepo worktree (${green('recommended')})`, value: 'worktree' },
|
|
841
|
+
{ label: `use the existing monorepo checkout ${dim('(advanced)')}`, value: 'in-place' },
|
|
842
|
+
],
|
|
843
|
+
defaultIndex: 0,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
const targetMonorepoRoot =
|
|
847
|
+
worktreeMode === 'worktree'
|
|
848
|
+
? await createMonorepoPortWorktree({ rootDir, monorepoRepoRoot, slug: sanitizeSlugPart(branch), baseRef: '' })
|
|
849
|
+
: monorepoRepoRoot;
|
|
850
|
+
|
|
851
|
+
// Only pass sources that exist.
|
|
852
|
+
const sources = {};
|
|
853
|
+
if (pins.happy) sources.happy = pins.happy;
|
|
854
|
+
if (pins['happy-cli']) sources['happy-cli'] = pins['happy-cli'];
|
|
855
|
+
if (pins['happy-server']) sources['happy-server'] = pins['happy-server'];
|
|
856
|
+
// Port flow is owned by `hstack monorepo port guide` (preflight + auto-apply + conflicts + optional LLM).
|
|
857
|
+
// eslint-disable-next-line no-console
|
|
858
|
+
console.log('');
|
|
859
|
+
// eslint-disable-next-line no-console
|
|
860
|
+
console.log(banner('Migrating', { subtitle: 'Porting commits into the monorepo layout (preflight + guided conflict resolution).' }));
|
|
861
|
+
await runMonorepoPort({ rootDir, targetMonorepoRoot, sources, branch, dryRun: false });
|
|
862
|
+
|
|
863
|
+
// After migration: reuse or new stack.
|
|
864
|
+
// eslint-disable-next-line no-console
|
|
865
|
+
console.log('');
|
|
866
|
+
const stackAfter = await promptSelect(rl, {
|
|
867
|
+
title: `${bold('After migration')}\n${dim('Reuse this stack, or create a new monorepo stack to keep the old one intact?')}`,
|
|
868
|
+
options: [
|
|
869
|
+
{ label: `create a new stack (${green('recommended')}) — keep legacy stack intact`, value: 'new' },
|
|
870
|
+
{ label: `reuse existing stack — switch it to the monorepo checkout`, value: 'reuse' },
|
|
871
|
+
],
|
|
872
|
+
defaultIndex: 0,
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
const migratedStackNameDefault =
|
|
876
|
+
sanitizeStackName(`${stackName}-mono`) || sanitizeStackName(`mono-${stackName}`) || 'mono';
|
|
877
|
+
const migratedStackName =
|
|
878
|
+
stackAfter === 'reuse'
|
|
879
|
+
? stackName
|
|
880
|
+
: sanitizeStackName(
|
|
881
|
+
(
|
|
882
|
+
await prompt(rl, `New monorepo stack name (default: ${migratedStackNameDefault}): `, {
|
|
883
|
+
defaultValue: migratedStackNameDefault,
|
|
884
|
+
})
|
|
885
|
+
).trim() || migratedStackNameDefault
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
const finalStackName =
|
|
889
|
+
stackAfter === 'reuse'
|
|
890
|
+
? migratedStackName
|
|
891
|
+
: await ensureStackExists({ rootDir, stackName: migratedStackName, serverComponent: 'happy-server' });
|
|
892
|
+
|
|
893
|
+
const monoPins = {};
|
|
894
|
+
if (pins.happy) monoPins.happy = targetMonorepoRoot;
|
|
895
|
+
if (pins['happy-cli']) monoPins['happy-cli'] = targetMonorepoRoot;
|
|
896
|
+
if (pins['happy-server']) monoPins['happy-server'] = targetMonorepoRoot;
|
|
897
|
+
if (pins['happy-server-light']) monoPins['happy-server-light'] = pins['happy-server-light'];
|
|
898
|
+
const finalEnvPath = await pinStackComponentDirs({ stackName: finalStackName, pins: monoPins });
|
|
899
|
+
|
|
900
|
+
// eslint-disable-next-line no-console
|
|
901
|
+
console.log('');
|
|
902
|
+
// eslint-disable-next-line no-console
|
|
903
|
+
console.log(banner('Migrated', { subtitle: 'Your monorepo stack is ready.' }));
|
|
904
|
+
// eslint-disable-next-line no-console
|
|
905
|
+
console.log(kvFmt('Stack', cyan(finalStackName)));
|
|
906
|
+
// eslint-disable-next-line no-console
|
|
907
|
+
console.log(kvFmt('Env', finalEnvPath));
|
|
908
|
+
// eslint-disable-next-line no-console
|
|
909
|
+
console.log(sectionTitle('Next'));
|
|
910
|
+
// eslint-disable-next-line no-console
|
|
911
|
+
console.log(bullets([`Run: ${cmdFmt(`hstack stack dev ${finalStackName}`)}`]));
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function main() {
|
|
916
|
+
const rootDir = getRootDir(import.meta.url);
|
|
917
|
+
const argv = process.argv.slice(2);
|
|
918
|
+
const { flags, kv } = parseArgs(argv);
|
|
919
|
+
const json = wantsJson(argv, { flags });
|
|
920
|
+
const interactive = isTty() && !json;
|
|
921
|
+
|
|
922
|
+
if (wantsHelp(argv, { flags })) {
|
|
923
|
+
printResult({ json, data: { json: true }, text: usage() });
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
928
|
+
const sub = positionals[0] || '';
|
|
929
|
+
if (sub === 'inspect') {
|
|
930
|
+
await cmdInspect({ rootDir, argv });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (sub === 'apply') {
|
|
934
|
+
await cmdApply({ rootDir, argv });
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (sub === 'migrate') {
|
|
938
|
+
await cmdMigrateStack({ rootDir, argv });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (sub === 'llm') {
|
|
942
|
+
await cmdLlm({ argv });
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (!interactive) {
|
|
947
|
+
printResult({
|
|
948
|
+
json,
|
|
949
|
+
data: { ok: false },
|
|
950
|
+
text: '[import] This command is currently interactive-only. Re-run in a TTY.',
|
|
951
|
+
});
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// eslint-disable-next-line no-console
|
|
956
|
+
console.log('');
|
|
957
|
+
// eslint-disable-next-line no-console
|
|
958
|
+
console.log(
|
|
959
|
+
banner('Import legacy repos', {
|
|
960
|
+
subtitle: 'Bring your pre-monorepo (split repo) work into hstack, then optionally migrate to monorepo.',
|
|
961
|
+
})
|
|
962
|
+
);
|
|
963
|
+
// eslint-disable-next-line no-console
|
|
964
|
+
console.log(sectionTitle('Key concepts'));
|
|
965
|
+
// eslint-disable-next-line no-console
|
|
966
|
+
console.log(
|
|
967
|
+
bullets([
|
|
968
|
+
`${bold('components')}: the main codebases (UI = ${cyan('happy')}, CLI/daemon = ${cyan('happy-cli')}, server = ${cyan('happy-server')})`,
|
|
969
|
+
`${bold('stack')}: an isolated runtime (ports + data + env) under ${dim('~/.happy/stacks/<name>')}`,
|
|
970
|
+
`${bold('import')}: pin a stack to your existing repo checkouts (so you can run your work as-is)`,
|
|
971
|
+
`${bold('migrate')}: port your split-repo commits into the monorepo layout via ${cyan('hstack monorepo port')}`,
|
|
972
|
+
])
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
await withRl(async (rl) => {
|
|
976
|
+
const repos = { happy: '', 'happy-cli': '', 'happy-server': '', 'happy-server-light': '' };
|
|
977
|
+
|
|
978
|
+
const collectRepos = async () => {
|
|
979
|
+
// eslint-disable-next-line no-console
|
|
980
|
+
console.log('');
|
|
981
|
+
// eslint-disable-next-line no-console
|
|
982
|
+
console.log(sectionTitle('Your repos'));
|
|
983
|
+
// eslint-disable-next-line no-console
|
|
984
|
+
console.log(
|
|
985
|
+
bullets([
|
|
986
|
+
`Paste a ${bold('local path')} or a ${bold('git URL')} (GitHub HTTPS/SSH).`,
|
|
987
|
+
`If you paste a URL, we clone it into your hstack workspace under ${dim('imports/repos/...')}.`,
|
|
988
|
+
`Tip: if you already have a worktree checked out on the branch you want, paste that worktree path.`,
|
|
989
|
+
])
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
const uiRaw = await prompt(rl, `${cyan('happy')} (UI) path or URL: `, { defaultValue: repos.happy ? repos.happy : '' });
|
|
993
|
+
const cliRaw = await prompt(rl, `${cyan('happy-cli')} (CLI/daemon) path or URL: `, {
|
|
994
|
+
defaultValue: repos['happy-cli'] ? repos['happy-cli'] : '',
|
|
995
|
+
});
|
|
996
|
+
const serverRaw = await prompt(rl, `${cyan('happy-server')} (server) path or URL: `, {
|
|
997
|
+
defaultValue: repos['happy-server'] ? repos['happy-server'] : '',
|
|
998
|
+
});
|
|
999
|
+
const serverLightRaw = await prompt(rl, `${cyan('happy-server-light')} (optional) path or URL: `, {
|
|
1000
|
+
defaultValue: repos['happy-server-light'] ? repos['happy-server-light'] : '',
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
repos.happy = uiRaw.trim() ? await resolveRepoRootFromPathOrUrl({ rootDir, label: 'happy', raw: uiRaw, rl }) : '';
|
|
1004
|
+
repos['happy-cli'] = cliRaw.trim()
|
|
1005
|
+
? await resolveRepoRootFromPathOrUrl({ rootDir, label: 'happy-cli', raw: cliRaw, rl })
|
|
1006
|
+
: '';
|
|
1007
|
+
repos['happy-server'] = serverRaw.trim()
|
|
1008
|
+
? await resolveRepoRootFromPathOrUrl({ rootDir, label: 'happy-server', raw: serverRaw, rl })
|
|
1009
|
+
: '';
|
|
1010
|
+
repos['happy-server-light'] = serverLightRaw.trim()
|
|
1011
|
+
? await resolveRepoRootFromPathOrUrl({ rootDir, label: 'happy-server-light', raw: serverLightRaw, rl })
|
|
1012
|
+
: '';
|
|
1013
|
+
|
|
1014
|
+
if (!repos.happy && !repos['happy-cli'] && !repos['happy-server'] && !repos['happy-server-light']) {
|
|
1015
|
+
throw new Error('[import] no repos provided. Provide at least one path/URL.');
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
|
|
1019
|
+
await collectRepos();
|
|
1020
|
+
|
|
1021
|
+
// eslint-disable-next-line no-console
|
|
1022
|
+
console.log('');
|
|
1023
|
+
// eslint-disable-next-line no-console
|
|
1024
|
+
console.log(sectionTitle('Import plan'));
|
|
1025
|
+
// eslint-disable-next-line no-console
|
|
1026
|
+
console.log(dim('We can import multiple branches/stacks in one run. Start with your first one.'));
|
|
1027
|
+
|
|
1028
|
+
while (true) {
|
|
1029
|
+
// Step: choose checkouts (branch/worktree) for this stack
|
|
1030
|
+
// eslint-disable-next-line no-console
|
|
1031
|
+
console.log('');
|
|
1032
|
+
// eslint-disable-next-line no-console
|
|
1033
|
+
console.log(sectionTitle('Choose what to import'));
|
|
1034
|
+
// eslint-disable-next-line no-console
|
|
1035
|
+
console.log(dim('We will pin the stack to these exact checkouts so you can run your work as-is.'));
|
|
1036
|
+
|
|
1037
|
+
const selected = {};
|
|
1038
|
+
const selectedBranches = [];
|
|
1039
|
+
|
|
1040
|
+
if (repos.happy) {
|
|
1041
|
+
const r = await chooseCheckoutPathForRepo({
|
|
1042
|
+
rl,
|
|
1043
|
+
rootDir,
|
|
1044
|
+
componentLabel: 'happy',
|
|
1045
|
+
repoRoot: repos.happy,
|
|
1046
|
+
repoHintLabel: 'UI',
|
|
1047
|
+
});
|
|
1048
|
+
selected.happy = r.path;
|
|
1049
|
+
if (r.branch && r.branch !== 'unknown' && r.branch !== 'detached') selectedBranches.push(r.branch);
|
|
1050
|
+
}
|
|
1051
|
+
if (repos['happy-cli']) {
|
|
1052
|
+
const r = await chooseCheckoutPathForRepo({
|
|
1053
|
+
rl,
|
|
1054
|
+
rootDir,
|
|
1055
|
+
componentLabel: 'happy-cli',
|
|
1056
|
+
repoRoot: repos['happy-cli'],
|
|
1057
|
+
repoHintLabel: 'CLI/daemon',
|
|
1058
|
+
});
|
|
1059
|
+
selected['happy-cli'] = r.path;
|
|
1060
|
+
if (r.branch && r.branch !== 'unknown' && r.branch !== 'detached') selectedBranches.push(r.branch);
|
|
1061
|
+
}
|
|
1062
|
+
if (repos['happy-server']) {
|
|
1063
|
+
const r = await chooseCheckoutPathForRepo({
|
|
1064
|
+
rl,
|
|
1065
|
+
rootDir,
|
|
1066
|
+
componentLabel: 'happy-server',
|
|
1067
|
+
repoRoot: repos['happy-server'],
|
|
1068
|
+
repoHintLabel: 'server',
|
|
1069
|
+
});
|
|
1070
|
+
selected['happy-server'] = r.path;
|
|
1071
|
+
if (r.branch && r.branch !== 'unknown' && r.branch !== 'detached') selectedBranches.push(r.branch);
|
|
1072
|
+
}
|
|
1073
|
+
if (repos['happy-server-light']) {
|
|
1074
|
+
const includeServerLight = await promptSelect(rl, {
|
|
1075
|
+
title: `${bold('happy-server-light')}\n${dim('Optional: also pin server-light for this stack?')}`,
|
|
1076
|
+
options: [
|
|
1077
|
+
{ label: 'no (default)', value: false },
|
|
1078
|
+
{ label: 'yes', value: true },
|
|
1079
|
+
],
|
|
1080
|
+
defaultIndex: 0,
|
|
1081
|
+
});
|
|
1082
|
+
if (includeServerLight) {
|
|
1083
|
+
const r = await chooseCheckoutPathForRepo({
|
|
1084
|
+
rl,
|
|
1085
|
+
rootDir,
|
|
1086
|
+
componentLabel: 'happy-server-light',
|
|
1087
|
+
repoRoot: repos['happy-server-light'],
|
|
1088
|
+
repoHintLabel: 'server-light (light flavor)',
|
|
1089
|
+
});
|
|
1090
|
+
selected['happy-server-light'] = r.path;
|
|
1091
|
+
if (r.branch && r.branch !== 'unknown' && r.branch !== 'detached') selectedBranches.push(r.branch);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Step: stack selection
|
|
1096
|
+
// eslint-disable-next-line no-console
|
|
1097
|
+
console.log('');
|
|
1098
|
+
// eslint-disable-next-line no-console
|
|
1099
|
+
console.log(sectionTitle('Stack'));
|
|
1100
|
+
// eslint-disable-next-line no-console
|
|
1101
|
+
console.log(dim('A stack is an isolated runtime. Create a new stack per feature/branch (recommended).'));
|
|
1102
|
+
|
|
1103
|
+
const existing = await listAllStackNames();
|
|
1104
|
+
const canReuse = existing.length > 0;
|
|
1105
|
+
const stackMode = await promptSelect(rl, {
|
|
1106
|
+
title: `${bold('Where should this imported work run?')}`,
|
|
1107
|
+
options: [
|
|
1108
|
+
{ label: `create a new stack (${green('recommended')})`, value: 'new' },
|
|
1109
|
+
...(canReuse ? [{ label: `reuse an existing stack ${dim('(advanced)')}`, value: 'reuse' }] : []),
|
|
1110
|
+
],
|
|
1111
|
+
defaultIndex: 0,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
const inferredBase =
|
|
1115
|
+
selectedBranches.length && selectedBranches.every((b) => b === selectedBranches[0]) ? selectedBranches[0] : 'import';
|
|
1116
|
+
const defaultStackName = sanitizeStackName(inferredBase) || 'import';
|
|
1117
|
+
|
|
1118
|
+
let stackName = '';
|
|
1119
|
+
if (stackMode === 'reuse') {
|
|
1120
|
+
stackName = await promptSelect(rl, {
|
|
1121
|
+
title: `${bold('Pick a stack to reuse')}\n${dim('We will update its component pins to point at your selected checkouts.')}`,
|
|
1122
|
+
options: existing.map((s) => ({ label: s, value: s })),
|
|
1123
|
+
defaultIndex: 0,
|
|
1124
|
+
});
|
|
1125
|
+
} else {
|
|
1126
|
+
const raw = await prompt(rl, `New stack name (default: ${defaultStackName}): `, { defaultValue: defaultStackName });
|
|
1127
|
+
stackName = sanitizeStackName(raw.trim() || defaultStackName);
|
|
1128
|
+
}
|
|
1129
|
+
if (!stackName) throw new Error('[import] missing stack name');
|
|
1130
|
+
|
|
1131
|
+
const serverComponentDefault = selected['happy-server-light'] ? 0 : 1;
|
|
1132
|
+
const serverComponent = await promptSelect(rl, {
|
|
1133
|
+
title: `${bold('Server flavor for this stack')}\n${dim('Pick how you want to run the server for this imported work.')}`,
|
|
1134
|
+
options: [
|
|
1135
|
+
{ label: `${cyan('happy-server-light')} (${green('recommended')}) — easier local dev`, value: 'happy-server-light' },
|
|
1136
|
+
{ label: `${cyan('happy-server')} — full server (Docker-managed infra)`, value: 'happy-server' },
|
|
1137
|
+
],
|
|
1138
|
+
defaultIndex: serverComponentDefault,
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
// Apply pins
|
|
1142
|
+
const ensuredStack = await ensureStackExists({ rootDir, stackName, serverComponent });
|
|
1143
|
+
const envPath = await pinStackComponentDirs({ stackName: ensuredStack, pins: selected });
|
|
1144
|
+
|
|
1145
|
+
// eslint-disable-next-line no-console
|
|
1146
|
+
console.log('');
|
|
1147
|
+
// eslint-disable-next-line no-console
|
|
1148
|
+
console.log(banner('Imported', { subtitle: 'Your stack is now pinned to your existing repo checkouts.' }));
|
|
1149
|
+
// eslint-disable-next-line no-console
|
|
1150
|
+
console.log(kvFmt('Stack', cyan(ensuredStack)));
|
|
1151
|
+
// eslint-disable-next-line no-console
|
|
1152
|
+
console.log(kvFmt('Env', envPath));
|
|
1153
|
+
// eslint-disable-next-line no-console
|
|
1154
|
+
console.log(sectionTitle('Pinned components'));
|
|
1155
|
+
// eslint-disable-next-line no-console
|
|
1156
|
+
console.log(bullets(summarizePins(selected)));
|
|
1157
|
+
// eslint-disable-next-line no-console
|
|
1158
|
+
console.log('');
|
|
1159
|
+
// eslint-disable-next-line no-console
|
|
1160
|
+
console.log(dim(`Tip: run it with ${cmdFmt(`hstack stack dev ${ensuredStack}`)} (or ${cmdFmt(`hstack stack start ${ensuredStack}`)}).`));
|
|
1161
|
+
|
|
1162
|
+
// Optional migration
|
|
1163
|
+
// eslint-disable-next-line no-console
|
|
1164
|
+
console.log('');
|
|
1165
|
+
const migrateWanted = await promptSelect(rl, {
|
|
1166
|
+
title: `${bold('Migrate to monorepo?')}\n${dim(
|
|
1167
|
+
'Optional: port split-repo commits into the monorepo layout (packages/happy-* or legacy expo-app/cli/server).'
|
|
1168
|
+
)}`,
|
|
1169
|
+
options: [
|
|
1170
|
+
{ label: `no (default) — keep running from split repos`, value: 'no' },
|
|
1171
|
+
{ label: `yes (${green('recommended')}) — port commits into a monorepo branch`, value: 'yes' },
|
|
1172
|
+
{ label: `dry run — preview what would be ported`, value: 'dry-run' },
|
|
1173
|
+
],
|
|
1174
|
+
defaultIndex: 0,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
if (migrateWanted !== 'no') {
|
|
1178
|
+
const defaultTarget = await resolveDefaultMonorepoRoot({ rootDir });
|
|
1179
|
+
let monorepoRepoRoot = defaultTarget;
|
|
1180
|
+
if (!monorepoRepoRoot) {
|
|
1181
|
+
// eslint-disable-next-line no-console
|
|
1182
|
+
console.log(
|
|
1183
|
+
`${yellow('!')} No monorepo checkout detected in your hstack workspace yet.\n` +
|
|
1184
|
+
dim(`Fix: run ${cmdFmt('hstack setup --profile=dev')} (or ${cmdFmt('hstack bootstrap')}) first, then re-run import.`)
|
|
1185
|
+
);
|
|
1186
|
+
const raw = await prompt(rl, `Monorepo target path (Happier monorepo root): `, { defaultValue: '' });
|
|
1187
|
+
monorepoRepoRoot = raw.trim() ? await gitRoot(raw.trim()) : '';
|
|
1188
|
+
if (!monorepoRepoRoot || !isHappyMonorepoRoot(monorepoRepoRoot)) {
|
|
1189
|
+
throw new Error('[import] target is not a Happier monorepo root (missing apps/ui|apps/cli|apps/server).');
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const portBranchDefault = `port/${sanitizeSlugPart(ensuredStack || 'import')}`;
|
|
1194
|
+
const portBranch = await prompt(rl, `Monorepo port branch (default: ${portBranchDefault}): `, {
|
|
1195
|
+
defaultValue: portBranchDefault,
|
|
1196
|
+
});
|
|
1197
|
+
const branch = String(portBranch ?? '').trim() || portBranchDefault;
|
|
1198
|
+
|
|
1199
|
+
if (migrateWanted === 'dry-run') {
|
|
1200
|
+
// Also show the "what would be ported" preview (patch count only).
|
|
1201
|
+
// eslint-disable-next-line no-console
|
|
1202
|
+
console.log('');
|
|
1203
|
+
// eslint-disable-next-line no-console
|
|
1204
|
+
console.log(banner('Dry run', { subtitle: 'Previewing what would be ported (does not apply patches).' }));
|
|
1205
|
+
await runMonorepoPort({ rootDir, targetMonorepoRoot: monorepoRepoRoot, sources: selected, branch, dryRun: true });
|
|
1206
|
+
// eslint-disable-next-line no-console
|
|
1207
|
+
console.log(green('✓ Dry run complete'));
|
|
1208
|
+
} else {
|
|
1209
|
+
// Choose where to apply the real port (only needed when we actually run it).
|
|
1210
|
+
const worktreeMode = await promptSelect(rl, {
|
|
1211
|
+
title: `${bold('Where should the port be applied?')}\n${dim('Recommended: create a dedicated monorepo worktree for this port branch.')}`,
|
|
1212
|
+
options: [
|
|
1213
|
+
{ label: `create a dedicated monorepo worktree (${green('recommended')})`, value: 'worktree' },
|
|
1214
|
+
{ label: `use the existing monorepo checkout ${dim('(advanced)')}`, value: 'in-place' },
|
|
1215
|
+
],
|
|
1216
|
+
defaultIndex: 0,
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
const targetMonorepoRoot =
|
|
1220
|
+
worktreeMode === 'worktree'
|
|
1221
|
+
? await createMonorepoPortWorktree({ rootDir, monorepoRepoRoot, slug: sanitizeSlugPart(branch), baseRef: '' })
|
|
1222
|
+
: monorepoRepoRoot;
|
|
1223
|
+
|
|
1224
|
+
let migrationCompleted = false;
|
|
1225
|
+
try {
|
|
1226
|
+
// This delegates all port logic to `hstack monorepo port guide` (preflight + auto-apply + conflicts + optional LLM).
|
|
1227
|
+
// eslint-disable-next-line no-console
|
|
1228
|
+
console.log('');
|
|
1229
|
+
// eslint-disable-next-line no-console
|
|
1230
|
+
console.log(banner('Migrating', { subtitle: 'Porting commits into the monorepo layout (guided).' }));
|
|
1231
|
+
await runMonorepoPort({ rootDir, targetMonorepoRoot, sources: selected, branch, dryRun: false });
|
|
1232
|
+
migrationCompleted = true;
|
|
1233
|
+
} catch (e) {
|
|
1234
|
+
// eslint-disable-next-line no-console
|
|
1235
|
+
console.log('');
|
|
1236
|
+
// eslint-disable-next-line no-console
|
|
1237
|
+
console.log(`${yellow('!')} Migration stopped ${dim(`(${String(e?.message ?? e ?? 'unknown')})`)}`);
|
|
1238
|
+
// eslint-disable-next-line no-console
|
|
1239
|
+
console.log(dim('You can retry later by re-running import and choosing migration again.'));
|
|
1240
|
+
migrationCompleted = false;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (migrationCompleted) {
|
|
1244
|
+
// eslint-disable-next-line no-console
|
|
1245
|
+
console.log('');
|
|
1246
|
+
const stackAfter = await promptSelect(rl, {
|
|
1247
|
+
title: `${bold('After migration')}\n${dim('Do you want to reuse the same stack or create a new stack for the monorepo branch?')}`,
|
|
1248
|
+
options: [
|
|
1249
|
+
{ label: `create a new stack (${green('recommended')}) — keep legacy stack intact`, value: 'new' },
|
|
1250
|
+
{ label: `reuse existing stack — switch it to the monorepo checkout`, value: 'reuse' },
|
|
1251
|
+
],
|
|
1252
|
+
defaultIndex: 0,
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
const migratedStackNameDefault =
|
|
1256
|
+
sanitizeStackName(`${ensuredStack}-mono`) || sanitizeStackName(`mono-${ensuredStack}`) || 'mono';
|
|
1257
|
+
const migratedStackName =
|
|
1258
|
+
stackAfter === 'reuse'
|
|
1259
|
+
? ensuredStack
|
|
1260
|
+
: sanitizeStackName(
|
|
1261
|
+
(
|
|
1262
|
+
await prompt(rl, `New monorepo stack name (default: ${migratedStackNameDefault}): `, {
|
|
1263
|
+
defaultValue: migratedStackNameDefault,
|
|
1264
|
+
})
|
|
1265
|
+
).trim() || migratedStackNameDefault
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
const migratedServerComponent = selected['happy-server'] ? 'happy-server' : serverComponent;
|
|
1269
|
+
const finalStackName =
|
|
1270
|
+
stackAfter === 'reuse'
|
|
1271
|
+
? migratedStackName
|
|
1272
|
+
: await ensureStackExists({ rootDir, stackName: migratedStackName, serverComponent: migratedServerComponent });
|
|
1273
|
+
|
|
1274
|
+
const monoPins = {};
|
|
1275
|
+
if (selected.happy) monoPins.happy = targetMonorepoRoot;
|
|
1276
|
+
if (selected['happy-cli']) monoPins['happy-cli'] = targetMonorepoRoot;
|
|
1277
|
+
if (selected['happy-server']) monoPins['happy-server'] = targetMonorepoRoot;
|
|
1278
|
+
// Keep server-light pinned if user opted into it (server-light is not ported by monorepo port today).
|
|
1279
|
+
if (selected['happy-server-light']) monoPins['happy-server-light'] = selected['happy-server-light'];
|
|
1280
|
+
|
|
1281
|
+
const finalEnvPath = await pinStackComponentDirs({ stackName: finalStackName, pins: monoPins });
|
|
1282
|
+
|
|
1283
|
+
// eslint-disable-next-line no-console
|
|
1284
|
+
console.log('');
|
|
1285
|
+
// eslint-disable-next-line no-console
|
|
1286
|
+
console.log(banner('Migrated', { subtitle: 'Your monorepo stack is ready.' }));
|
|
1287
|
+
// eslint-disable-next-line no-console
|
|
1288
|
+
console.log(kvFmt('Stack', cyan(finalStackName)));
|
|
1289
|
+
// eslint-disable-next-line no-console
|
|
1290
|
+
console.log(kvFmt('Env', finalEnvPath));
|
|
1291
|
+
// eslint-disable-next-line no-console
|
|
1292
|
+
console.log(sectionTitle('Next'));
|
|
1293
|
+
// eslint-disable-next-line no-console
|
|
1294
|
+
console.log(
|
|
1295
|
+
bullets([
|
|
1296
|
+
`Run: ${cmdFmt(`hstack stack dev ${finalStackName}`)}`,
|
|
1297
|
+
`If you need to import more branches later, re-run: ${cmdFmt('hstack import')}`,
|
|
1298
|
+
])
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Loop
|
|
1305
|
+
// eslint-disable-next-line no-console
|
|
1306
|
+
console.log('');
|
|
1307
|
+
const again = await promptSelect(rl, {
|
|
1308
|
+
title: `${bold('Import another branch/stack?')}`,
|
|
1309
|
+
options: [
|
|
1310
|
+
{ label: 'no (default)', value: 'no' },
|
|
1311
|
+
{ label: 'yes — import another branch into another stack', value: 'yes' },
|
|
1312
|
+
{ label: `yes — change repo inputs first ${dim('(advanced)')}`, value: 'change-repos' },
|
|
1313
|
+
],
|
|
1314
|
+
defaultIndex: 0,
|
|
1315
|
+
});
|
|
1316
|
+
if (again === 'no') break;
|
|
1317
|
+
if (again === 'change-repos') {
|
|
1318
|
+
await collectRepos();
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
main().catch((err) => {
|
|
1325
|
+
process.stderr.write(String(err?.message ?? err) + '\n');
|
|
1326
|
+
process.exit(1);
|
|
1327
|
+
});
|