@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,2388 @@
|
|
|
1
|
+
import './utils/env/env.mjs';
|
|
2
|
+
import { chmod, copyFile, mkdir, readFile, readdir, rename } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
// NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
|
|
8
|
+
|
|
9
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
10
|
+
import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
|
|
11
|
+
import {
|
|
12
|
+
coerceHappyMonorepoRootFromPath,
|
|
13
|
+
getComponentDir,
|
|
14
|
+
getHappyStacksHomeDir,
|
|
15
|
+
getRootDir,
|
|
16
|
+
getWorkspaceDir,
|
|
17
|
+
happyMonorepoSubdirForComponent,
|
|
18
|
+
resolveStackEnvPath,
|
|
19
|
+
} from './utils/paths/paths.mjs';
|
|
20
|
+
import { isTcpPortFree, pickNextFreeTcpPort } from './utils/net/ports.mjs';
|
|
21
|
+
import { collectReservedStackPorts, getDefaultPortStart, isPortFree, pickNextFreePort, readPortFromEnvFile } from './stack/port_reservation.mjs';
|
|
22
|
+
import {
|
|
23
|
+
createWorktreeFromBaseWorktree,
|
|
24
|
+
WORKTREE_CATEGORIES,
|
|
25
|
+
getWorktreeCategoryRoot,
|
|
26
|
+
inferRemoteNameForOwner,
|
|
27
|
+
isWorktreePath,
|
|
28
|
+
worktreeSpecFromDir,
|
|
29
|
+
} from './utils/git/worktrees.mjs';
|
|
30
|
+
import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
31
|
+
import { parseEnvToObject } from './utils/env/dotenv.mjs';
|
|
32
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
33
|
+
import { normalizeStackNameFirstArgs, resolveTopLevelNodeScriptFile, stackNameFromArg } from './stack/command_arguments.mjs';
|
|
34
|
+
import { getStackHelpUsageLine, renderStackRootHelpText, renderStackSubcommandHelpText, STACK_HELP_COMMANDS } from './stack/help_text.mjs';
|
|
35
|
+
import { copyAuthFromStackIntoNewStack } from './stack/copy_auth_from_stack.mjs';
|
|
36
|
+
import {
|
|
37
|
+
getRuntimePortExtraEnv,
|
|
38
|
+
parseServerComponentFromEnv,
|
|
39
|
+
readStackEnvObject,
|
|
40
|
+
resolveDefaultRepoEnv,
|
|
41
|
+
withStackEnv,
|
|
42
|
+
writeStackEnv,
|
|
43
|
+
} from './stack/stack_environment.mjs';
|
|
44
|
+
import { cmdAuth, cmdListStacks, cmdService, cmdSrv, cmdTailscale, cmdWt } from './stack/delegated_script_commands.mjs';
|
|
45
|
+
import { runStackDaemonCommand } from './stack/stack_daemon_command.mjs';
|
|
46
|
+
import { runStackHappierPassthroughCommand } from './stack/stack_happier_passthrough_command.mjs';
|
|
47
|
+
import { runStackMobileInstallCommand } from './stack/stack_mobile_install_command.mjs';
|
|
48
|
+
import { runStackResumeCommand } from './stack/stack_resume_command.mjs';
|
|
49
|
+
import { runStackStopCommand } from './stack/stack_stop_command.mjs';
|
|
50
|
+
import { readStackInfoSnapshot } from './stack/stack_info_snapshot.mjs';
|
|
51
|
+
import { runStackScriptWithStackEnv } from './stack/run_script_with_stack_env.mjs';
|
|
52
|
+
import { printDelegatedStackHelpIfAvailable } from './stack/stack_delegated_help.mjs';
|
|
53
|
+
import { runStackWorkspaceCommand } from './stack/stack_workspace_command.mjs';
|
|
54
|
+
import { resolveRequestedRepoCheckoutDir } from './stack/repo_checkout_resolution.mjs';
|
|
55
|
+
import { resolveTransientRepoOverrides } from './stack/transient_repo_overrides.mjs';
|
|
56
|
+
import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
57
|
+
import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
|
|
58
|
+
import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
|
|
59
|
+
import { startDevServer } from './utils/dev/server.mjs';
|
|
60
|
+
import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
|
|
61
|
+
import { requireDir } from './utils/proc/pm.mjs';
|
|
62
|
+
import { waitForHttpOk } from './utils/server/server.mjs';
|
|
63
|
+
import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
|
|
64
|
+
import { openUrlInBrowser } from './utils/ui/browser.mjs';
|
|
65
|
+
import { buildConfigureServerLinks } from '@happier-dev/cli-common/links';
|
|
66
|
+
import { bold, cyan, dim, green, yellow } from './utils/ui/ansi.mjs';
|
|
67
|
+
import { bullets, sectionTitle } from './utils/ui/layout.mjs';
|
|
68
|
+
import { findAnyCredentialPathInCliHome } from './utils/auth/credentials_paths.mjs';
|
|
69
|
+
import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
|
|
70
|
+
import { getHomeEnvLocalPath } from './utils/env/config.mjs';
|
|
71
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
|
|
72
|
+
import { getEnvValue, getEnvValueAny } from './utils/env/values.mjs';
|
|
73
|
+
import { sanitizeDnsLabel } from './utils/net/dns.mjs';
|
|
74
|
+
import {
|
|
75
|
+
getStackRuntimeStatePath,
|
|
76
|
+
isPidAlive,
|
|
77
|
+
readStackRuntimeStateFile,
|
|
78
|
+
} from './utils/stack/runtime_state.mjs';
|
|
79
|
+
import { killPid } from './utils/expo/expo.mjs';
|
|
80
|
+
import { randomToken } from './utils/crypto/tokens.mjs';
|
|
81
|
+
import { sanitizeSlugPart } from './utils/git/refs.mjs';
|
|
82
|
+
import { readLastLines } from './utils/fs/tail.mjs';
|
|
83
|
+
import { interactiveEdit, interactiveNew } from './utils/stack/interactive_stack_config.mjs';
|
|
84
|
+
import { normalizeStackNameOrNull } from './utils/stack/names.mjs';
|
|
85
|
+
import { runOrchestratedGuidedAuthFlow } from './utils/auth/orchestrated_stack_auth_flow.mjs';
|
|
86
|
+
import { assertExpoWebappBundlesOrThrow } from './utils/auth/stack_guided_login.mjs';
|
|
87
|
+
import { createStepPrinter } from './utils/cli/progress.mjs';
|
|
88
|
+
import { getVerbosityLevel } from './utils/cli/verbosity.mjs';
|
|
89
|
+
import { applyBindModeToEnv, resolveBindModeFromArgs } from './utils/net/bind_mode.mjs';
|
|
90
|
+
import { getTodayYmd } from './utils/time/get_today_ymd.mjs';
|
|
91
|
+
|
|
92
|
+
const readExistingEnv = readTextOrEmpty;
|
|
93
|
+
const STACK_BACKGROUND_SCRIPT_BY_COMMAND = new Map([
|
|
94
|
+
['dev', resolveTopLevelNodeScriptFile('dev') || 'dev.mjs'],
|
|
95
|
+
['start', resolveTopLevelNodeScriptFile('start') || 'run.mjs'],
|
|
96
|
+
]);
|
|
97
|
+
const STACK_REPO_OVERRIDE_SCRIPT_BY_COMMAND = new Map([
|
|
98
|
+
['build', resolveTopLevelNodeScriptFile('build') || 'build.mjs'],
|
|
99
|
+
['typecheck', resolveTopLevelNodeScriptFile('typecheck') || 'typecheck.mjs'],
|
|
100
|
+
['lint', resolveTopLevelNodeScriptFile('lint') || 'lint.mjs'],
|
|
101
|
+
['test', resolveTopLevelNodeScriptFile('test') || 'test_cmd.mjs'],
|
|
102
|
+
['review', resolveTopLevelNodeScriptFile('review') || 'review.mjs'],
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
async function cmdNew({ rootDir, argv, emit = true }) {
|
|
106
|
+
const { flags, kv } = parseArgs(argv);
|
|
107
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
108
|
+
const json = wantsJson(argv, { flags });
|
|
109
|
+
const copyAuth = !(flags.has('--no-copy-auth') || flags.has('--fresh-auth'));
|
|
110
|
+
const copyAuthFrom =
|
|
111
|
+
(kv.get('--copy-auth-from') ?? '').trim() ||
|
|
112
|
+
(process.env.HAPPIER_STACK_AUTH_SEED_FROM ?? '').trim() ||
|
|
113
|
+
'main';
|
|
114
|
+
const linkAuth =
|
|
115
|
+
flags.has('--link-auth') ||
|
|
116
|
+
flags.has('--link') ||
|
|
117
|
+
flags.has('--symlink-auth') ||
|
|
118
|
+
(kv.get('--link-auth') ?? '').trim() === '1' ||
|
|
119
|
+
(kv.get('--auth-mode') ?? '').trim() === 'link' ||
|
|
120
|
+
(kv.get('--copy-auth-mode') ?? '').trim() === 'link' ||
|
|
121
|
+
(process.env.HAPPIER_STACK_AUTH_LINK ?? '').toString().trim() === '1' ||
|
|
122
|
+
(process.env.HAPPIER_STACK_AUTH_MODE ?? '').toString().trim() === 'link';
|
|
123
|
+
const forcePort = flags.has('--force-port');
|
|
124
|
+
const dbProviderRaw = (kv.get('--db-provider') ?? kv.get('--db') ?? '').toString().trim().toLowerCase();
|
|
125
|
+
const databaseUrlOverride = (kv.get('--database-url') ?? '').toString().trim();
|
|
126
|
+
|
|
127
|
+
// argv here is already "args after 'new'", so the first positional is the stack name.
|
|
128
|
+
let stackName = stackNameFromArg(positionals, 0);
|
|
129
|
+
const interactive =
|
|
130
|
+
flags.has('--interactive') ||
|
|
131
|
+
(!flags.has('--non-interactive') && isTty() && !json);
|
|
132
|
+
|
|
133
|
+
const defaults = {
|
|
134
|
+
stackName,
|
|
135
|
+
port: kv.get('--port')?.trim() ? Number(kv.get('--port')) : null,
|
|
136
|
+
serverComponent: (kv.get('--server') ?? '').trim() || '',
|
|
137
|
+
createRemote: (kv.get('--remote') ?? '').trim() || '',
|
|
138
|
+
repo: (kv.get('--repo') ?? kv.get('--repo-dir') ?? '').trim() || null,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let config = defaults;
|
|
142
|
+
if (interactive) {
|
|
143
|
+
config = await withRl((rl) => interactiveNew({ rootDir, rl, defaults }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
stackName = config.stackName?.trim() ? config.stackName.trim() : '';
|
|
147
|
+
if (!stackName) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'[stack] usage: hstack stack new <name> [--port=NNN] [--server=happier-server|happier-server-light] ' +
|
|
150
|
+
'[--repo=<owner/...>|<path>|default] [--remote=<name>] [--db-provider=pglite|sqlite|postgres|mysql] [--database-url=<url>] ' +
|
|
151
|
+
'[--copy-auth-from=<stack>] [--link-auth] [--no-copy-auth] [--interactive] [--non-interactive] [--force-port]'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const normalizedName = normalizeStackNameOrNull(stackName);
|
|
155
|
+
if (!normalizedName) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`[stack] invalid stack name: ${JSON.stringify(stackName)}\n` +
|
|
158
|
+
`[stack] stack names must be DNS-safe labels (lowercase letters/numbers/hyphens).\n` +
|
|
159
|
+
`[stack] Example: my-stack`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
{
|
|
163
|
+
const changedBeyondCase = normalizedName !== stackName.toLowerCase();
|
|
164
|
+
stackName = normalizedName;
|
|
165
|
+
if (!json && emit && changedBeyondCase) {
|
|
166
|
+
console.warn(`[stack] normalized stack name to: ${stackName}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (stackName === 'main') {
|
|
170
|
+
throw new Error('[stack] stack name \"main\" is reserved (use the default stack without creating it)');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const serverComponent = (config.serverComponent || 'happier-server-light').trim();
|
|
174
|
+
if (serverComponent !== 'happier-server-light' && serverComponent !== 'happier-server') {
|
|
175
|
+
throw new Error(`[stack] invalid server component: ${serverComponent}`);
|
|
176
|
+
}
|
|
177
|
+
const effectiveDbProvider =
|
|
178
|
+
dbProviderRaw ||
|
|
179
|
+
(serverComponent === 'happier-server-light' ? 'sqlite' : 'postgres');
|
|
180
|
+
if (serverComponent === 'happier-server-light' && effectiveDbProvider !== 'pglite' && effectiveDbProvider !== 'sqlite') {
|
|
181
|
+
throw new Error(`[stack] invalid --db-provider for happier-server-light: ${effectiveDbProvider} (supported: pglite, sqlite)`);
|
|
182
|
+
}
|
|
183
|
+
if (serverComponent === 'happier-server' && effectiveDbProvider !== 'postgres' && effectiveDbProvider !== 'mysql') {
|
|
184
|
+
throw new Error(`[stack] invalid --db-provider for happier-server: ${effectiveDbProvider} (supported: postgres, mysql)`);
|
|
185
|
+
}
|
|
186
|
+
if (serverComponent === 'happier-server' && effectiveDbProvider === 'mysql' && !databaseUrlOverride) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`[stack] mysql support requires an explicit DATABASE_URL.\n` +
|
|
189
|
+
`Fix:\n` +
|
|
190
|
+
`- re-run with: --database-url=mysql://...\n` +
|
|
191
|
+
`- or use the default: --db-provider=postgres\n`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
196
|
+
const uiBuildDir = join(baseDir, 'ui');
|
|
197
|
+
const cliHomeDir = join(baseDir, 'cli');
|
|
198
|
+
|
|
199
|
+
// Port strategy:
|
|
200
|
+
// - If --port is provided, we treat it as a pinned port and persist it in the stack env.
|
|
201
|
+
// - Otherwise, ports are ephemeral and chosen at stack start time (stored only in stack.runtime.json).
|
|
202
|
+
let port = config.port;
|
|
203
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
204
|
+
port = null;
|
|
205
|
+
}
|
|
206
|
+
if (port != null) {
|
|
207
|
+
// If user picked a port explicitly, fail-closed on collisions by default.
|
|
208
|
+
const reservedPorts = await collectReservedStackPorts();
|
|
209
|
+
if (!forcePort && reservedPorts.has(port)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`[stack] port ${port} is already reserved by another stack env.\n` +
|
|
212
|
+
`Fix:\n` +
|
|
213
|
+
`- omit --port to use an ephemeral port at start time (recommended)\n` +
|
|
214
|
+
`- or pick a different --port\n` +
|
|
215
|
+
`- or re-run with --force-port (not recommended)\n`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (!(await isTcpPortFree(port))) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[stack] port ${port} is not free on 127.0.0.1.\n` +
|
|
221
|
+
`Fix:\n` +
|
|
222
|
+
`- omit --port to use an ephemeral port at start time (recommended)\n` +
|
|
223
|
+
`- or stop the process currently using ${port}\n`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const defaultRepoEnv = resolveDefaultRepoEnv({ rootDir });
|
|
229
|
+
|
|
230
|
+
// Prepare component dirs (may create worktrees).
|
|
231
|
+
const stackEnv = {
|
|
232
|
+
HAPPIER_STACK_STACK: stackName,
|
|
233
|
+
HAPPIER_STACK_SERVER_COMPONENT: serverComponent,
|
|
234
|
+
HAPPIER_STACK_UI_BUILD_DIR: uiBuildDir,
|
|
235
|
+
HAPPIER_STACK_CLI_HOME_DIR: cliHomeDir,
|
|
236
|
+
HAPPIER_STACK_STACK_REMOTE: config.createRemote?.trim() ? config.createRemote.trim() : 'upstream',
|
|
237
|
+
...defaultRepoEnv,
|
|
238
|
+
};
|
|
239
|
+
// Persist DB provider explicitly so existing behavior is stable even if defaults evolve later.
|
|
240
|
+
stackEnv.HAPPIER_DB_PROVIDER = effectiveDbProvider;
|
|
241
|
+
// Power user knob: override DATABASE_URL (required for mysql today, useful for external DBs).
|
|
242
|
+
if (databaseUrlOverride) {
|
|
243
|
+
if (serverComponent === 'happier-server-light') {
|
|
244
|
+
throw new Error('[stack] --database-url is not supported for happier-server-light');
|
|
245
|
+
}
|
|
246
|
+
stackEnv.DATABASE_URL = databaseUrlOverride;
|
|
247
|
+
}
|
|
248
|
+
if (port != null) {
|
|
249
|
+
stackEnv.HAPPIER_STACK_SERVER_PORT = String(port);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Server-light storage isolation: ensure stacks have their own light data dir.
|
|
253
|
+
// (This prevents a dev stack from mutating main stack's data when schema changes.)
|
|
254
|
+
if (serverComponent === 'happier-server-light') {
|
|
255
|
+
const dataDir = join(baseDir, 'server-light');
|
|
256
|
+
stackEnv.HAPPIER_SERVER_LIGHT_DATA_DIR = dataDir;
|
|
257
|
+
stackEnv.HAPPIER_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
|
|
258
|
+
if (effectiveDbProvider !== 'sqlite') {
|
|
259
|
+
stackEnv.HAPPIER_SERVER_LIGHT_DB_DIR = join(dataDir, 'pglite');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (serverComponent === 'happier-server') {
|
|
263
|
+
// Persist stable infra credentials in the stack env (ports are ephemeral unless explicitly pinned).
|
|
264
|
+
const pgUser = 'handy';
|
|
265
|
+
const pgPassword = randomToken(24);
|
|
266
|
+
const pgDb = 'handy';
|
|
267
|
+
const s3Bucket = sanitizeDnsLabel(`happier-${stackName}`, { fallback: 'happier' });
|
|
268
|
+
const s3AccessKey = randomToken(12);
|
|
269
|
+
const s3SecretKey = randomToken(24);
|
|
270
|
+
|
|
271
|
+
stackEnv.HAPPIER_STACK_MANAGED_INFRA = stackEnv.HAPPIER_STACK_MANAGED_INFRA ?? '1';
|
|
272
|
+
stackEnv.HAPPIER_STACK_PG_USER = pgUser;
|
|
273
|
+
stackEnv.HAPPIER_STACK_PG_PASSWORD = pgPassword;
|
|
274
|
+
stackEnv.HAPPIER_STACK_PG_DATABASE = pgDb;
|
|
275
|
+
stackEnv.HAPPIER_STACK_HANDY_MASTER_SECRET_FILE = join(baseDir, 'happier-server', 'handy-master-secret.txt');
|
|
276
|
+
stackEnv.S3_ACCESS_KEY = s3AccessKey;
|
|
277
|
+
stackEnv.S3_SECRET_KEY = s3SecretKey;
|
|
278
|
+
stackEnv.S3_BUCKET = s3Bucket;
|
|
279
|
+
|
|
280
|
+
// If user explicitly pinned the server port, also pin the rest of the ports + derived URLs for reproducibility.
|
|
281
|
+
if (port != null) {
|
|
282
|
+
const reservedPorts = await collectReservedStackPorts();
|
|
283
|
+
reservedPorts.add(port);
|
|
284
|
+
const backendPort = await pickNextFreePort(port + 10, { reservedPorts });
|
|
285
|
+
reservedPorts.add(backendPort);
|
|
286
|
+
const wantsManagedPostgres = effectiveDbProvider === 'postgres' && !databaseUrlOverride;
|
|
287
|
+
const baseInfraPort = port + 1000;
|
|
288
|
+
const dbPort = wantsManagedPostgres ? await pickNextFreePort(baseInfraPort, { reservedPorts }) : null;
|
|
289
|
+
if (dbPort != null) reservedPorts.add(dbPort);
|
|
290
|
+
const redisBase = dbPort != null ? dbPort + 1 : baseInfraPort;
|
|
291
|
+
const redisPort = await pickNextFreePort(redisBase, { reservedPorts });
|
|
292
|
+
reservedPorts.add(redisPort);
|
|
293
|
+
const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts });
|
|
294
|
+
reservedPorts.add(minioPort);
|
|
295
|
+
const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts });
|
|
296
|
+
|
|
297
|
+
const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
|
|
298
|
+
|
|
299
|
+
stackEnv.HAPPIER_STACK_SERVER_BACKEND_PORT = String(backendPort);
|
|
300
|
+
if (dbPort != null) {
|
|
301
|
+
stackEnv.HAPPIER_STACK_PG_PORT = String(dbPort);
|
|
302
|
+
}
|
|
303
|
+
stackEnv.HAPPIER_STACK_REDIS_PORT = String(redisPort);
|
|
304
|
+
stackEnv.HAPPIER_STACK_MINIO_PORT = String(minioPort);
|
|
305
|
+
stackEnv.HAPPIER_STACK_MINIO_CONSOLE_PORT = String(minioConsolePort);
|
|
306
|
+
|
|
307
|
+
// Vars consumed by happier-server:
|
|
308
|
+
if (databaseUrlOverride) {
|
|
309
|
+
stackEnv.DATABASE_URL = databaseUrlOverride;
|
|
310
|
+
} else if (effectiveDbProvider === 'postgres' && dbPort != null) {
|
|
311
|
+
stackEnv.DATABASE_URL = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${dbPort}/${encodeURIComponent(pgDb)}`;
|
|
312
|
+
}
|
|
313
|
+
stackEnv.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
|
|
314
|
+
stackEnv.S3_HOST = '127.0.0.1';
|
|
315
|
+
stackEnv.S3_PORT = String(minioPort);
|
|
316
|
+
stackEnv.S3_USE_SSL = 'false';
|
|
317
|
+
stackEnv.S3_PUBLIC_URL = s3PublicUrl;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Pin the repo checkout/worktree for this stack (single monorepo).
|
|
322
|
+
// Default is already set via resolveDefaultRepoEnv(); this only applies when the user
|
|
323
|
+
// explicitly selected a different repo source.
|
|
324
|
+
if (config.repo) {
|
|
325
|
+
const resolved = await resolveRequestedRepoCheckoutDir({
|
|
326
|
+
rootDir,
|
|
327
|
+
repoSelection: config.repo,
|
|
328
|
+
defaultRepoDir: String(defaultRepoEnv.HAPPIER_STACK_REPO_DIR ?? '').trim(),
|
|
329
|
+
remoteName: config.repo?.remote || stackEnv.HAPPIER_STACK_STACK_REMOTE,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (!resolved || !existsSync(resolved)) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`[stack] repo checkout does not exist: ${resolved || '(empty)'}\n` +
|
|
335
|
+
`Fix:\n` +
|
|
336
|
+
`- run: hstack setup --profile=dev (clones the monorepo into the workspace)\n` +
|
|
337
|
+
`- or pass an explicit --repo=<path|worktreeSpec>\n`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const monoRoot = coerceHappyMonorepoRootFromPath(resolved);
|
|
342
|
+
if (!monoRoot) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`[stack] invalid repo checkout (expected Happier monorepo root): ${resolved}\n` +
|
|
345
|
+
`- expected to contain apps/ui, apps/cli, and apps/server\n`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
stackEnv.HAPPIER_STACK_REPO_DIR = monoRoot;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (copyAuth) {
|
|
352
|
+
// Default: inherit seed stack auth so creating a new stack doesn't require re-login.
|
|
353
|
+
// Source: --copy-auth-from (highest), else HAPPIER_STACK_AUTH_SEED_FROM (default: main).
|
|
354
|
+
// Users can opt out with --no-copy-auth to force a fresh auth / machine identity.
|
|
355
|
+
await copyAuthFromStackIntoNewStack({
|
|
356
|
+
fromStackName: copyAuthFrom,
|
|
357
|
+
stackName,
|
|
358
|
+
stackEnv,
|
|
359
|
+
serverComponent,
|
|
360
|
+
json,
|
|
361
|
+
requireSourceStackExists: kv.has('--copy-auth-from'),
|
|
362
|
+
linkMode: linkAuth,
|
|
363
|
+
}).catch((err) => {
|
|
364
|
+
if (!json && emit) {
|
|
365
|
+
console.warn(`[stack] auth copy skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
366
|
+
console.warn(`[stack] tip: you can always run: hstack stack auth ${stackName} login`);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const envPath = await writeStackEnv({ stackName, env: stackEnv });
|
|
372
|
+
const res = { ok: true, stackName, envPath, port: port ?? null, serverComponent, portsMode: port == null ? 'ephemeral' : 'pinned' };
|
|
373
|
+
if (emit) {
|
|
374
|
+
printResult({
|
|
375
|
+
json,
|
|
376
|
+
data: res,
|
|
377
|
+
text: [
|
|
378
|
+
`[stack] created ${stackName}`,
|
|
379
|
+
`[stack] env: ${envPath}`,
|
|
380
|
+
`[stack] port: ${port == null ? 'ephemeral (picked at start)' : String(port)}`,
|
|
381
|
+
`[stack] server: ${serverComponent}`,
|
|
382
|
+
].join('\n'),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return res;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function cmdEdit({ rootDir, argv }) {
|
|
389
|
+
const { flags } = parseArgs(argv);
|
|
390
|
+
const json = wantsJson(argv, { flags });
|
|
391
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
392
|
+
const stackName = stackNameFromArg(positionals, 1);
|
|
393
|
+
if (!stackName) {
|
|
394
|
+
throw new Error('[stack] usage: hstack stack edit <name> [--interactive]');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const envPath = resolveStackEnvPath(stackName).envPath;
|
|
398
|
+
const raw = await readExistingEnv(envPath);
|
|
399
|
+
const existingEnv = parseEnvToObject(raw);
|
|
400
|
+
|
|
401
|
+
const interactive = flags.has('--interactive') || (!flags.has('--no-interactive') && isTty());
|
|
402
|
+
if (!interactive) {
|
|
403
|
+
throw new Error('[stack] edit currently requires --interactive (non-interactive editing not implemented yet).');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const defaults = {
|
|
407
|
+
stackName,
|
|
408
|
+
port: null,
|
|
409
|
+
serverComponent: '',
|
|
410
|
+
createRemote: '',
|
|
411
|
+
repo: null,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const config = await withRl((rl) => interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }));
|
|
415
|
+
|
|
416
|
+
// Build next env, starting from existing env but enforcing stack-scoped invariants.
|
|
417
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
418
|
+
const uiBuildDir = join(baseDir, 'ui');
|
|
419
|
+
const cliHomeDir = join(baseDir, 'cli');
|
|
420
|
+
|
|
421
|
+
let port = config.port;
|
|
422
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
423
|
+
port = null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const serverComponent = (config.serverComponent || existingEnv.HAPPIER_STACK_SERVER_COMPONENT || 'happier-server-light').trim();
|
|
427
|
+
|
|
428
|
+
const next = {
|
|
429
|
+
HAPPIER_STACK_STACK: stackName,
|
|
430
|
+
HAPPIER_STACK_SERVER_COMPONENT: serverComponent,
|
|
431
|
+
HAPPIER_STACK_UI_BUILD_DIR: uiBuildDir,
|
|
432
|
+
HAPPIER_STACK_CLI_HOME_DIR: cliHomeDir,
|
|
433
|
+
HAPPIER_STACK_STACK_REMOTE: config.createRemote?.trim()
|
|
434
|
+
? config.createRemote.trim()
|
|
435
|
+
: (existingEnv.HAPPIER_STACK_STACK_REMOTE || 'upstream'),
|
|
436
|
+
// Always pin defaults; overrides below can replace.
|
|
437
|
+
...resolveDefaultRepoEnv({ rootDir }),
|
|
438
|
+
};
|
|
439
|
+
if ((existingEnv.HAPPIER_STACK_REPO_DIR ?? '').trim()) {
|
|
440
|
+
next.HAPPIER_STACK_REPO_DIR = String(existingEnv.HAPPIER_STACK_REPO_DIR).trim();
|
|
441
|
+
}
|
|
442
|
+
if (port != null) {
|
|
443
|
+
next.HAPPIER_STACK_SERVER_PORT = String(port);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (serverComponent === 'happier-server-light') {
|
|
447
|
+
const dataDir = join(baseDir, 'server-light');
|
|
448
|
+
next.HAPPIER_SERVER_LIGHT_DATA_DIR = dataDir;
|
|
449
|
+
next.HAPPIER_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
|
|
450
|
+
next.HAPPIER_SERVER_LIGHT_DB_DIR = join(dataDir, 'pglite');
|
|
451
|
+
// Light flavor manages its own embedded pglite connection string at runtime.
|
|
452
|
+
// Do not persist DATABASE_URL in the stack env.
|
|
453
|
+
delete next.DATABASE_URL;
|
|
454
|
+
}
|
|
455
|
+
if (serverComponent === 'happier-server') {
|
|
456
|
+
// Persist stable infra credentials. Ports are ephemeral unless explicitly pinned.
|
|
457
|
+
const pgUser = (existingEnv.HAPPIER_STACK_PG_USER ?? 'handy').trim() || 'handy';
|
|
458
|
+
const pgPassword = (existingEnv.HAPPIER_STACK_PG_PASSWORD ?? '').trim() || randomToken(24);
|
|
459
|
+
const pgDb = (existingEnv.HAPPIER_STACK_PG_DATABASE ?? 'handy').trim() || 'handy';
|
|
460
|
+
const s3Bucket =
|
|
461
|
+
(existingEnv.S3_BUCKET ?? sanitizeDnsLabel(`happier-${stackName}`, { fallback: 'happier' })).trim() ||
|
|
462
|
+
sanitizeDnsLabel(`happier-${stackName}`, { fallback: 'happier' });
|
|
463
|
+
const s3AccessKey = (existingEnv.S3_ACCESS_KEY ?? '').trim() || randomToken(12);
|
|
464
|
+
const s3SecretKey = (existingEnv.S3_SECRET_KEY ?? '').trim() || randomToken(24);
|
|
465
|
+
|
|
466
|
+
next.HAPPIER_STACK_MANAGED_INFRA = (existingEnv.HAPPIER_STACK_MANAGED_INFRA ?? '1').trim() || '1';
|
|
467
|
+
next.HAPPIER_STACK_PG_USER = pgUser;
|
|
468
|
+
next.HAPPIER_STACK_PG_PASSWORD = pgPassword;
|
|
469
|
+
next.HAPPIER_STACK_PG_DATABASE = pgDb;
|
|
470
|
+
next.HAPPIER_STACK_HANDY_MASTER_SECRET_FILE =
|
|
471
|
+
(existingEnv.HAPPIER_STACK_HANDY_MASTER_SECRET_FILE ?? '').trim() || join(baseDir, 'happier-server', 'handy-master-secret.txt');
|
|
472
|
+
next.S3_ACCESS_KEY = s3AccessKey;
|
|
473
|
+
next.S3_SECRET_KEY = s3SecretKey;
|
|
474
|
+
next.S3_BUCKET = s3Bucket;
|
|
475
|
+
|
|
476
|
+
if (port != null) {
|
|
477
|
+
// If user pinned the server port, keep ports + derived URLs stable as well.
|
|
478
|
+
const reservedPorts = await collectReservedStackPorts({ excludeStackName: stackName });
|
|
479
|
+
reservedPorts.add(port);
|
|
480
|
+
const backendPort = existingEnv.HAPPIER_STACK_SERVER_BACKEND_PORT?.trim()
|
|
481
|
+
? Number(existingEnv.HAPPIER_STACK_SERVER_BACKEND_PORT.trim())
|
|
482
|
+
: await pickNextFreePort(port + 10, { reservedPorts });
|
|
483
|
+
reservedPorts.add(backendPort);
|
|
484
|
+
const pgPort = existingEnv.HAPPIER_STACK_PG_PORT?.trim()
|
|
485
|
+
? Number(existingEnv.HAPPIER_STACK_PG_PORT.trim())
|
|
486
|
+
: await pickNextFreePort(port + 1000, { reservedPorts });
|
|
487
|
+
reservedPorts.add(pgPort);
|
|
488
|
+
const redisPort = existingEnv.HAPPIER_STACK_REDIS_PORT?.trim()
|
|
489
|
+
? Number(existingEnv.HAPPIER_STACK_REDIS_PORT.trim())
|
|
490
|
+
: await pickNextFreePort(pgPort + 1, { reservedPorts });
|
|
491
|
+
reservedPorts.add(redisPort);
|
|
492
|
+
const minioPort = existingEnv.HAPPIER_STACK_MINIO_PORT?.trim()
|
|
493
|
+
? Number(existingEnv.HAPPIER_STACK_MINIO_PORT.trim())
|
|
494
|
+
: await pickNextFreePort(redisPort + 1, { reservedPorts });
|
|
495
|
+
reservedPorts.add(minioPort);
|
|
496
|
+
const minioConsolePort = existingEnv.HAPPIER_STACK_MINIO_CONSOLE_PORT?.trim()
|
|
497
|
+
? Number(existingEnv.HAPPIER_STACK_MINIO_CONSOLE_PORT.trim())
|
|
498
|
+
: await pickNextFreePort(minioPort + 1, { reservedPorts });
|
|
499
|
+
|
|
500
|
+
const databaseUrl = `postgresql://${encodeURIComponent(pgUser)}:${encodeURIComponent(pgPassword)}@127.0.0.1:${pgPort}/${encodeURIComponent(pgDb)}`;
|
|
501
|
+
const s3PublicUrl = `http://127.0.0.1:${minioPort}/${s3Bucket}`;
|
|
502
|
+
|
|
503
|
+
next.HAPPIER_STACK_SERVER_BACKEND_PORT = String(backendPort);
|
|
504
|
+
next.HAPPIER_STACK_PG_PORT = String(pgPort);
|
|
505
|
+
next.HAPPIER_STACK_REDIS_PORT = String(redisPort);
|
|
506
|
+
next.HAPPIER_STACK_MINIO_PORT = String(minioPort);
|
|
507
|
+
next.HAPPIER_STACK_MINIO_CONSOLE_PORT = String(minioConsolePort);
|
|
508
|
+
|
|
509
|
+
next.DATABASE_URL = databaseUrl;
|
|
510
|
+
next.REDIS_URL = `redis://127.0.0.1:${redisPort}`;
|
|
511
|
+
next.S3_HOST = '127.0.0.1';
|
|
512
|
+
next.S3_PORT = String(minioPort);
|
|
513
|
+
next.S3_USE_SSL = 'false';
|
|
514
|
+
next.S3_PUBLIC_URL = s3PublicUrl;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Repo pinning (optional update via interactive edit).
|
|
519
|
+
if (config.repo) {
|
|
520
|
+
const resolved = await resolveRequestedRepoCheckoutDir({
|
|
521
|
+
rootDir,
|
|
522
|
+
repoSelection: config.repo,
|
|
523
|
+
remoteName: config.repo?.remote || next.HAPPIER_STACK_STACK_REMOTE,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
if (!resolved || !existsSync(resolved)) {
|
|
527
|
+
throw new Error(`[stack] repo checkout does not exist: ${resolved || '(empty)'}`);
|
|
528
|
+
}
|
|
529
|
+
const monoRoot = coerceHappyMonorepoRootFromPath(resolved);
|
|
530
|
+
if (!monoRoot) {
|
|
531
|
+
throw new Error(`[stack] invalid repo checkout (expected Happier monorepo root): ${resolved}`);
|
|
532
|
+
}
|
|
533
|
+
next.HAPPIER_STACK_REPO_DIR = monoRoot;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const wrote = await writeStackEnv({ stackName, env: next });
|
|
537
|
+
printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
|
|
541
|
+
await runStackScriptWithStackEnv({ rootDir, stackName, scriptPath, args, extraEnv, background });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function cmdAudit({ rootDir, argv }) {
|
|
545
|
+
const { flags, kv } = parseArgs(argv);
|
|
546
|
+
const json = wantsJson(argv, { flags });
|
|
547
|
+
const fix = flags.has('--fix');
|
|
548
|
+
const fixMain = flags.has('--fix-main');
|
|
549
|
+
const fixPorts = flags.has('--fix-ports');
|
|
550
|
+
const fixWorkspace = flags.has('--fix-workspace');
|
|
551
|
+
const fixPaths = flags.has('--fix-paths');
|
|
552
|
+
const unpinPorts = flags.has('--unpin-ports');
|
|
553
|
+
const unpinPortsExceptRaw = (kv.get('--unpin-ports-except') ?? '').trim();
|
|
554
|
+
const unpinPortsExcept = new Set(
|
|
555
|
+
unpinPortsExceptRaw
|
|
556
|
+
.split(',')
|
|
557
|
+
.map((s) => s.trim())
|
|
558
|
+
.filter(Boolean)
|
|
559
|
+
);
|
|
560
|
+
const wantsEnvRepair = Boolean(fix || fixWorkspace || fixPaths);
|
|
561
|
+
|
|
562
|
+
const stacks = await listAllStackNames();
|
|
563
|
+
|
|
564
|
+
const report = [];
|
|
565
|
+
const ports = new Map(); // port -> [stackName]
|
|
566
|
+
const otherWorkspaceRoot = join(getHappyStacksHomeDir(), 'workspace');
|
|
567
|
+
|
|
568
|
+
for (const stackName of stacks) {
|
|
569
|
+
const resolved = resolveStackEnvPath(stackName);
|
|
570
|
+
const envPath = resolved.envPath;
|
|
571
|
+
const baseDir = resolved.baseDir;
|
|
572
|
+
|
|
573
|
+
let raw = await readExistingEnv(envPath);
|
|
574
|
+
let env = parseEnvToObject(raw);
|
|
575
|
+
|
|
576
|
+
// If the env file is missing/empty, optionally reconstruct a safe baseline env.
|
|
577
|
+
if (!raw.trim() && wantsEnvRepair && (stackName !== 'main' || fixMain)) {
|
|
578
|
+
const serverComponent =
|
|
579
|
+
getEnvValue(env, 'HAPPIER_STACK_SERVER_COMPONENT') ||
|
|
580
|
+
'happier-server-light';
|
|
581
|
+
const expectedUi = join(baseDir, 'ui');
|
|
582
|
+
const expectedCli = join(baseDir, 'cli');
|
|
583
|
+
// Port strategy: main is pinned by convention; non-main stacks default to ephemeral ports.
|
|
584
|
+
const reservedPorts = stackName === 'main' ? await collectReservedStackPorts({ excludeStackName: stackName }) : new Set();
|
|
585
|
+
const port = stackName === 'main' ? await pickNextFreePort(getDefaultPortStart(), { reservedPorts }) : null;
|
|
586
|
+
|
|
587
|
+
const nextEnv = {
|
|
588
|
+
HAPPIER_STACK_STACK: stackName,
|
|
589
|
+
HAPPIER_STACK_SERVER_COMPONENT: serverComponent,
|
|
590
|
+
HAPPIER_STACK_UI_BUILD_DIR: expectedUi,
|
|
591
|
+
HAPPIER_STACK_CLI_HOME_DIR: expectedCli,
|
|
592
|
+
HAPPIER_STACK_STACK_REMOTE: 'upstream',
|
|
593
|
+
...resolveDefaultRepoEnv({ rootDir }),
|
|
594
|
+
};
|
|
595
|
+
if (port != null) {
|
|
596
|
+
nextEnv.HAPPIER_STACK_SERVER_PORT = String(port);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (serverComponent === 'happier-server-light') {
|
|
600
|
+
const dataDir = join(baseDir, 'server-light');
|
|
601
|
+
nextEnv.HAPPIER_SERVER_LIGHT_DATA_DIR = dataDir;
|
|
602
|
+
nextEnv.HAPPIER_SERVER_LIGHT_FILES_DIR = join(dataDir, 'files');
|
|
603
|
+
nextEnv.HAPPIER_SERVER_LIGHT_DB_DIR = join(dataDir, 'pglite');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
await writeStackEnv({ stackName, env: nextEnv });
|
|
607
|
+
raw = await readExistingEnv(envPath);
|
|
608
|
+
env = parseEnvToObject(raw);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Optional: unpin ports for non-main stacks (ephemeral port model).
|
|
612
|
+
if (unpinPorts && stackName !== 'main' && !unpinPortsExcept.has(stackName) && raw.trim()) {
|
|
613
|
+
const serverComponentTmp =
|
|
614
|
+
getEnvValue(env, 'HAPPIER_STACK_SERVER_COMPONENT') || 'happier-server-light';
|
|
615
|
+
const remove = [
|
|
616
|
+
// Always remove pinned public server port.
|
|
617
|
+
'HAPPIER_STACK_SERVER_PORT',
|
|
618
|
+
// Happier-server gateway/backend ports.
|
|
619
|
+
'HAPPIER_STACK_SERVER_BACKEND_PORT',
|
|
620
|
+
// Managed infra ports.
|
|
621
|
+
'HAPPIER_STACK_PG_PORT',
|
|
622
|
+
'HAPPIER_STACK_REDIS_PORT',
|
|
623
|
+
'HAPPIER_STACK_MINIO_PORT',
|
|
624
|
+
'HAPPIER_STACK_MINIO_CONSOLE_PORT',
|
|
625
|
+
];
|
|
626
|
+
if (serverComponentTmp === 'happier-server') {
|
|
627
|
+
// These are derived from the ports above; safe to re-compute at start time.
|
|
628
|
+
remove.push('DATABASE_URL', 'REDIS_URL', 'S3_PORT', 'S3_PUBLIC_URL');
|
|
629
|
+
}
|
|
630
|
+
await ensureEnvFilePruned({ envPath, removeKeys: remove });
|
|
631
|
+
raw = await readExistingEnv(envPath);
|
|
632
|
+
env = parseEnvToObject(raw);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const serverComponent = getEnvValue(env, 'HAPPIER_STACK_SERVER_COMPONENT') || 'happier-server-light';
|
|
636
|
+
const portRaw = getEnvValue(env, 'HAPPIER_STACK_SERVER_PORT');
|
|
637
|
+
const port = portRaw ? Number(portRaw) : null;
|
|
638
|
+
if (Number.isFinite(port) && port > 0) {
|
|
639
|
+
const existing = ports.get(port) ?? [];
|
|
640
|
+
existing.push(stackName);
|
|
641
|
+
ports.set(port, existing);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const issues = [];
|
|
645
|
+
|
|
646
|
+
if (!raw.trim()) {
|
|
647
|
+
issues.push({ code: 'missing_env', message: `env file missing/empty (${envPath})` });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const uiBuildDir = getEnvValue(env, 'HAPPIER_STACK_UI_BUILD_DIR');
|
|
651
|
+
const expectedUi = join(baseDir, 'ui');
|
|
652
|
+
if (!uiBuildDir) {
|
|
653
|
+
issues.push({ code: 'missing_ui_build_dir', message: `missing UI build dir (expected ${expectedUi})` });
|
|
654
|
+
} else if (uiBuildDir !== expectedUi) {
|
|
655
|
+
issues.push({ code: 'ui_build_dir_mismatch', message: `UI build dir points to ${uiBuildDir} (expected ${expectedUi})` });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const cliHomeDir = getEnvValue(env, 'HAPPIER_STACK_CLI_HOME_DIR');
|
|
659
|
+
const expectedCli = join(baseDir, 'cli');
|
|
660
|
+
if (!cliHomeDir) {
|
|
661
|
+
issues.push({ code: 'missing_cli_home_dir', message: `missing CLI home dir (expected ${expectedCli})` });
|
|
662
|
+
} else if (cliHomeDir !== expectedCli) {
|
|
663
|
+
issues.push({ code: 'cli_home_dir_mismatch', message: `CLI home dir points to ${cliHomeDir} (expected ${expectedCli})` });
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const missingRepoKeys = [];
|
|
667
|
+
const repoDir = getEnvValue(env, 'HAPPIER_STACK_REPO_DIR');
|
|
668
|
+
if (!repoDir) {
|
|
669
|
+
missingRepoKeys.push('HAPPIER_STACK_REPO_DIR');
|
|
670
|
+
issues.push({ code: 'missing_repo_dir', message: `missing HAPPIER_STACK_REPO_DIR` });
|
|
671
|
+
} else if (!isAbsolute(repoDir)) {
|
|
672
|
+
issues.push({ code: 'relative_repo_dir', message: `HAPPIER_STACK_REPO_DIR is relative (${repoDir}); prefer absolute paths under this workspace` });
|
|
673
|
+
} else {
|
|
674
|
+
const norm = repoDir.replaceAll('\\', '/');
|
|
675
|
+
if (norm.startsWith(otherWorkspaceRoot.replaceAll('\\', '/') + '/')) {
|
|
676
|
+
issues.push({ code: 'foreign_workspace_repo_dir', message: `HAPPIER_STACK_REPO_DIR points to another workspace: ${repoDir}` });
|
|
677
|
+
}
|
|
678
|
+
// Optional: fail-closed existence check.
|
|
679
|
+
if (!existsSync(repoDir)) {
|
|
680
|
+
issues.push({ code: 'missing_repo_path', message: `HAPPIER_STACK_REPO_DIR path does not exist: ${repoDir}` });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Server-light DB/files isolation.
|
|
685
|
+
const isServerLight = serverComponent === 'happier-server-light';
|
|
686
|
+
if (isServerLight) {
|
|
687
|
+
const dataDir = getEnvValue(env, 'HAPPIER_SERVER_LIGHT_DATA_DIR');
|
|
688
|
+
const filesDir = getEnvValue(env, 'HAPPIER_SERVER_LIGHT_FILES_DIR');
|
|
689
|
+
const dbDir = getEnvValue(env, 'HAPPIER_SERVER_LIGHT_DB_DIR');
|
|
690
|
+
const rawDbProvider =
|
|
691
|
+
(getEnvValue(env, 'HAPPIER_DB_PROVIDER') ?? getEnvValue(env, 'HAPPY_DB_PROVIDER') ?? '').toString().trim().toLowerCase();
|
|
692
|
+
const dbProvider = rawDbProvider === 'pglite' ? 'pglite' : 'sqlite';
|
|
693
|
+
const expectedDataDir = join(baseDir, 'server-light');
|
|
694
|
+
const expectedFilesDir = join(expectedDataDir, 'files');
|
|
695
|
+
const expectedDbDir = join(expectedDataDir, 'pglite');
|
|
696
|
+
|
|
697
|
+
if (!dataDir) issues.push({ code: 'missing_server_light_data_dir', message: `missing HAPPIER_SERVER_LIGHT_DATA_DIR (expected ${expectedDataDir})` });
|
|
698
|
+
if (!filesDir) issues.push({ code: 'missing_server_light_files_dir', message: `missing HAPPIER_SERVER_LIGHT_FILES_DIR (expected ${expectedFilesDir})` });
|
|
699
|
+
if (dataDir && dataDir !== expectedDataDir) issues.push({ code: 'server_light_data_dir_mismatch', message: `HAPPIER_SERVER_LIGHT_DATA_DIR=${dataDir} (expected ${expectedDataDir})` });
|
|
700
|
+
if (filesDir && filesDir !== expectedFilesDir) issues.push({ code: 'server_light_files_dir_mismatch', message: `HAPPIER_SERVER_LIGHT_FILES_DIR=${filesDir} (expected ${expectedFilesDir})` });
|
|
701
|
+
if (dbProvider === 'pglite') {
|
|
702
|
+
if (!dbDir) issues.push({ code: 'missing_server_light_db_dir', message: `missing HAPPIER_SERVER_LIGHT_DB_DIR (expected ${expectedDbDir})` });
|
|
703
|
+
if (dbDir && dbDir !== expectedDbDir) issues.push({ code: 'server_light_db_dir_mismatch', message: `HAPPIER_SERVER_LIGHT_DB_DIR=${dbDir} (expected ${expectedDbDir})` });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const legacyDbUrl = getEnvValue(env, 'DATABASE_URL');
|
|
707
|
+
if (legacyDbUrl) {
|
|
708
|
+
issues.push({
|
|
709
|
+
code: 'legacy_database_url',
|
|
710
|
+
message: `DATABASE_URL is set for a light stack (${legacyDbUrl}); light manages its own local database and does not require DATABASE_URL in the stack env`,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Best-effort env repair (opt-in; non-main stacks only by default).
|
|
716
|
+
if ((fix || fixWorkspace || fixPaths) && (stackName !== 'main' || fixMain) && raw.trim()) {
|
|
717
|
+
const updates = [];
|
|
718
|
+
|
|
719
|
+
// Always ensure stack directories are explicitly pinned when missing.
|
|
720
|
+
if (!uiBuildDir) updates.push({ key: 'HAPPIER_STACK_UI_BUILD_DIR', value: expectedUi });
|
|
721
|
+
if (!cliHomeDir) updates.push({ key: 'HAPPIER_STACK_CLI_HOME_DIR', value: expectedCli });
|
|
722
|
+
if (fixPaths) {
|
|
723
|
+
if (uiBuildDir && uiBuildDir !== expectedUi) updates.push({ key: 'HAPPIER_STACK_UI_BUILD_DIR', value: expectedUi });
|
|
724
|
+
if (cliHomeDir && cliHomeDir !== expectedCli) updates.push({ key: 'HAPPIER_STACK_CLI_HOME_DIR', value: expectedCli });
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Pin repo dir if missing (best-effort).
|
|
728
|
+
if (missingRepoKeys.length) {
|
|
729
|
+
const defaults = resolveDefaultRepoEnv({ rootDir });
|
|
730
|
+
const repo = String(defaults.HAPPIER_STACK_REPO_DIR ?? '').trim();
|
|
731
|
+
if (repo) {
|
|
732
|
+
updates.push({ key: 'HAPPIER_STACK_REPO_DIR', value: repo });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Server-light storage isolation.
|
|
737
|
+
if (isServerLight) {
|
|
738
|
+
const dataDir = getEnvValue(env, 'HAPPIER_SERVER_LIGHT_DATA_DIR');
|
|
739
|
+
const filesDir = getEnvValue(env, 'HAPPIER_SERVER_LIGHT_FILES_DIR');
|
|
740
|
+
const dbDir = getEnvValue(env, 'HAPPIER_SERVER_LIGHT_DB_DIR');
|
|
741
|
+
const expectedDataDir = join(baseDir, 'server-light');
|
|
742
|
+
const expectedFilesDir = join(expectedDataDir, 'files');
|
|
743
|
+
const expectedDbDir = join(expectedDataDir, 'pglite');
|
|
744
|
+
if (!dataDir || (fixPaths && dataDir !== expectedDataDir)) updates.push({ key: 'HAPPIER_SERVER_LIGHT_DATA_DIR', value: expectedDataDir });
|
|
745
|
+
if (!filesDir || (fixPaths && filesDir !== expectedFilesDir)) updates.push({ key: 'HAPPIER_SERVER_LIGHT_FILES_DIR', value: expectedFilesDir });
|
|
746
|
+
if (!dbDir || (fixPaths && dbDir !== expectedDbDir)) updates.push({ key: 'HAPPIER_SERVER_LIGHT_DB_DIR', value: expectedDbDir });
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (fixWorkspace) {
|
|
750
|
+
const repoKey = 'HAPPIER_STACK_REPO_DIR';
|
|
751
|
+
const current = getEnvValue(env, repoKey);
|
|
752
|
+
if (current) {
|
|
753
|
+
const otherNorm = otherWorkspaceRoot.replaceAll('\\', '/') + '/';
|
|
754
|
+
const abs = isAbsolute(current) ? current : resolve(getWorkspaceDir(rootDir, env), current);
|
|
755
|
+
const norm = abs.replaceAll('\\', '/');
|
|
756
|
+
if (norm.startsWith(otherNorm)) {
|
|
757
|
+
// Map any path under another workspace root back into this workspace root.
|
|
758
|
+
const rel = norm.slice(otherNorm.length);
|
|
759
|
+
const candidate = resolve(getWorkspaceDir(rootDir, process.env), rel);
|
|
760
|
+
if (existsSync(candidate)) {
|
|
761
|
+
updates.push({ key: repoKey, value: candidate });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (updates.length) {
|
|
768
|
+
await ensureEnvFileUpdated({ envPath, updates });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Light stacks no longer persist DATABASE_URL in the env file (light uses embedded PGlite).
|
|
772
|
+
// For legacy SQLite-era stacks, prune it when fixing paths so future commands don't accidentally
|
|
773
|
+
// treat the stack as SQLite-backed.
|
|
774
|
+
if (isServerLight && fixPaths) {
|
|
775
|
+
const legacyDbUrl = getEnvValue(env, 'DATABASE_URL');
|
|
776
|
+
if (legacyDbUrl) {
|
|
777
|
+
await ensureEnvFilePruned({ envPath, removeKeys: ['DATABASE_URL'] });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
report.push({
|
|
783
|
+
stackName,
|
|
784
|
+
envPath,
|
|
785
|
+
baseDir,
|
|
786
|
+
serverComponent,
|
|
787
|
+
serverPort: Number.isFinite(port) ? port : null,
|
|
788
|
+
uiBuildDir: uiBuildDir || null,
|
|
789
|
+
cliHomeDir: cliHomeDir || null,
|
|
790
|
+
issues,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Port collisions (post-pass)
|
|
795
|
+
const collisions = [];
|
|
796
|
+
for (const [port, names] of ports.entries()) {
|
|
797
|
+
if (names.length <= 1) continue;
|
|
798
|
+
collisions.push({ port, names: Array.from(names) });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Optional: fix collisions by reassigning ports (non-main stacks only by default).
|
|
802
|
+
if (fixPorts) {
|
|
803
|
+
const allowMain = Boolean(fixMain);
|
|
804
|
+
const planned = await collectReservedStackPorts();
|
|
805
|
+
const byName = new Map(report.map((r) => [r.stackName, r]));
|
|
806
|
+
|
|
807
|
+
const parsePg = (url) => {
|
|
808
|
+
try {
|
|
809
|
+
const u = new URL(url);
|
|
810
|
+
const db = u.pathname?.replace(/^\//, '') || '';
|
|
811
|
+
return {
|
|
812
|
+
user: decodeURIComponent(u.username || ''),
|
|
813
|
+
password: decodeURIComponent(u.password || ''),
|
|
814
|
+
db,
|
|
815
|
+
host: u.hostname || '127.0.0.1',
|
|
816
|
+
};
|
|
817
|
+
} catch {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
for (const c of collisions) {
|
|
823
|
+
const names = c.names.slice().sort();
|
|
824
|
+
// Keep the first stack stable; reassign others to reduce churn.
|
|
825
|
+
const keep = names[0];
|
|
826
|
+
for (const stackName of names.slice(1)) {
|
|
827
|
+
if (stackName === 'main' && !allowMain) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const entry = byName.get(stackName);
|
|
831
|
+
if (!entry) continue;
|
|
832
|
+
if (!entry.envPath) continue;
|
|
833
|
+
const raw = await readExistingEnv(entry.envPath);
|
|
834
|
+
if (!raw.trim()) continue;
|
|
835
|
+
const env = parseEnvToObject(raw);
|
|
836
|
+
|
|
837
|
+
const serverComponent =
|
|
838
|
+
getEnvValue(env, 'HAPPIER_STACK_SERVER_COMPONENT') || 'happier-server-light';
|
|
839
|
+
const portRaw = getEnvValue(env, 'HAPPIER_STACK_SERVER_PORT');
|
|
840
|
+
const currentPort = portRaw ? Number(portRaw) : NaN;
|
|
841
|
+
if (Number.isFinite(currentPort) && currentPort > 0) {
|
|
842
|
+
// Fail-safe: don't rewrite ports for a stack that appears to be actively running.
|
|
843
|
+
// Otherwise we can strand a running server/daemon on a now-stale port.
|
|
844
|
+
// eslint-disable-next-line no-await-in-loop
|
|
845
|
+
const free = await isPortFree(currentPort);
|
|
846
|
+
if (!free) {
|
|
847
|
+
entry.issues.push({
|
|
848
|
+
code: 'port_fix_skipped_running',
|
|
849
|
+
message: `skipped port reassignment because port ${currentPort} is currently in use (stop the stack and re-run --fix-ports)`,
|
|
850
|
+
});
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const startFrom = Number.isFinite(currentPort) && currentPort > 0 ? currentPort + 1 : getDefaultPortStart();
|
|
855
|
+
|
|
856
|
+
const updates = [];
|
|
857
|
+
const newServerPort = await pickNextFreePort(startFrom, { reservedPorts: planned });
|
|
858
|
+
planned.add(newServerPort);
|
|
859
|
+
updates.push({ key: 'HAPPIER_STACK_SERVER_PORT', value: String(newServerPort) });
|
|
860
|
+
|
|
861
|
+
if (serverComponent === 'happier-server') {
|
|
862
|
+
planned.add(newServerPort);
|
|
863
|
+
const backendPort = await pickNextFreePort(newServerPort + 10, { reservedPorts: planned });
|
|
864
|
+
planned.add(backendPort);
|
|
865
|
+
const pgPort = await pickNextFreePort(newServerPort + 1000, { reservedPorts: planned });
|
|
866
|
+
planned.add(pgPort);
|
|
867
|
+
const redisPort = await pickNextFreePort(pgPort + 1, { reservedPorts: planned });
|
|
868
|
+
planned.add(redisPort);
|
|
869
|
+
const minioPort = await pickNextFreePort(redisPort + 1, { reservedPorts: planned });
|
|
870
|
+
planned.add(minioPort);
|
|
871
|
+
const minioConsolePort = await pickNextFreePort(minioPort + 1, { reservedPorts: planned });
|
|
872
|
+
planned.add(minioConsolePort);
|
|
873
|
+
|
|
874
|
+
updates.push({ key: 'HAPPIER_STACK_SERVER_BACKEND_PORT', value: String(backendPort) });
|
|
875
|
+
updates.push({ key: 'HAPPIER_STACK_PG_PORT', value: String(pgPort) });
|
|
876
|
+
updates.push({ key: 'HAPPIER_STACK_REDIS_PORT', value: String(redisPort) });
|
|
877
|
+
updates.push({ key: 'HAPPIER_STACK_MINIO_PORT', value: String(minioPort) });
|
|
878
|
+
updates.push({ key: 'HAPPIER_STACK_MINIO_CONSOLE_PORT', value: String(minioConsolePort) });
|
|
879
|
+
|
|
880
|
+
// Update URLs while preserving existing credentials.
|
|
881
|
+
const pgUser = getEnvValue(env, 'HAPPIER_STACK_PG_USER') || 'handy';
|
|
882
|
+
const pgPassword = getEnvValue(env, 'HAPPIER_STACK_PG_PASSWORD') || '';
|
|
883
|
+
const pgDb = getEnvValue(env, 'HAPPIER_STACK_PG_DATABASE') || 'handy';
|
|
884
|
+
let user = pgUser;
|
|
885
|
+
let pass = pgPassword;
|
|
886
|
+
let db = pgDb;
|
|
887
|
+
const parsed = parsePg(getEnvValue(env, 'DATABASE_URL'));
|
|
888
|
+
if (parsed) {
|
|
889
|
+
if (parsed.user) user = parsed.user;
|
|
890
|
+
if (parsed.password) pass = parsed.password;
|
|
891
|
+
if (parsed.db) db = parsed.db;
|
|
892
|
+
}
|
|
893
|
+
const databaseUrl = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@127.0.0.1:${pgPort}/${encodeURIComponent(db)}`;
|
|
894
|
+
updates.push({ key: 'DATABASE_URL', value: databaseUrl });
|
|
895
|
+
updates.push({ key: 'REDIS_URL', value: `redis://127.0.0.1:${redisPort}` });
|
|
896
|
+
updates.push({ key: 'S3_PORT', value: String(minioPort) });
|
|
897
|
+
const bucket = getEnvValue(env, 'S3_BUCKET') || sanitizeDnsLabel(`happier-${stackName}`, { fallback: 'happier' });
|
|
898
|
+
updates.push({ key: 'S3_PUBLIC_URL', value: `http://127.0.0.1:${minioPort}/${bucket}` });
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
await ensureEnvFileUpdated({ envPath: entry.envPath, updates });
|
|
902
|
+
|
|
903
|
+
// Update in-memory report for follow-up collision recomputation.
|
|
904
|
+
entry.serverPort = newServerPort;
|
|
905
|
+
entry.issues.push({ code: 'port_reassigned', message: `server port reassigned -> ${newServerPort} (was ${currentPort || 'unknown'})` });
|
|
906
|
+
}
|
|
907
|
+
// Ensure the "kept" one remains reserved in planned as well.
|
|
908
|
+
const keptEntry = byName.get(keep);
|
|
909
|
+
if (keptEntry?.serverPort) planned.add(keptEntry.serverPort);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Recompute port collisions after optional fixes.
|
|
914
|
+
for (const r of report) {
|
|
915
|
+
r.issues = (r.issues ?? []).filter((i) => i.code !== 'port_collision');
|
|
916
|
+
}
|
|
917
|
+
const portsNow = new Map();
|
|
918
|
+
for (const r of report) {
|
|
919
|
+
if (!Number.isFinite(r.serverPort) || r.serverPort == null) continue;
|
|
920
|
+
const existing = portsNow.get(r.serverPort) ?? [];
|
|
921
|
+
existing.push(r.stackName);
|
|
922
|
+
portsNow.set(r.serverPort, existing);
|
|
923
|
+
}
|
|
924
|
+
for (const [port, names] of portsNow.entries()) {
|
|
925
|
+
if (names.length <= 1) continue;
|
|
926
|
+
for (const r of report) {
|
|
927
|
+
if (r.serverPort === port) {
|
|
928
|
+
r.issues.push({ code: 'port_collision', message: `server port ${port} is also used by: ${names.filter((n) => n !== r.stackName).join(', ')}` });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const out = {
|
|
934
|
+
ok: true,
|
|
935
|
+
fixed: Boolean(fix || fixPorts || fixWorkspace || fixPaths || unpinPorts),
|
|
936
|
+
stacks: report,
|
|
937
|
+
summary: {
|
|
938
|
+
total: report.length,
|
|
939
|
+
withIssues: report.filter((r) => (r.issues ?? []).length > 0).length,
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
if (json) {
|
|
944
|
+
printResult({ json, data: out });
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
console.log('[stack] audit');
|
|
949
|
+
for (const r of report) {
|
|
950
|
+
const issueCount = (r.issues ?? []).length;
|
|
951
|
+
const status = issueCount ? `issues=${issueCount}` : 'ok';
|
|
952
|
+
console.log(`- ${r.stackName} (${status})`);
|
|
953
|
+
if (issueCount) {
|
|
954
|
+
for (const i of r.issues) console.log(` - ${i.code}: ${i.message}`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (fix) {
|
|
958
|
+
console.log('');
|
|
959
|
+
console.log('[stack] audit: applied best-effort fixes (missing keys only).');
|
|
960
|
+
} else {
|
|
961
|
+
console.log('');
|
|
962
|
+
console.log('[stack] tip: run with --fix to add missing safe defaults (non-main stacks only).');
|
|
963
|
+
console.log('[stack] tip: include --fix-main if you also want to modify main stack env defaults.');
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
async function cmdCreateDevAuthSeed({ rootDir, argv }) {
|
|
968
|
+
const { flags, kv } = parseArgs(argv);
|
|
969
|
+
const json = wantsJson(argv, { flags });
|
|
970
|
+
|
|
971
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
972
|
+
const name = (positionals[1] ?? '').trim() || 'dev-auth';
|
|
973
|
+
const serverComponent = (kv.get('--server') ?? '').trim() || 'happier-server-light';
|
|
974
|
+
const interactive = !flags.has('--non-interactive') && (flags.has('--interactive') || isTty());
|
|
975
|
+
const bindMode = resolveBindModeFromArgs({ flags, kv });
|
|
976
|
+
const skipDefaultSeed =
|
|
977
|
+
flags.has('--skip-default-seed') || flags.has('--no-default-seed') || flags.has('--no-configure-default-seed');
|
|
978
|
+
const forceLogin =
|
|
979
|
+
flags.has('--login') ? true : flags.has('--no-login') || flags.has('--skip-login') ? false : null;
|
|
980
|
+
|
|
981
|
+
if (json) {
|
|
982
|
+
// Keep JSON mode non-interactive and stable by using the existing stack command output.
|
|
983
|
+
// (We intentionally don't run the guided login flow in JSON mode.)
|
|
984
|
+
const createArgs = ['new', name, '--no-copy-auth', '--server', serverComponent, '--json'];
|
|
985
|
+
const created = await runCapture(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), ...createArgs], { cwd: rootDir, env: process.env }).catch((e) => {
|
|
986
|
+
throw new Error(
|
|
987
|
+
`[stack] create-dev-auth-seed: failed to create auth seed stack "${name}": ${e instanceof Error ? e.message : String(e)}`
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
printResult({
|
|
992
|
+
json,
|
|
993
|
+
data: {
|
|
994
|
+
ok: true,
|
|
995
|
+
seedStack: name,
|
|
996
|
+
serverComponent,
|
|
997
|
+
created: created.trim() ? JSON.parse(created.trim()) : { ok: true },
|
|
998
|
+
next: {
|
|
999
|
+
login: `hstack stack auth ${name} login`,
|
|
1000
|
+
setEnv: `# add to ${getHomeEnvLocalPath()}:\nHAPPIER_STACK_AUTH_SEED_FROM=${name}\nHAPPIER_STACK_AUTO_AUTH_SEED=1`,
|
|
1001
|
+
reseedAll: `hstack auth copy-from ${name} --all --except=main,${name}`,
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Create the seed stack as fresh auth (no copy) so it doesn't share main identity.
|
|
1009
|
+
// IMPORTANT: do this in-process (no recursive spawn) so the env file is definitely written
|
|
1010
|
+
// before we run any guided steps (withStackEnv/login).
|
|
1011
|
+
if (!stackExistsSync(name)) {
|
|
1012
|
+
await cmdNew({
|
|
1013
|
+
rootDir,
|
|
1014
|
+
argv: [name, '--no-copy-auth', '--server', serverComponent],
|
|
1015
|
+
});
|
|
1016
|
+
} else {
|
|
1017
|
+
console.log(`[stack] auth seed stack already exists: ${name}`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (!stackExistsSync(name)) {
|
|
1021
|
+
throw new Error(`[stack] create-dev-auth-seed: expected stack "${name}" to exist after creation, but it does not`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Interactive convenience: guide login first, then configure env.local + store dev key.
|
|
1025
|
+
if (interactive) {
|
|
1026
|
+
await withRl(async (rl) => {
|
|
1027
|
+
let savedDevKey = false;
|
|
1028
|
+
const wantLogin =
|
|
1029
|
+
forceLogin != null
|
|
1030
|
+
? forceLogin
|
|
1031
|
+
: await promptSelect(rl, {
|
|
1032
|
+
title: `${bold('dev-auth seed stack')}\n${dim('Recommended: do the guided login now so the seed is ready immediately.')}`,
|
|
1033
|
+
options: [
|
|
1034
|
+
{ label: `yes (${green('recommended')}) — start temporary server + UI and log in`, value: true },
|
|
1035
|
+
{ label: `no — I will do this later`, value: false },
|
|
1036
|
+
],
|
|
1037
|
+
defaultIndex: 0,
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
if (wantLogin) {
|
|
1041
|
+
console.log('');
|
|
1042
|
+
console.log(`[stack] starting ${serverComponent} temporarily so we can log in...`);
|
|
1043
|
+
|
|
1044
|
+
const verbosity = getVerbosityLevel(process.env);
|
|
1045
|
+
const quietAuthFlow = verbosity === 0;
|
|
1046
|
+
const steps = createStepPrinter({ enabled: quietAuthFlow });
|
|
1047
|
+
|
|
1048
|
+
// Pick a temporary server port for the guided login flow.
|
|
1049
|
+
// Respect HAPPIER_STACK_STACK_PORT_START so VM/CI environments can avoid host port collisions
|
|
1050
|
+
// without pinning stack env ports explicitly.
|
|
1051
|
+
const serverPortStart = getDefaultPortStart(name);
|
|
1052
|
+
const serverPort = await pickNextFreeTcpPort(serverPortStart, { host: '127.0.0.1' });
|
|
1053
|
+
const internalServerUrl = `http://127.0.0.1:${serverPort}`;
|
|
1054
|
+
const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
|
|
1055
|
+
|
|
1056
|
+
const logDir = join(getHappyStacksHomeDir(process.env), 'logs', 'dev-auth');
|
|
1057
|
+
await mkdir(logDir, { recursive: true }).catch(() => {});
|
|
1058
|
+
const serverLogPath = join(logDir, `server.${Date.now()}.log`);
|
|
1059
|
+
const expoLogPath = join(logDir, `expo.${Date.now()}.log`);
|
|
1060
|
+
|
|
1061
|
+
const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
|
|
1062
|
+
const children = [];
|
|
1063
|
+
|
|
1064
|
+
await withStackEnv({
|
|
1065
|
+
stackName: name,
|
|
1066
|
+
extraEnv: {
|
|
1067
|
+
// Make sure stack auth login uses the same port we just picked, and avoid inheriting
|
|
1068
|
+
// any global/public URL (e.g. main stack’s Tailscale URL) for this guided flow.
|
|
1069
|
+
HAPPIER_STACK_SERVER_PORT: String(serverPort),
|
|
1070
|
+
HAPPIER_STACK_SERVER_URL: '',
|
|
1071
|
+
...(bindMode
|
|
1072
|
+
? applyBindModeToEnv(
|
|
1073
|
+
{
|
|
1074
|
+
// start from empty so we only inject the bind override keys here
|
|
1075
|
+
},
|
|
1076
|
+
bindMode
|
|
1077
|
+
)
|
|
1078
|
+
: {}),
|
|
1079
|
+
},
|
|
1080
|
+
fn: async ({ env }) => {
|
|
1081
|
+
if (bindMode) {
|
|
1082
|
+
applyBindModeToEnv(env, bindMode);
|
|
1083
|
+
}
|
|
1084
|
+
const resolvedServerDir = getComponentDir(rootDir, serverComponent, env);
|
|
1085
|
+
const resolvedCliDir = getComponentDir(rootDir, 'happier-cli', env);
|
|
1086
|
+
const resolvedUiDir = getComponentDir(rootDir, 'happier-ui', env);
|
|
1087
|
+
|
|
1088
|
+
await requireDir(serverComponent, resolvedServerDir);
|
|
1089
|
+
await requireDir('happier-cli', resolvedCliDir);
|
|
1090
|
+
await requireDir('happier-ui', resolvedUiDir);
|
|
1091
|
+
|
|
1092
|
+
let serverProc = null;
|
|
1093
|
+
let uiProc = null;
|
|
1094
|
+
let uiStopRequested = false;
|
|
1095
|
+
try {
|
|
1096
|
+
steps.start('start temporary server');
|
|
1097
|
+
const started = await startDevServer({
|
|
1098
|
+
serverComponentName: serverComponent,
|
|
1099
|
+
serverDir: resolvedServerDir,
|
|
1100
|
+
autostart,
|
|
1101
|
+
baseEnv: env,
|
|
1102
|
+
serverPort,
|
|
1103
|
+
internalServerUrl,
|
|
1104
|
+
publicServerUrl,
|
|
1105
|
+
envPath: env.HAPPIER_STACK_ENV_FILE ?? '',
|
|
1106
|
+
stackMode: true,
|
|
1107
|
+
runtimeStatePath: null,
|
|
1108
|
+
serverAlreadyRunning: false,
|
|
1109
|
+
restart: true,
|
|
1110
|
+
children,
|
|
1111
|
+
spawnOptions: quietAuthFlow ? { silent: true, teeFile: serverLogPath, teeLabel: 'server' } : {},
|
|
1112
|
+
quiet: quietAuthFlow,
|
|
1113
|
+
});
|
|
1114
|
+
serverProc = started.serverProc;
|
|
1115
|
+
steps.stop('✓', 'start temporary server');
|
|
1116
|
+
|
|
1117
|
+
// Start Expo (web) so /terminal/connect exists for happier-cli web auth.
|
|
1118
|
+
steps.start('start temporary UI');
|
|
1119
|
+
const uiRes = await ensureDevExpoServer({
|
|
1120
|
+
startUi: true,
|
|
1121
|
+
startMobile: false,
|
|
1122
|
+
uiDir: resolvedUiDir,
|
|
1123
|
+
autostart,
|
|
1124
|
+
baseEnv: env,
|
|
1125
|
+
// In the browser, prefer localhost for API calls.
|
|
1126
|
+
apiServerUrl: publicServerUrl,
|
|
1127
|
+
restart: false,
|
|
1128
|
+
stackMode: true,
|
|
1129
|
+
runtimeStatePath: null,
|
|
1130
|
+
stackName: name,
|
|
1131
|
+
envPath: env.HAPPIER_STACK_ENV_FILE ?? '',
|
|
1132
|
+
children,
|
|
1133
|
+
spawnOptions: quietAuthFlow ? { silent: true, teeFile: expoLogPath, teeLabel: 'expo' } : {},
|
|
1134
|
+
quiet: quietAuthFlow,
|
|
1135
|
+
});
|
|
1136
|
+
if (uiRes?.skipped === false && uiRes.proc) {
|
|
1137
|
+
uiProc = uiRes.proc;
|
|
1138
|
+
}
|
|
1139
|
+
steps.stop('✓', 'start temporary UI');
|
|
1140
|
+
|
|
1141
|
+
if (quietAuthFlow && uiProc) {
|
|
1142
|
+
uiProc.once('exit', (code, sig) => {
|
|
1143
|
+
// We intentionally SIGINT Expo when we're done with login.
|
|
1144
|
+
if (uiStopRequested && (sig === 'SIGINT' || sig === 'SIGTERM')) return;
|
|
1145
|
+
if (code === 0) return;
|
|
1146
|
+
void (async () => {
|
|
1147
|
+
const c = typeof code === 'number' ? code : null;
|
|
1148
|
+
// eslint-disable-next-line no-console
|
|
1149
|
+
console.error(`[stack] Expo exited unexpectedly (code=${c ?? 'null'}, sig=${sig ?? 'null'})`);
|
|
1150
|
+
// eslint-disable-next-line no-console
|
|
1151
|
+
console.error(`[stack] expo log: ${expoLogPath}`);
|
|
1152
|
+
const tail = await readLastLines(expoLogPath, 80);
|
|
1153
|
+
if (tail) {
|
|
1154
|
+
// eslint-disable-next-line no-console
|
|
1155
|
+
console.error('');
|
|
1156
|
+
// eslint-disable-next-line no-console
|
|
1157
|
+
console.error(tail.trimEnd());
|
|
1158
|
+
}
|
|
1159
|
+
})();
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
console.log('');
|
|
1164
|
+
const uiPort = uiRes?.port;
|
|
1165
|
+
const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
|
|
1166
|
+
const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
|
|
1167
|
+
const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
|
|
1168
|
+
|
|
1169
|
+
console.log(`[stack] step 1/3: create a ${cyan('dev-auth')} account in the UI (this generates the dev key)`);
|
|
1170
|
+
if (uiRoot) {
|
|
1171
|
+
console.log(`[stack] waiting for UI to be ready...`);
|
|
1172
|
+
// Prefer localhost for readiness checks (faster/more reliable), even though we
|
|
1173
|
+
// instruct the user to use the stack-scoped *.localhost origin for storage isolation.
|
|
1174
|
+
await waitForHttpOk(uiRootLocalhost || uiRoot, { timeoutMs: 30_000 });
|
|
1175
|
+
try {
|
|
1176
|
+
await assertExpoWebappBundlesOrThrow({
|
|
1177
|
+
rootDir,
|
|
1178
|
+
stackName: name,
|
|
1179
|
+
webappUrl: uiRootLocalhost || uiRoot,
|
|
1180
|
+
});
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
1183
|
+
throw new Error(
|
|
1184
|
+
`[stack] temporary UI is reachable, but the Expo web bundle is not ready.\n` +
|
|
1185
|
+
`${detail}\n` +
|
|
1186
|
+
`[stack] expo log: ${expoLogPath}`
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
console.log(`- open: ${uiRoot}`);
|
|
1190
|
+
console.log(`- click: "Create Account"`);
|
|
1191
|
+
console.log(`- then open: ${uiSettings}`);
|
|
1192
|
+
console.log(`- tap: "Secret Key" to reveal + copy it`);
|
|
1193
|
+
console.log('');
|
|
1194
|
+
console.log(`${bold('Press Enter')} to open it in your browser.`);
|
|
1195
|
+
await prompt(rl, '', { defaultValue: '' });
|
|
1196
|
+
if (uiProc && uiProc.exitCode != null && uiProc.exitCode !== 0) {
|
|
1197
|
+
throw new Error(`[stack] Expo exited unexpectedly (code=${uiProc.exitCode}). See log: ${expoLogPath}`);
|
|
1198
|
+
}
|
|
1199
|
+
await openUrlInBrowser(uiRoot).catch(() => {});
|
|
1200
|
+
console.log(`${green('✓')} Browser opened`);
|
|
1201
|
+
} else {
|
|
1202
|
+
console.log(`- UI is running but the port was not detected; rerun with DEBUG logs if needed`);
|
|
1203
|
+
}
|
|
1204
|
+
await prompt(rl, `Press Enter once you've created the account in the UI... `);
|
|
1205
|
+
|
|
1206
|
+
console.log('');
|
|
1207
|
+
console.log(`[stack] step 2/3: save the dev key locally ${dim('(optional; helps UI restore + automation)')}`);
|
|
1208
|
+
const keyInput = (await prompt(
|
|
1209
|
+
rl,
|
|
1210
|
+
`Paste the Secret Key now (from Settings → Account → Secret Key). Leave empty to skip: `
|
|
1211
|
+
)).trim();
|
|
1212
|
+
if (keyInput) {
|
|
1213
|
+
const res = await writeDevAuthKey({ env: process.env, input: keyInput });
|
|
1214
|
+
savedDevKey = true;
|
|
1215
|
+
console.log(`[stack] dev key saved: ${res.path}`);
|
|
1216
|
+
} else {
|
|
1217
|
+
console.log(`[stack] dev key not saved; you can do it later with: ${yellow('hstack auth dev-key --set="<key>"')}`);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
console.log('');
|
|
1221
|
+
console.log(`[stack] step 3/3: authenticate the CLI against this stack ${dim('(web auth)')}`);
|
|
1222
|
+
console.log(`[stack] launching unified guided auth flow`);
|
|
1223
|
+
await runOrchestratedGuidedAuthFlow({
|
|
1224
|
+
rootDir,
|
|
1225
|
+
stackName: name,
|
|
1226
|
+
env,
|
|
1227
|
+
verbosity,
|
|
1228
|
+
json: false,
|
|
1229
|
+
});
|
|
1230
|
+
} finally {
|
|
1231
|
+
if (uiProc) {
|
|
1232
|
+
console.log('');
|
|
1233
|
+
console.log(`[stack] stopping temporary UI (pid=${uiProc.pid})...`);
|
|
1234
|
+
uiStopRequested = true;
|
|
1235
|
+
killProcessTree(uiProc, 'SIGINT');
|
|
1236
|
+
await Promise.race([
|
|
1237
|
+
new Promise((resolve) => uiProc.on('exit', resolve)),
|
|
1238
|
+
new Promise((resolve) => setTimeout(resolve, 15_000)),
|
|
1239
|
+
]);
|
|
1240
|
+
}
|
|
1241
|
+
if (serverProc) {
|
|
1242
|
+
console.log('');
|
|
1243
|
+
console.log(`[stack] stopping temporary server (pid=${serverProc.pid})...`);
|
|
1244
|
+
killProcessTree(serverProc, 'SIGINT');
|
|
1245
|
+
await Promise.race([
|
|
1246
|
+
new Promise((resolve) => serverProc.on('exit', resolve)),
|
|
1247
|
+
new Promise((resolve) => setTimeout(resolve, 15_000)),
|
|
1248
|
+
]);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
console.log('');
|
|
1255
|
+
console.log('[stack] login step complete.');
|
|
1256
|
+
} else {
|
|
1257
|
+
console.log(`[stack] skipping guided login. You can do it later with: ${yellow(`hstack stack auth ${name} login`)}`);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (!skipDefaultSeed) {
|
|
1261
|
+
const envLocalPath = getHomeEnvLocalPath();
|
|
1262
|
+
const wantEnv = await promptSelect(rl, {
|
|
1263
|
+
title:
|
|
1264
|
+
`${bold('Automatic sign-in for new stacks')}\n` +
|
|
1265
|
+
`${dim(`Recommended: when you create a new stack, copy/symlink auth from ${cyan(name)} automatically.`)}\n` +
|
|
1266
|
+
`${dim(`This writes ${cyan('HAPPIER_STACK_AUTO_AUTH_SEED=1')} + ${cyan(`HAPPIER_STACK_AUTH_SEED_FROM=${name}`)} in ${envLocalPath}.`)}`,
|
|
1267
|
+
options: [
|
|
1268
|
+
{ label: `yes (${green('recommended')}) — enable automatic auth seeding`, value: true },
|
|
1269
|
+
{ label: `no — I will configure this later`, value: false },
|
|
1270
|
+
],
|
|
1271
|
+
defaultIndex: 0,
|
|
1272
|
+
});
|
|
1273
|
+
if (wantEnv) {
|
|
1274
|
+
await ensureEnvFileUpdated({
|
|
1275
|
+
envPath: envLocalPath,
|
|
1276
|
+
updates: [
|
|
1277
|
+
{ key: 'HAPPIER_STACK_AUTH_SEED_FROM', value: name },
|
|
1278
|
+
{ key: 'HAPPIER_STACK_AUTO_AUTH_SEED', value: '1' },
|
|
1279
|
+
],
|
|
1280
|
+
});
|
|
1281
|
+
console.log(`[stack] updated: ${envLocalPath}`);
|
|
1282
|
+
} else {
|
|
1283
|
+
console.log(
|
|
1284
|
+
`[stack] tip: set in ${envLocalPath}: HAPPIER_STACK_AUTH_SEED_FROM=${name} and HAPPIER_STACK_AUTO_AUTH_SEED=1`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (!savedDevKey) {
|
|
1290
|
+
const wantKey = await promptSelect(rl, {
|
|
1291
|
+
title: `${bold('Dev key (optional, sensitive)')}\n${dim('Save a dev key locally so you can restore the UI account quickly (and support automation).')}`,
|
|
1292
|
+
options: [
|
|
1293
|
+
{ label: 'no (default)', value: false },
|
|
1294
|
+
{ label: `yes — save a dev key now`, value: true },
|
|
1295
|
+
],
|
|
1296
|
+
defaultIndex: 0,
|
|
1297
|
+
});
|
|
1298
|
+
if (wantKey) {
|
|
1299
|
+
console.log(`[stack] paste the secret key (base64url OR backup-format like XXXXX-XXXXX-...):`);
|
|
1300
|
+
const input = (await prompt(rl, `dev key: `)).trim();
|
|
1301
|
+
if (input) {
|
|
1302
|
+
try {
|
|
1303
|
+
const res = await writeDevAuthKey({ env: process.env, input });
|
|
1304
|
+
console.log(`[stack] dev key saved: ${res.path}`);
|
|
1305
|
+
} catch (e) {
|
|
1306
|
+
console.warn(`[stack] dev key not saved: ${e instanceof Error ? e.message : String(e)}`);
|
|
1307
|
+
}
|
|
1308
|
+
} else {
|
|
1309
|
+
console.log('[stack] dev key not provided; skipping');
|
|
1310
|
+
}
|
|
1311
|
+
} else {
|
|
1312
|
+
console.log(`[stack] tip: you can set it later with: ${yellow('hstack auth dev-key --set="<key>"')}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
} else {
|
|
1317
|
+
console.log(`- set as default seed (recommended) in ${getHomeEnvLocalPath()}:`);
|
|
1318
|
+
console.log(` HAPPIER_STACK_AUTH_SEED_FROM=${name}`);
|
|
1319
|
+
console.log(` HAPPIER_STACK_AUTO_AUTH_SEED=1`);
|
|
1320
|
+
console.log(`- (optional) seed existing stacks: hstack auth copy-from ${name} --all --except=main,${name}`);
|
|
1321
|
+
console.log(`- (optional) store dev key for UI automation: hstack auth dev-key --set="<key>"`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function cmdArchiveStack({ rootDir, argv, stackName }) {
|
|
1326
|
+
const { flags, kv } = parseArgs(argv);
|
|
1327
|
+
const json = wantsJson(argv, { flags });
|
|
1328
|
+
const dryRun = flags.has('--dry-run');
|
|
1329
|
+
const date = (kv.get('--date') ?? '').toString().trim() || getTodayYmd();
|
|
1330
|
+
|
|
1331
|
+
if (!stackExistsSync(stackName)) {
|
|
1332
|
+
throw new Error(`[stack] archive: stack does not exist: ${stackName}`);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const { env } = await readStackEnvObject(stackName);
|
|
1336
|
+
|
|
1337
|
+
const workspaceDir = getWorkspaceDir(rootDir);
|
|
1338
|
+
|
|
1339
|
+
// Collect unique git worktree roots referenced by this stack.
|
|
1340
|
+
const byRoot = new Map();
|
|
1341
|
+
const rawRepo = (env.HAPPIER_STACK_REPO_DIR ?? '').toString().trim();
|
|
1342
|
+
if (rawRepo) {
|
|
1343
|
+
const abs = isAbsolute(rawRepo) ? rawRepo : resolve(workspaceDir, rawRepo);
|
|
1344
|
+
// Only archive paths that live under workspace worktree categories (<workspace>/{pr,local,tmp}/...).
|
|
1345
|
+
if (isWorktreePath({ rootDir, dir: abs, env: process.env })) {
|
|
1346
|
+
try {
|
|
1347
|
+
const top = (await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd: abs })).trim();
|
|
1348
|
+
if (top) {
|
|
1349
|
+
byRoot.set(top, { dir: top });
|
|
1350
|
+
}
|
|
1351
|
+
} catch {
|
|
1352
|
+
// ignore invalid git dirs
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const { baseDir } = resolveStackEnvPath(stackName);
|
|
1358
|
+
const destStackDir = join(dirname(baseDir), '.archived', date, stackName);
|
|
1359
|
+
|
|
1360
|
+
// Safety: avoid archiving a worktree that is still actively referenced by other stacks.
|
|
1361
|
+
// If we did, we'd break those stacks by moving their active checkout.
|
|
1362
|
+
if (!dryRun && byRoot.size) {
|
|
1363
|
+
const otherStacks = new Map(); // envPath -> Set(keys)
|
|
1364
|
+
const otherNames = new Set();
|
|
1365
|
+
|
|
1366
|
+
for (const wt of byRoot.values()) {
|
|
1367
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1368
|
+
const out = await runCapture(
|
|
1369
|
+
process.execPath,
|
|
1370
|
+
[join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', wt.dir, '--dry-run', `--date=${date}`, '--json'],
|
|
1371
|
+
{ cwd: rootDir, env: process.env }
|
|
1372
|
+
);
|
|
1373
|
+
const info = JSON.parse(out);
|
|
1374
|
+
const linked = Array.isArray(info.linkedStacks) ? info.linkedStacks : [];
|
|
1375
|
+
for (const s of linked) {
|
|
1376
|
+
if (!s?.name || s.name === stackName) continue;
|
|
1377
|
+
otherNames.add(s.name);
|
|
1378
|
+
const envPath = String(s.envPath ?? '').trim();
|
|
1379
|
+
if (!envPath) continue;
|
|
1380
|
+
const set = otherStacks.get(envPath) ?? new Set();
|
|
1381
|
+
for (const k of Array.isArray(s.keys) ? s.keys : []) {
|
|
1382
|
+
if (k) set.add(String(k));
|
|
1383
|
+
}
|
|
1384
|
+
otherStacks.set(envPath, set);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (otherNames.size) {
|
|
1389
|
+
const names = Array.from(otherNames).sort().join(', ');
|
|
1390
|
+
if (json || !isTty()) {
|
|
1391
|
+
throw new Error(`[stack] archive: worktree(s) are still referenced by other stacks: ${names}. Resolve first (detach or archive those stacks).`);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const action = await withRl(async (rl) => {
|
|
1395
|
+
return await promptSelect(rl, {
|
|
1396
|
+
title: `Worktree(s) referenced by "${stackName}" are still in use by other stacks: ${names}`,
|
|
1397
|
+
options: [
|
|
1398
|
+
{ label: 'abort (recommended)', value: 'abort' },
|
|
1399
|
+
{ label: 'detach those stacks from the shared worktree(s)', value: 'detach' },
|
|
1400
|
+
{ label: 'archive the linked stacks as well', value: 'archive-stacks' },
|
|
1401
|
+
],
|
|
1402
|
+
defaultIndex: 0,
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
if (action === 'abort') {
|
|
1407
|
+
throw new Error('[stack] archive aborted');
|
|
1408
|
+
}
|
|
1409
|
+
if (action === 'archive-stacks') {
|
|
1410
|
+
for (const name of Array.from(otherNames).sort()) {
|
|
1411
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1412
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'archive', name, `--date=${date}`], { cwd: rootDir, env: process.env });
|
|
1413
|
+
}
|
|
1414
|
+
} else {
|
|
1415
|
+
for (const [envPath, keys] of otherStacks.entries()) {
|
|
1416
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1417
|
+
await ensureEnvFilePruned({ envPath, removeKeys: Array.from(keys) });
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if (dryRun) {
|
|
1424
|
+
return {
|
|
1425
|
+
ok: true,
|
|
1426
|
+
dryRun: true,
|
|
1427
|
+
stackName,
|
|
1428
|
+
date,
|
|
1429
|
+
stackBaseDir: baseDir,
|
|
1430
|
+
archivedStackDir: destStackDir,
|
|
1431
|
+
worktrees: Array.from(byRoot.values()),
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
await mkdir(dirname(destStackDir), { recursive: true });
|
|
1436
|
+
await rename(baseDir, destStackDir);
|
|
1437
|
+
|
|
1438
|
+
const archivedWorktrees = [];
|
|
1439
|
+
for (const wt of byRoot.values()) {
|
|
1440
|
+
if (!existsSync(wt.dir)) continue;
|
|
1441
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1442
|
+
const out = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', wt.dir, `--date=${date}`, '--json'], {
|
|
1443
|
+
cwd: rootDir,
|
|
1444
|
+
env: process.env,
|
|
1445
|
+
});
|
|
1446
|
+
archivedWorktrees.push(JSON.parse(out));
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return { ok: true, dryRun: false, stackName, date, archivedStackDir: destStackDir, archivedWorktrees };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// (removed) per-component stack pinning: stacks now pin a single monorepo checkout via HAPPIER_STACK_REPO_DIR.
|
|
1453
|
+
|
|
1454
|
+
async function cmdDuplicate({ rootDir, argv }) {
|
|
1455
|
+
const { flags, kv } = parseArgs(argv);
|
|
1456
|
+
const json = wantsJson(argv, { flags });
|
|
1457
|
+
|
|
1458
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1459
|
+
const fromStack = (positionals[1] ?? '').trim();
|
|
1460
|
+
let toStack = (positionals[2] ?? '').trim();
|
|
1461
|
+
if (!fromStack || !toStack) {
|
|
1462
|
+
throw new Error('[stack] usage: hstack stack duplicate <from> <to> [--duplicate-worktrees] [--deps=...] [--json]');
|
|
1463
|
+
}
|
|
1464
|
+
if (toStack === 'main') {
|
|
1465
|
+
throw new Error('[stack] refusing to duplicate into stack name "main"');
|
|
1466
|
+
}
|
|
1467
|
+
if (!stackExistsSync(fromStack)) {
|
|
1468
|
+
throw new Error(`[stack] duplicate: source stack does not exist: ${fromStack}`);
|
|
1469
|
+
}
|
|
1470
|
+
if (stackExistsSync(toStack)) {
|
|
1471
|
+
throw new Error(`[stack] duplicate: destination stack already exists: ${toStack}`);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const duplicateWorktrees =
|
|
1475
|
+
flags.has('--duplicate-worktrees') ||
|
|
1476
|
+
flags.has('--with-worktrees') ||
|
|
1477
|
+
(kv.get('--duplicate-worktrees') ?? '').trim() === '1';
|
|
1478
|
+
const depsMode = (kv.get('--deps') ?? '').trim(); // forwarded to wt new when duplicating worktrees
|
|
1479
|
+
|
|
1480
|
+
const { env: fromEnv } = await readStackEnvObject(fromStack);
|
|
1481
|
+
const serverComponent = parseServerComponentFromEnv(fromEnv);
|
|
1482
|
+
|
|
1483
|
+
// Create the destination stack env with the correct baseDir and defaults (do not copy auth/data).
|
|
1484
|
+
const created = await cmdNew({
|
|
1485
|
+
rootDir,
|
|
1486
|
+
argv: [toStack, '--no-copy-auth', '--server', serverComponent],
|
|
1487
|
+
});
|
|
1488
|
+
toStack = created?.stackName ?? toStack;
|
|
1489
|
+
|
|
1490
|
+
const fromRepoDir = String(fromEnv.HAPPIER_STACK_REPO_DIR ?? '').trim();
|
|
1491
|
+
if (!fromRepoDir) {
|
|
1492
|
+
throw new Error(`[stack] duplicate: source stack is missing HAPPIER_STACK_REPO_DIR (${fromStack})`);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
let nextRepoDir = fromRepoDir;
|
|
1496
|
+
if (duplicateWorktrees && isWorktreePath({ rootDir, dir: fromRepoDir, env: fromEnv })) {
|
|
1497
|
+
const spec = worktreeSpecFromDir({ rootDir, component: 'happier-ui', dir: fromRepoDir, env: fromEnv });
|
|
1498
|
+
if (spec) {
|
|
1499
|
+
// Duplicate into a disposable tmp worktree by default. This avoids collisions and keeps
|
|
1500
|
+
// the new stack isolated even if the source worktree is later archived/deleted.
|
|
1501
|
+
const slugSafe = sanitizeSlugPart(spec.replaceAll('/', '-'));
|
|
1502
|
+
const slug = `tmp/dup/${sanitizeSlugPart(toStack)}/${slugSafe || 'worktree'}`;
|
|
1503
|
+
|
|
1504
|
+
const remoteName = 'upstream';
|
|
1505
|
+
const created = await createWorktreeFromBaseWorktree({
|
|
1506
|
+
rootDir,
|
|
1507
|
+
component: 'happier-ui',
|
|
1508
|
+
slug,
|
|
1509
|
+
baseWorktreeSpec: spec,
|
|
1510
|
+
remoteName,
|
|
1511
|
+
depsMode,
|
|
1512
|
+
env: fromEnv,
|
|
1513
|
+
});
|
|
1514
|
+
nextRepoDir = coerceHappyMonorepoRootFromPath(created) || created;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
const updates = [{ key: 'HAPPIER_STACK_REPO_DIR', value: nextRepoDir }];
|
|
1519
|
+
|
|
1520
|
+
// Apply component dir overrides to the destination stack env file.
|
|
1521
|
+
const toEnvPath = resolveStackEnvPath(toStack).envPath;
|
|
1522
|
+
if (updates.length) {
|
|
1523
|
+
await ensureEnvFileUpdated({ envPath: toEnvPath, updates });
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const out = {
|
|
1527
|
+
ok: true,
|
|
1528
|
+
from: fromStack,
|
|
1529
|
+
to: toStack,
|
|
1530
|
+
serverComponent,
|
|
1531
|
+
duplicatedWorktrees: duplicateWorktrees,
|
|
1532
|
+
updatedKeys: updates.map((u) => u.key),
|
|
1533
|
+
envPath: toEnvPath,
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
if (json) {
|
|
1537
|
+
printResult({ json, data: out });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
console.log(`[stack] duplicated: ${fromStack} -> ${toStack}`);
|
|
1542
|
+
console.log(`[stack] env: ${toEnvPath}`);
|
|
1543
|
+
if (duplicateWorktrees) {
|
|
1544
|
+
console.log(`[stack] worktrees: duplicated (deps=${depsMode || 'none'})`);
|
|
1545
|
+
} else {
|
|
1546
|
+
console.log('[stack] worktrees: not duplicated (reusing existing component dirs)');
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function cmdInfo({ rootDir, argv }) {
|
|
1551
|
+
const { flags } = parseArgs(argv);
|
|
1552
|
+
const json = wantsJson(argv, { flags });
|
|
1553
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
1554
|
+
const stackName = (positionals[1] ?? '').trim();
|
|
1555
|
+
if (!stackName) {
|
|
1556
|
+
throw new Error('[stack] usage: hstack stack info <name> [--json]');
|
|
1557
|
+
}
|
|
1558
|
+
if (!stackExistsSync(stackName)) {
|
|
1559
|
+
throw new Error(`[stack] info: stack does not exist: ${stackName}`);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const out = await readStackInfoSnapshot({ rootDir, stackName });
|
|
1563
|
+
if (json) {
|
|
1564
|
+
printResult({ json, data: out });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
console.log(`[stack] info: ${stackName}`);
|
|
1569
|
+
console.log(`- env: ${out.envPath}`);
|
|
1570
|
+
console.log(`- runtime: ${out.runtimeStatePath}`);
|
|
1571
|
+
console.log(`- server: ${out.serverComponent}`);
|
|
1572
|
+
const runningPid = Number(out.runtime?.runningPid);
|
|
1573
|
+
const ownerPid = Number(out.runtime?.ownerPid);
|
|
1574
|
+
const runningPidSuffix = Number.isFinite(runningPid) && runningPid > 1
|
|
1575
|
+
? ` (pid=${runningPid})`
|
|
1576
|
+
: Number.isFinite(ownerPid) && ownerPid > 1
|
|
1577
|
+
? ` (ownerPid=${ownerPid})`
|
|
1578
|
+
: '';
|
|
1579
|
+
console.log(`- running: ${out.runtime?.running ? 'yes' : 'no'}${runningPidSuffix}`);
|
|
1580
|
+
if (typeof out.runtime?.health?.status === 'string' && out.runtime.health.status) {
|
|
1581
|
+
const issues = Array.isArray(out.runtime.health.issues) ? out.runtime.health.issues : [];
|
|
1582
|
+
const issueSuffix = issues.length > 0 ? ` (${issues.join(',')})` : '';
|
|
1583
|
+
console.log(`- health: ${out.runtime.health.status}${issueSuffix}`);
|
|
1584
|
+
}
|
|
1585
|
+
if (out.ports.server) console.log(`- port: server=${out.ports.server}${out.ports.backend ? ` backend=${out.ports.backend}` : ''}`);
|
|
1586
|
+
if (out.ports.ui) {
|
|
1587
|
+
const uiRunning = out.runtime?.components?.ui?.running !== false;
|
|
1588
|
+
console.log(`- port: ui=${out.ports.ui}${uiRunning ? '' : ' (unreachable)'}`);
|
|
1589
|
+
}
|
|
1590
|
+
if (out.urls.uiUrl && out.runtime?.components?.ui?.running !== false) {
|
|
1591
|
+
console.log(`- ui: ${out.urls.uiUrl}`);
|
|
1592
|
+
} else if (out.ports.ui && out.runtime?.components?.ui?.running === false) {
|
|
1593
|
+
console.log(`- ui: unavailable (re-run: hstack stack dev ${stackName} --restart)`);
|
|
1594
|
+
}
|
|
1595
|
+
if (out.urls.internalServerUrl) console.log(`- internal: ${out.urls.internalServerUrl}`);
|
|
1596
|
+
if (out.pinned.serverPort) console.log(`- pinned: serverPort=${out.pinned.serverPort}`);
|
|
1597
|
+
if (out.repo?.dir) {
|
|
1598
|
+
console.log(`- repo: ${out.repo.dir}${out.repo.worktreeSpec ? ` (${out.repo.worktreeSpec})` : ''}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (out.dirs?.uiDir) console.log(`- dir: ui=${out.dirs.uiDir}`);
|
|
1601
|
+
if (out.dirs?.cliDir) console.log(`- dir: cli=${out.dirs.cliDir}`);
|
|
1602
|
+
if (out.dirs?.serverDir) console.log(`- dir: server=${out.dirs.serverDir}`);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async function cmdPrStack({ rootDir, argv }) {
|
|
1606
|
+
// Supports passing args to the eventual `stack dev/start` via `-- ...`.
|
|
1607
|
+
const sep = argv.indexOf('--');
|
|
1608
|
+
const argv0 = sep >= 0 ? argv.slice(0, sep) : argv;
|
|
1609
|
+
const passthrough = sep >= 0 ? argv.slice(sep + 1) : [];
|
|
1610
|
+
|
|
1611
|
+
const { flags, kv } = parseArgs(argv0);
|
|
1612
|
+
const json = wantsJson(argv0, { flags });
|
|
1613
|
+
|
|
1614
|
+
if (wantsHelp(argv0, { flags })) {
|
|
1615
|
+
printResult({
|
|
1616
|
+
json,
|
|
1617
|
+
data: {
|
|
1618
|
+
usage:
|
|
1619
|
+
'hstack stack pr <name> --repo=<pr-url|number> [--server-flavor=light|full] [--server=happier-server|happier-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--background] [--mobile] [--expo-tailscale] [--json] [-- <stack dev/start args...>]',
|
|
1620
|
+
},
|
|
1621
|
+
text: [
|
|
1622
|
+
'[stack] usage:',
|
|
1623
|
+
' hstack stack pr <name> --repo=<pr-url|number> [--dev|--start]',
|
|
1624
|
+
' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
|
|
1625
|
+
' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
|
|
1626
|
+
' [--mobile] # also start Expo dev-client Metro for mobile',
|
|
1627
|
+
' [--expo-tailscale] # forward Expo to Tailscale interface for remote access',
|
|
1628
|
+
' [--json] [-- <stack dev/start args...>]',
|
|
1629
|
+
'',
|
|
1630
|
+
'examples:',
|
|
1631
|
+
' # Create stack + check out PRs + start dev UI',
|
|
1632
|
+
' hstack stack pr pr123 \\',
|
|
1633
|
+
' --repo=https://github.com/happier-dev/happier/pull/123 \\',
|
|
1634
|
+
' --seed-auth --copy-auth-from=dev-auth \\',
|
|
1635
|
+
' --dev',
|
|
1636
|
+
'',
|
|
1637
|
+
' # Use numeric PR refs (remote defaults to upstream)',
|
|
1638
|
+
' hstack stack pr pr123 --repo=123 --seed-auth --copy-auth-from=dev-auth --dev',
|
|
1639
|
+
'',
|
|
1640
|
+
'notes:',
|
|
1641
|
+
' - This composes existing commands: `hstack stack new`, `hstack stack wt ...`, and `hstack stack auth ...`',
|
|
1642
|
+
' - For auth seeding, pass `--seed-auth` and optionally `--copy-auth-from=dev-auth` (or main)',
|
|
1643
|
+
' - `--link-auth` symlinks auth files instead of copying (keeps credentials in sync, but reduces isolation)',
|
|
1644
|
+
].join('\n'),
|
|
1645
|
+
});
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const positionals = argv0.filter((a) => !a.startsWith('--'));
|
|
1650
|
+
let stackName = (positionals[1] ?? '').trim();
|
|
1651
|
+
if (!stackName) {
|
|
1652
|
+
throw new Error('[stack] pr: missing stack name. Usage: hstack stack pr <name> --repo=<pr>');
|
|
1653
|
+
}
|
|
1654
|
+
{
|
|
1655
|
+
const normalizedName = normalizeStackNameOrNull(stackName);
|
|
1656
|
+
if (!normalizedName) {
|
|
1657
|
+
throw new Error(
|
|
1658
|
+
`[stack] pr: invalid stack name: ${JSON.stringify(stackName)}\n` +
|
|
1659
|
+
`[stack] stack names must be DNS-safe labels (lowercase letters/numbers/hyphens).\n` +
|
|
1660
|
+
`[stack] Example: pr-123`
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
const changedBeyondCase = normalizedName !== stackName.toLowerCase();
|
|
1664
|
+
stackName = normalizedName;
|
|
1665
|
+
if (!json && changedBeyondCase) {
|
|
1666
|
+
console.warn(`[stack] normalized stack name to: ${stackName}`);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (stackName === 'main') {
|
|
1670
|
+
throw new Error('[stack] pr: stack name "main" is reserved; pick a unique name for this PR stack');
|
|
1671
|
+
}
|
|
1672
|
+
const reuseExisting = flags.has('--reuse') || flags.has('--update-existing') || (kv.get('--reuse') ?? '').trim() === '1';
|
|
1673
|
+
const stackExists = stackExistsSync(stackName);
|
|
1674
|
+
if (stackExists && !reuseExisting) {
|
|
1675
|
+
throw new Error(
|
|
1676
|
+
`[stack] pr: stack already exists: ${stackName}\n` +
|
|
1677
|
+
`[stack] tip: re-run with --reuse to update the existing PR worktrees and keep the stack wiring intact`
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
|
|
1682
|
+
const depsMode = (kv.get('--deps') ?? '').trim();
|
|
1683
|
+
const dbProviderFromArg = (kv.get('--db-provider') ?? kv.get('--db') ?? '').toString().trim();
|
|
1684
|
+
const databaseUrlFromArg = (kv.get('--database-url') ?? '').toString().trim();
|
|
1685
|
+
|
|
1686
|
+
const prRepo = (kv.get('--repo') ?? kv.get('--pr') ?? '').trim();
|
|
1687
|
+
if (!prRepo) {
|
|
1688
|
+
throw new Error('[stack] pr: missing PR input. Provide --repo=<pr-url|number>.');
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const serverFlavorFromArg = (kv.get('--server-flavor') ?? '').trim().toLowerCase();
|
|
1692
|
+
const serverFromArg = (kv.get('--server') ?? '').trim();
|
|
1693
|
+
const serverComponent =
|
|
1694
|
+
serverFlavorFromArg === 'full'
|
|
1695
|
+
? 'happier-server'
|
|
1696
|
+
: serverFlavorFromArg === 'light'
|
|
1697
|
+
? 'happier-server-light'
|
|
1698
|
+
: (serverFromArg || 'happier-server-light').trim();
|
|
1699
|
+
if (serverComponent !== 'happier-server' && serverComponent !== 'happier-server-light') {
|
|
1700
|
+
throw new Error(`[stack] pr: invalid --server: ${serverFromArg || serverComponent}`);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const wantsDev = flags.has('--dev') || flags.has('--start-dev');
|
|
1704
|
+
const wantsStart = flags.has('--start') || flags.has('--prod');
|
|
1705
|
+
if (wantsDev && wantsStart) {
|
|
1706
|
+
throw new Error('[stack] pr: choose either --dev or --start (not both)');
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
|
|
1710
|
+
const wantsExpoTailscale = flags.has('--expo-tailscale');
|
|
1711
|
+
const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
|
|
1712
|
+
|
|
1713
|
+
const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
|
|
1714
|
+
const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
|
|
1715
|
+
const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
|
|
1716
|
+
const authForce = flags.has('--auth-force') || flags.has('--force-auth');
|
|
1717
|
+
const authLinkFlag = flags.has('--link-auth') || flags.has('--link') || flags.has('--symlink-auth') ? true : null;
|
|
1718
|
+
const authLinkEnv =
|
|
1719
|
+
(process.env.HAPPIER_STACK_AUTH_LINK ?? '').toString().trim() === '1' ||
|
|
1720
|
+
(process.env.HAPPIER_STACK_AUTH_MODE ?? '').toString().trim() === 'link';
|
|
1721
|
+
|
|
1722
|
+
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !json;
|
|
1723
|
+
|
|
1724
|
+
const hasMainAccessKey = Boolean(findAnyCredentialPathInCliHome({ cliHomeDir: join(resolveStackEnvPath('main').baseDir, 'cli') }));
|
|
1725
|
+
const hasDevAuthAccessKey =
|
|
1726
|
+
existsSync(resolveStackEnvPath('dev-auth').envPath) &&
|
|
1727
|
+
Boolean(findAnyCredentialPathInCliHome({ cliHomeDir: join(resolveStackEnvPath('dev-auth').baseDir, 'cli') }));
|
|
1728
|
+
const hasLegacyAccessKey =
|
|
1729
|
+
existsSync(resolveStackEnvPath('legacy').envPath) &&
|
|
1730
|
+
Boolean(findAnyCredentialPathInCliHome({ cliHomeDir: join(resolveStackEnvPath('legacy').baseDir, 'cli') }));
|
|
1731
|
+
|
|
1732
|
+
const inferredSeedFromEnv = resolveAuthSeedFromEnv(process.env);
|
|
1733
|
+
const inferredSeedFromAvailability = hasDevAuthAccessKey ? 'dev-auth' : hasMainAccessKey ? 'main' : 'main';
|
|
1734
|
+
const defaultAuthFrom = authFromFlag || inferredSeedFromEnv || inferredSeedFromAvailability;
|
|
1735
|
+
|
|
1736
|
+
// Default behavior for stack pr:
|
|
1737
|
+
// - if user explicitly flags --seed-auth/--no-seed-auth, obey
|
|
1738
|
+
// - otherwise in interactive mode: prompt when we have *some* plausible source, default yes
|
|
1739
|
+
// - in non-interactive mode: follow HAPPIER_STACK_AUTO_AUTH_SEED (if set), else default false
|
|
1740
|
+
const envAutoSeed =
|
|
1741
|
+
(process.env.HAPPIER_STACK_AUTO_AUTH_SEED ?? '').toString().trim();
|
|
1742
|
+
const autoSeedEnabled = envAutoSeed ? envAutoSeed !== '0' : false;
|
|
1743
|
+
|
|
1744
|
+
let seedAuth = seedAuthFlag != null ? seedAuthFlag : autoSeedEnabled;
|
|
1745
|
+
let authFrom = defaultAuthFrom;
|
|
1746
|
+
let authLink = authLinkFlag != null ? authLinkFlag : authLinkEnv;
|
|
1747
|
+
|
|
1748
|
+
if (seedAuthFlag == null && isInteractive) {
|
|
1749
|
+
const anySource = hasDevAuthAccessKey || hasMainAccessKey || hasLegacyAccessKey;
|
|
1750
|
+
if (anySource) {
|
|
1751
|
+
seedAuth = await withRl(async (rl) => {
|
|
1752
|
+
return await promptSelect(rl, {
|
|
1753
|
+
title: 'Seed authentication into this PR stack so it works without a re-login?',
|
|
1754
|
+
options: [
|
|
1755
|
+
{ label: 'yes (recommended)', value: true },
|
|
1756
|
+
{ label: 'no (I will login manually for this stack)', value: false },
|
|
1757
|
+
],
|
|
1758
|
+
defaultIndex: 0,
|
|
1759
|
+
});
|
|
1760
|
+
});
|
|
1761
|
+
} else {
|
|
1762
|
+
seedAuth = false;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
if (seedAuth && !authFromFlag && isInteractive) {
|
|
1767
|
+
const options = [];
|
|
1768
|
+
if (hasDevAuthAccessKey) {
|
|
1769
|
+
options.push({ label: 'dev-auth (recommended) — use your dedicated dev auth seed stack', value: 'dev-auth' });
|
|
1770
|
+
}
|
|
1771
|
+
if (hasMainAccessKey) {
|
|
1772
|
+
options.push({ label: 'main — use hstack main credentials', value: 'main' });
|
|
1773
|
+
}
|
|
1774
|
+
options.push({ label: 'skip seeding (manual login)', value: 'skip' });
|
|
1775
|
+
|
|
1776
|
+
const defaultIdx = Math.max(
|
|
1777
|
+
0,
|
|
1778
|
+
options.findIndex((o) => o.value === (hasDevAuthAccessKey ? 'dev-auth' : hasMainAccessKey ? 'main' : 'skip'))
|
|
1779
|
+
);
|
|
1780
|
+
const picked = await withRl(async (rl) => {
|
|
1781
|
+
return await promptSelect(rl, {
|
|
1782
|
+
title: 'Which auth source should this PR stack use?',
|
|
1783
|
+
options,
|
|
1784
|
+
defaultIndex: defaultIdx,
|
|
1785
|
+
});
|
|
1786
|
+
});
|
|
1787
|
+
if (picked === 'skip') {
|
|
1788
|
+
seedAuth = false;
|
|
1789
|
+
} else {
|
|
1790
|
+
authFrom = String(picked);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
if (seedAuth && authLinkFlag == null && isInteractive) {
|
|
1795
|
+
authLink = await withRl(async (rl) => {
|
|
1796
|
+
return await promptSelect(rl, {
|
|
1797
|
+
title: 'When seeding, reuse credentials via symlink or copy?',
|
|
1798
|
+
options: [
|
|
1799
|
+
{ label: 'symlink (recommended) — stays up to date', value: true },
|
|
1800
|
+
{ label: 'copy — more isolated per stack', value: false },
|
|
1801
|
+
],
|
|
1802
|
+
defaultIndex: authLink ? 0 : 1,
|
|
1803
|
+
});
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
const progress = (line) => {
|
|
1808
|
+
// In JSON mode, never pollute stdout (reserved for final JSON).
|
|
1809
|
+
// eslint-disable-next-line no-console
|
|
1810
|
+
(json ? console.error : console.log)(line);
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
// 1) Create (or reuse) the stack.
|
|
1814
|
+
let created = null;
|
|
1815
|
+
if (!stackExists) {
|
|
1816
|
+
progress(`[stack] pr: creating stack "${stackName}" (server=${serverComponent})...`);
|
|
1817
|
+
created = await cmdNew({
|
|
1818
|
+
rootDir,
|
|
1819
|
+
argv: [
|
|
1820
|
+
stackName,
|
|
1821
|
+
'--no-copy-auth',
|
|
1822
|
+
`--server=${serverComponent}`,
|
|
1823
|
+
...(dbProviderFromArg ? [`--db-provider=${dbProviderFromArg}`] : []),
|
|
1824
|
+
...(databaseUrlFromArg ? [`--database-url=${databaseUrlFromArg}`] : []),
|
|
1825
|
+
...(json ? ['--json'] : []),
|
|
1826
|
+
],
|
|
1827
|
+
// Prevent cmdNew from printing in JSON mode (we’ll print the final combined object below).
|
|
1828
|
+
emit: !json,
|
|
1829
|
+
});
|
|
1830
|
+
stackName = created?.stackName ?? stackName;
|
|
1831
|
+
} else {
|
|
1832
|
+
progress(`[stack] pr: reusing existing stack "${stackName}"...`);
|
|
1833
|
+
// Ensure requested server flavor is compatible with the existing stack.
|
|
1834
|
+
const existing = await readStackInfoSnapshot({ rootDir, stackName });
|
|
1835
|
+
if (existing.serverComponent !== serverComponent) {
|
|
1836
|
+
throw new Error(
|
|
1837
|
+
`[stack] pr: existing stack "${stackName}" uses server=${existing.serverComponent}, but command requested server=${serverComponent}.\n` +
|
|
1838
|
+
`Fix: create a new stack name, or switch the stack's server flavor first (hstack stack srv ${stackName} -- use ...).`
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
created = { ok: true, stackName, reused: true, serverComponent: existing.serverComponent };
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// 2) Checkout PR worktrees and pin them to the stack env file.
|
|
1845
|
+
const prSpecs = [{ component: 'happier-ui', pr: prRepo }];
|
|
1846
|
+
|
|
1847
|
+
const worktrees = [];
|
|
1848
|
+
const stackEnvPath = resolveStackEnvPath(stackName).envPath;
|
|
1849
|
+
for (const { component, pr } of prSpecs) {
|
|
1850
|
+
progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
|
|
1851
|
+
const out = await withStackEnv({
|
|
1852
|
+
stackName,
|
|
1853
|
+
fn: async ({ env }) => {
|
|
1854
|
+
const doUpdate = reuseExisting || flags.has('--update');
|
|
1855
|
+
const args = [
|
|
1856
|
+
'pr',
|
|
1857
|
+
pr,
|
|
1858
|
+
...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
|
|
1859
|
+
...(depsMode ? [`--deps=${depsMode}`] : []),
|
|
1860
|
+
...(doUpdate ? ['--update'] : []),
|
|
1861
|
+
...(flags.has('--force') ? ['--force'] : []),
|
|
1862
|
+
'--use',
|
|
1863
|
+
'--json',
|
|
1864
|
+
];
|
|
1865
|
+
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
|
|
1866
|
+
const parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
1867
|
+
|
|
1868
|
+
// Fail-closed invariant for PR stacks:
|
|
1869
|
+
// If you asked to pin a component to a PR checkout, it MUST be a worktree path under
|
|
1870
|
+
// the active workspace components dir (including sandbox workspace).
|
|
1871
|
+
if (parsed?.path && !isWorktreePath({ rootDir, dir: parsed.path, env })) {
|
|
1872
|
+
throw new Error(
|
|
1873
|
+
`[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
|
|
1874
|
+
`- expected under: ${resolve(getWorkspaceDir(rootDir, env))}/{pr,local,tmp}/...\n` +
|
|
1875
|
+
`- actual: ${String(parsed.path ?? '').trim()}\n` +
|
|
1876
|
+
`Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
return parsed;
|
|
1881
|
+
},
|
|
1882
|
+
});
|
|
1883
|
+
if (out) {
|
|
1884
|
+
worktrees.push(out);
|
|
1885
|
+
const repoDir =
|
|
1886
|
+
(out.worktreeRoot ? resolve(String(out.worktreeRoot)) : null) ||
|
|
1887
|
+
(out.path ? coerceHappyMonorepoRootFromPath(String(out.path)) : null);
|
|
1888
|
+
if (!repoDir) {
|
|
1889
|
+
throw new Error('[stack] pr: expected a monorepo worktree root but could not resolve it from the checked out path.');
|
|
1890
|
+
}
|
|
1891
|
+
if (!isWorktreePath({ rootDir, dir: repoDir, env: process.env })) {
|
|
1892
|
+
throw new Error(`[stack] pr: refusing to pin repo because the checked out path is not a worktree: ${repoDir}`);
|
|
1893
|
+
}
|
|
1894
|
+
await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key: 'HAPPIER_STACK_REPO_DIR', value: repoDir }] });
|
|
1895
|
+
}
|
|
1896
|
+
if (json) {
|
|
1897
|
+
// collected above
|
|
1898
|
+
} else if (out) {
|
|
1899
|
+
const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
|
|
1900
|
+
const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
|
|
1901
|
+
if (changed) {
|
|
1902
|
+
// eslint-disable-next-line no-console
|
|
1903
|
+
console.log(`[stack] pr: ${stackName}: ${component}: updated ${short(out.oldHead)} -> ${short(out.newHead)}`);
|
|
1904
|
+
} else if (out.updated) {
|
|
1905
|
+
// eslint-disable-next-line no-console
|
|
1906
|
+
console.log(`[stack] pr: ${stackName}: ${component}: already up to date (${short(out.newHead)})`);
|
|
1907
|
+
} else {
|
|
1908
|
+
// eslint-disable-next-line no-console
|
|
1909
|
+
console.log(`[stack] pr: ${stackName}: ${component}: checked out (${short(out.newHead)})`);
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Validate that the PR checkout is pinned correctly before starting.
|
|
1915
|
+
if (prSpecs.length) {
|
|
1916
|
+
const wt0 = worktrees[0] ?? null;
|
|
1917
|
+
const expectedRepo =
|
|
1918
|
+
(wt0?.worktreeRoot ? resolve(String(wt0.worktreeRoot)) : null) ||
|
|
1919
|
+
(wt0?.path ? coerceHappyMonorepoRootFromPath(String(wt0.path)) : null);
|
|
1920
|
+
if (!expectedRepo) {
|
|
1921
|
+
throw new Error('[stack] pr: failed to resolve expected repo dir from the PR checkout output.');
|
|
1922
|
+
}
|
|
1923
|
+
const afterRaw = await readExistingEnv(stackEnvPath);
|
|
1924
|
+
const afterEnv = parseEnvToObject(afterRaw);
|
|
1925
|
+
const pinned = String(afterEnv.HAPPIER_STACK_REPO_DIR ?? '').trim();
|
|
1926
|
+
if (!pinned) {
|
|
1927
|
+
throw new Error(
|
|
1928
|
+
`[stack] pr: failed to pin repo to the PR checkout.\n` +
|
|
1929
|
+
`- missing env key: HAPPIER_STACK_REPO_DIR\n` +
|
|
1930
|
+
`- expected: ${expectedRepo}\n` +
|
|
1931
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
const expected = resolve(expectedRepo);
|
|
1935
|
+
const actual = resolve(pinned);
|
|
1936
|
+
if (expected !== actual) {
|
|
1937
|
+
throw new Error(
|
|
1938
|
+
`[stack] pr: stack is pinned to the wrong checkout.\n` +
|
|
1939
|
+
`- env key: HAPPIER_STACK_REPO_DIR\n` +
|
|
1940
|
+
`- expected: ${expected}\n` +
|
|
1941
|
+
`- actual: ${actual}\n` +
|
|
1942
|
+
`Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
|
|
1948
|
+
let auth = null;
|
|
1949
|
+
if (seedAuth) {
|
|
1950
|
+
progress(`[stack] pr: ${stackName}: seeding auth from "${authFrom}"...`);
|
|
1951
|
+
const args = [
|
|
1952
|
+
'copy-from',
|
|
1953
|
+
authFrom,
|
|
1954
|
+
...(authForce ? ['--force'] : []),
|
|
1955
|
+
...(withInfra ? ['--with-infra'] : []),
|
|
1956
|
+
...(authLink ? ['--link'] : []),
|
|
1957
|
+
];
|
|
1958
|
+
if (json) {
|
|
1959
|
+
const extraEnv = await getRuntimePortExtraEnv(stackName);
|
|
1960
|
+
auth = await withStackEnv({
|
|
1961
|
+
stackName,
|
|
1962
|
+
...(extraEnv ? { extraEnv } : {}),
|
|
1963
|
+
fn: async ({ env }) => {
|
|
1964
|
+
const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
|
|
1965
|
+
return stdout.trim() ? JSON.parse(stdout.trim()) : null;
|
|
1966
|
+
},
|
|
1967
|
+
});
|
|
1968
|
+
} else {
|
|
1969
|
+
await cmdAuth({ rootDir, stackName, args });
|
|
1970
|
+
auth = { ok: true, from: authFrom };
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// 4) Optional: start dev / start.
|
|
1975
|
+
if (wantsDev) {
|
|
1976
|
+
progress(`[stack] pr: ${stackName}: starting dev...`);
|
|
1977
|
+
const args = [
|
|
1978
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
1979
|
+
...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
|
|
1980
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
1981
|
+
];
|
|
1982
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: resolveTopLevelNodeScriptFile('dev') || 'dev.mjs', args, background });
|
|
1983
|
+
} else if (wantsStart) {
|
|
1984
|
+
progress(`[stack] pr: ${stackName}: starting...`);
|
|
1985
|
+
const args = [
|
|
1986
|
+
...(wantsMobile ? ['--mobile'] : []),
|
|
1987
|
+
...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
|
|
1988
|
+
...(passthrough.length ? ['--', ...passthrough] : []),
|
|
1989
|
+
];
|
|
1990
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: resolveTopLevelNodeScriptFile('start') || 'run.mjs', args, background });
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
const info = await readStackInfoSnapshot({ rootDir, stackName });
|
|
1994
|
+
|
|
1995
|
+
const out = {
|
|
1996
|
+
ok: true,
|
|
1997
|
+
stackName,
|
|
1998
|
+
created,
|
|
1999
|
+
worktrees: worktrees.length ? worktrees : null,
|
|
2000
|
+
auth,
|
|
2001
|
+
info,
|
|
2002
|
+
};
|
|
2003
|
+
|
|
2004
|
+
if (json) {
|
|
2005
|
+
printResult({ json, data: out });
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
// Non-JSON mode already streamed output.
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async function cmdStackDaemon({ rootDir, stackName, argv, json }) {
|
|
2012
|
+
await runStackDaemonCommand({ rootDir, stackName, argv, json });
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
async function main() {
|
|
2016
|
+
const rootDir = getRootDir(import.meta.url);
|
|
2017
|
+
// Some callers pass an extra leading `--` when forwarding args into scripts. Normalize it away so
|
|
2018
|
+
// positional slicing behaves consistently.
|
|
2019
|
+
const rawArgv = process.argv.slice(2);
|
|
2020
|
+
const argv0 = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv;
|
|
2021
|
+
const argv = normalizeStackNameFirstArgs(argv0, { stackExists: stackExistsSync });
|
|
2022
|
+
|
|
2023
|
+
const helpSepIdx = argv.indexOf('--');
|
|
2024
|
+
const helpScopeArgv = helpSepIdx === -1 ? argv : argv.slice(0, helpSepIdx);
|
|
2025
|
+
|
|
2026
|
+
const { flags } = parseArgs(helpScopeArgv);
|
|
2027
|
+
const json = wantsJson(helpScopeArgv, { flags });
|
|
2028
|
+
|
|
2029
|
+
const positionals = helpScopeArgv.filter((a) => a && a !== '--' && !a.startsWith('-'));
|
|
2030
|
+
const cmd = positionals[0] || 'help';
|
|
2031
|
+
const wantsHelpFlag = wantsHelp(helpScopeArgv, { flags });
|
|
2032
|
+
const stackNameForHelp = stackNameFromArg(positionals, 1);
|
|
2033
|
+
// Subcommand-specific help (so `hstack stack eas --help` works).
|
|
2034
|
+
if (wantsHelpFlag && cmd === 'eas') {
|
|
2035
|
+
const stackName = stackNameFromArg(positionals, 1);
|
|
2036
|
+
|
|
2037
|
+
const runHelp = async (env) => {
|
|
2038
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'eas.mjs'), '--help'], { cwd: rootDir, env });
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
if (stackName && stackExistsSync(stackName)) {
|
|
2042
|
+
await withStackEnv({
|
|
2043
|
+
stackName,
|
|
2044
|
+
fn: async ({ env }) => {
|
|
2045
|
+
await runHelp(env);
|
|
2046
|
+
},
|
|
2047
|
+
});
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
await runHelp(process.env);
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
// Allow subcommand-specific help (so `hstack stack pr --help` shows PR stack flags).
|
|
2055
|
+
if (wantsHelpFlag && cmd === 'pr') {
|
|
2056
|
+
await cmdPrStack({ rootDir, argv });
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
// Allow subcommand-specific help (so `hstack stack daemon <name> --help` works).
|
|
2060
|
+
if (wantsHelpFlag && cmd === 'daemon') {
|
|
2061
|
+
const stackName = stackNameFromArg(positionals, 1) || 'main';
|
|
2062
|
+
const passthrough = argv.slice(2);
|
|
2063
|
+
await cmdStackDaemon({ rootDir, stackName, argv: passthrough, json });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (wantsHelpFlag && cmd !== 'help') {
|
|
2067
|
+
const handled = await printDelegatedStackHelpIfAvailable({
|
|
2068
|
+
rootDir,
|
|
2069
|
+
command: cmd,
|
|
2070
|
+
stackName: stackNameForHelp,
|
|
2071
|
+
json,
|
|
2072
|
+
});
|
|
2073
|
+
if (handled) {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
if (wantsHelpFlag && cmd !== 'help') {
|
|
2078
|
+
const text = renderStackSubcommandHelpText(cmd);
|
|
2079
|
+
if (text) {
|
|
2080
|
+
printResult({
|
|
2081
|
+
json,
|
|
2082
|
+
data: { ok: true, cmd, usage: getStackHelpUsageLine(cmd) },
|
|
2083
|
+
text,
|
|
2084
|
+
});
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (wantsHelpFlag || cmd === 'help') {
|
|
2089
|
+
printResult({
|
|
2090
|
+
json,
|
|
2091
|
+
data: {
|
|
2092
|
+
commands: STACK_HELP_COMMANDS,
|
|
2093
|
+
},
|
|
2094
|
+
text: renderStackRootHelpText(),
|
|
2095
|
+
});
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
if (cmd === 'new') {
|
|
2100
|
+
await cmdNew({ rootDir, argv: argv.slice(1) });
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
if (cmd === 'edit') {
|
|
2104
|
+
await cmdEdit({ rootDir, argv });
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
if (cmd === 'list') {
|
|
2108
|
+
const names = (await listAllStackNames()).filter((n) => n !== 'main');
|
|
2109
|
+
if (json) {
|
|
2110
|
+
printResult({ json, data: { stacks: names } });
|
|
2111
|
+
} else {
|
|
2112
|
+
await cmdListStacks();
|
|
2113
|
+
}
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
if (cmd === 'audit') {
|
|
2117
|
+
await cmdAudit({ rootDir, argv });
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
if (cmd === 'duplicate') {
|
|
2121
|
+
await cmdDuplicate({ rootDir, argv });
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
if (cmd === 'info') {
|
|
2125
|
+
await cmdInfo({ rootDir, argv });
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
if (cmd === 'pr') {
|
|
2129
|
+
await cmdPrStack({ rootDir, argv });
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
if (cmd === 'create-dev-auth-seed') {
|
|
2133
|
+
await cmdCreateDevAuthSeed({ rootDir, argv });
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// Commands that need a stack name.
|
|
2138
|
+
const stackName = stackNameFromArg(positionals, 1);
|
|
2139
|
+
if (!stackName) {
|
|
2140
|
+
const helpLines =
|
|
2141
|
+
cmd === 'service'
|
|
2142
|
+
? [
|
|
2143
|
+
'[stack] usage:',
|
|
2144
|
+
' hstack stack service <name> <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>',
|
|
2145
|
+
'',
|
|
2146
|
+
'example:',
|
|
2147
|
+
' hstack stack service exp1 status',
|
|
2148
|
+
]
|
|
2149
|
+
: cmd === 'wt'
|
|
2150
|
+
? [
|
|
2151
|
+
'[stack] usage:',
|
|
2152
|
+
' hstack stack wt <name> -- <wt args...>',
|
|
2153
|
+
'',
|
|
2154
|
+
'example:',
|
|
2155
|
+
' hstack stack wt exp1 -- use happier pr/123-fix-thing',
|
|
2156
|
+
]
|
|
2157
|
+
: cmd === 'srv'
|
|
2158
|
+
? [
|
|
2159
|
+
'[stack] usage:',
|
|
2160
|
+
' hstack stack srv <name> -- status|use ...',
|
|
2161
|
+
'',
|
|
2162
|
+
'example:',
|
|
2163
|
+
' hstack stack srv exp1 -- status',
|
|
2164
|
+
]
|
|
2165
|
+
: cmd === 'env'
|
|
2166
|
+
? [
|
|
2167
|
+
'[stack] usage:',
|
|
2168
|
+
' hstack stack env <name> set KEY=VALUE [KEY2=VALUE2...]',
|
|
2169
|
+
' hstack stack env <name> unset KEY [KEY2...]',
|
|
2170
|
+
' hstack stack env <name> get KEY',
|
|
2171
|
+
' hstack stack env <name> list',
|
|
2172
|
+
' hstack stack env <name> path',
|
|
2173
|
+
]
|
|
2174
|
+
: cmd === 'eas'
|
|
2175
|
+
? [
|
|
2176
|
+
'[stack] usage:',
|
|
2177
|
+
' hstack stack eas <name> <eas args...>',
|
|
2178
|
+
'',
|
|
2179
|
+
'examples:',
|
|
2180
|
+
' hstack stack eas happier android --profile production',
|
|
2181
|
+
' hstack stack eas happier build --platform android --profile production',
|
|
2182
|
+
' hstack stack eas happier env:sync --environment production',
|
|
2183
|
+
]
|
|
2184
|
+
: cmd === 'daemon'
|
|
2185
|
+
? [
|
|
2186
|
+
'[stack] usage:',
|
|
2187
|
+
' hstack stack daemon <name> start|stop|restart|status [--json]',
|
|
2188
|
+
'',
|
|
2189
|
+
'example:',
|
|
2190
|
+
' hstack stack daemon main status',
|
|
2191
|
+
]
|
|
2192
|
+
: cmd === 'bug-report'
|
|
2193
|
+
? [
|
|
2194
|
+
'[stack] usage:',
|
|
2195
|
+
' hstack stack bug-report <name> [-- ...]',
|
|
2196
|
+
'',
|
|
2197
|
+
'example:',
|
|
2198
|
+
' hstack stack bug-report exp1 -- --title "Crash on launch" --summary "..." --current-behavior "..." --expected-behavior "..."',
|
|
2199
|
+
]
|
|
2200
|
+
: cmd.startsWith('tailscale:')
|
|
2201
|
+
? [
|
|
2202
|
+
'[stack] usage:',
|
|
2203
|
+
' hstack stack tailscale:status|enable|disable|url <name> [-- ...]',
|
|
2204
|
+
'',
|
|
2205
|
+
'example:',
|
|
2206
|
+
' hstack stack tailscale:status exp1',
|
|
2207
|
+
]
|
|
2208
|
+
: [
|
|
2209
|
+
'[stack] missing stack name.',
|
|
2210
|
+
'Run: hstack stack --help',
|
|
2211
|
+
];
|
|
2212
|
+
|
|
2213
|
+
printResult({ json, data: { ok: false, error: 'missing_stack_name', cmd }, text: helpLines.join('\n') });
|
|
2214
|
+
process.exit(1);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
// Remaining args after "<cmd> <name>"
|
|
2218
|
+
const passthrough = argv.slice(2);
|
|
2219
|
+
|
|
2220
|
+
if (cmd === 'archive') {
|
|
2221
|
+
const res = await cmdArchiveStack({ rootDir, argv, stackName });
|
|
2222
|
+
if (json) {
|
|
2223
|
+
printResult({ json, data: res });
|
|
2224
|
+
} else if (res.dryRun) {
|
|
2225
|
+
console.log(`[stack] would archive "${stackName}" -> ${res.archivedStackDir} (dry-run)`);
|
|
2226
|
+
} else {
|
|
2227
|
+
console.log(`[stack] archived "${stackName}" -> ${res.archivedStackDir}`);
|
|
2228
|
+
}
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
if (cmd === 'env') {
|
|
2233
|
+
const hasPositional = passthrough.some((a) => !a.startsWith('-'));
|
|
2234
|
+
const envArgv = hasPositional ? passthrough : ['list', ...passthrough];
|
|
2235
|
+
// Forward to scripts/env.mjs under the stack env.
|
|
2236
|
+
// This keeps stack env editing behavior unified with `hstack env ...`.
|
|
2237
|
+
await withStackEnv({
|
|
2238
|
+
stackName,
|
|
2239
|
+
fn: async ({ env }) => {
|
|
2240
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'env.mjs'), ...envArgv], { cwd: rootDir, env });
|
|
2241
|
+
},
|
|
2242
|
+
});
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (cmd === 'daemon') {
|
|
2246
|
+
await cmdStackDaemon({ rootDir, stackName, argv: passthrough, json });
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (cmd === 'eas') {
|
|
2250
|
+
// Forward EAS commands under the stack env.
|
|
2251
|
+
// Example:
|
|
2252
|
+
// hstack stack eas <name> build --platform ios --profile production
|
|
2253
|
+
await withStackEnv({
|
|
2254
|
+
stackName,
|
|
2255
|
+
fn: async ({ env }) => {
|
|
2256
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'eas.mjs'), ...passthrough], { cwd: rootDir, env });
|
|
2257
|
+
},
|
|
2258
|
+
});
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
if (cmd === 'happier') {
|
|
2262
|
+
await runStackHappierPassthroughCommand({ rootDir, stackName, passthrough });
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
if (cmd === 'bug-report') {
|
|
2266
|
+
const bugReportPassthroughRaw = passthrough[0] === '--' ? passthrough.slice(1) : passthrough;
|
|
2267
|
+
const separatorIndex = bugReportPassthroughRaw.indexOf('--');
|
|
2268
|
+
const bugReportPassthrough =
|
|
2269
|
+
separatorIndex === -1
|
|
2270
|
+
? ['bug-report', ...bugReportPassthroughRaw]
|
|
2271
|
+
: [
|
|
2272
|
+
...bugReportPassthroughRaw.slice(0, separatorIndex),
|
|
2273
|
+
'--',
|
|
2274
|
+
'bug-report',
|
|
2275
|
+
...bugReportPassthroughRaw.slice(separatorIndex + 1),
|
|
2276
|
+
];
|
|
2277
|
+
await runStackHappierPassthroughCommand({ rootDir, stackName, passthrough: bugReportPassthrough });
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
if (STACK_BACKGROUND_SCRIPT_BY_COMMAND.has(cmd)) {
|
|
2281
|
+
const background = passthrough.includes('--background') || passthrough.includes('--bg');
|
|
2282
|
+
const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
|
|
2283
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: STACK_BACKGROUND_SCRIPT_BY_COMMAND.get(cmd), args, background });
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
if (STACK_REPO_OVERRIDE_SCRIPT_BY_COMMAND.has(cmd)) {
|
|
2287
|
+
const { kv } = parseArgs(passthrough);
|
|
2288
|
+
const overrides = resolveTransientRepoOverrides({ rootDir, kv });
|
|
2289
|
+
await cmdRunScript({
|
|
2290
|
+
rootDir,
|
|
2291
|
+
stackName,
|
|
2292
|
+
scriptPath: STACK_REPO_OVERRIDE_SCRIPT_BY_COMMAND.get(cmd),
|
|
2293
|
+
args: passthrough,
|
|
2294
|
+
extraEnv: overrides,
|
|
2295
|
+
});
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
if (cmd === 'doctor') {
|
|
2299
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: resolveTopLevelNodeScriptFile('doctor') || 'doctor.mjs', args: passthrough });
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (cmd === 'mobile') {
|
|
2303
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: resolveTopLevelNodeScriptFile('mobile') || 'mobile.mjs', args: passthrough });
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
if (cmd === 'mobile-dev-client') {
|
|
2307
|
+
// Stack-scoped wrapper so the dev-client can be built from the stack's active Happier checkout/worktree.
|
|
2308
|
+
await cmdRunScript({ rootDir, stackName, scriptPath: resolveTopLevelNodeScriptFile('mobile-dev-client') || 'mobile_dev_client.mjs', args: passthrough });
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
if (cmd === 'mobile:install') {
|
|
2312
|
+
await runStackMobileInstallCommand({ rootDir, stackName, passthrough, json });
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (cmd === 'resume') {
|
|
2316
|
+
await runStackResumeCommand({ rootDir, stackName, passthrough, json });
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
if (cmd === 'stop') {
|
|
2321
|
+
await runStackStopCommand({ rootDir, stackName, passthrough, json });
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
if (cmd === 'code' || cmd === 'cursor' || cmd === 'open') {
|
|
2326
|
+
await runStackWorkspaceCommand({ command: cmd, rootDir, stackName, json, flags });
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
if (cmd === 'srv') {
|
|
2331
|
+
await cmdSrv({ rootDir, stackName, args: passthrough });
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
if (cmd === 'wt') {
|
|
2335
|
+
await cmdWt({ rootDir, stackName, args: passthrough });
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
if (cmd === 'auth') {
|
|
2339
|
+
await cmdAuth({ rootDir, stackName, args: passthrough });
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
if (cmd === 'service') {
|
|
2344
|
+
const svcCmd = passthrough[0];
|
|
2345
|
+
if (!svcCmd) {
|
|
2346
|
+
printResult({
|
|
2347
|
+
json,
|
|
2348
|
+
data: { ok: false, error: 'missing_service_subcommand', stackName },
|
|
2349
|
+
text: [
|
|
2350
|
+
'[stack] usage:',
|
|
2351
|
+
' hstack stack service <name> <install|uninstall|status|start|stop|restart|enable|disable|logs|tail>',
|
|
2352
|
+
'',
|
|
2353
|
+
'example:',
|
|
2354
|
+
` hstack stack service ${stackName} status`,
|
|
2355
|
+
].join('\n'),
|
|
2356
|
+
});
|
|
2357
|
+
process.exit(1);
|
|
2358
|
+
}
|
|
2359
|
+
await cmdService({ rootDir, stackName, svcCmd });
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
if (cmd.startsWith('service:')) {
|
|
2364
|
+
const svcCmd = cmd.slice('service:'.length);
|
|
2365
|
+
await cmdService({ rootDir, stackName, svcCmd });
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
if (cmd.startsWith('tailscale:')) {
|
|
2369
|
+
const subcmd = cmd.slice('tailscale:'.length);
|
|
2370
|
+
await cmdTailscale({ rootDir, stackName, subcmd, args: passthrough });
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
if (flags.has('--interactive') && cmd === 'help') {
|
|
2375
|
+
// no-op
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
console.log(`[stack] unknown command: ${cmd}`);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
main().catch((err) => {
|
|
2382
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2383
|
+
console.error('[stack] failed:', message);
|
|
2384
|
+
if (process.env.DEBUG && err instanceof Error && err.stack) {
|
|
2385
|
+
console.error(err.stack);
|
|
2386
|
+
}
|
|
2387
|
+
process.exit(1);
|
|
2388
|
+
});
|