@happier-dev/stack 0.1.0-preview.17.1 → 0.1.0-preview.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/docs/server-flavors.md +6 -6
  2. package/node_modules/@happier-dev/cli-common/dist/index.d.ts +2 -0
  3. package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -1
  4. package/node_modules/@happier-dev/cli-common/dist/index.js +2 -0
  5. package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -1
  6. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts +51 -0
  7. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts.map +1 -0
  8. package/node_modules/@happier-dev/cli-common/dist/providers/index.js +129 -0
  9. package/node_modules/@happier-dev/cli-common/dist/providers/index.js.map +1 -0
  10. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts +5 -0
  11. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts.map +1 -0
  12. package/node_modules/@happier-dev/cli-common/dist/service/index.js +5 -0
  13. package/node_modules/@happier-dev/cli-common/dist/service/index.js.map +1 -0
  14. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +19 -0
  15. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -0
  16. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +117 -0
  17. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -0
  18. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts +55 -0
  19. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts.map +1 -0
  20. package/node_modules/@happier-dev/cli-common/dist/service/manager.js +302 -0
  21. package/node_modules/@happier-dev/cli-common/dist/service/manager.js.map +1 -0
  22. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts +12 -0
  23. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts.map +1 -0
  24. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js +75 -0
  25. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js.map +1 -0
  26. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts +8 -0
  27. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts.map +1 -0
  28. package/node_modules/@happier-dev/cli-common/dist/service/windows.js +29 -0
  29. package/node_modules/@happier-dev/cli-common/dist/service/windows.js.map +1 -0
  30. package/node_modules/@happier-dev/cli-common/package.json +11 -0
  31. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts +22 -0
  32. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts.map +1 -0
  33. package/node_modules/@happier-dev/release-runtime/dist/assets.js +44 -0
  34. package/node_modules/@happier-dev/release-runtime/dist/assets.js.map +1 -0
  35. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts +5 -0
  36. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts.map +1 -0
  37. package/node_modules/@happier-dev/release-runtime/dist/checksums.js +21 -0
  38. package/node_modules/@happier-dev/release-runtime/dist/checksums.js.map +1 -0
  39. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts +14 -0
  40. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts.map +1 -0
  41. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js +39 -0
  42. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js.map +1 -0
  43. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts +20 -0
  44. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts.map +1 -0
  45. package/node_modules/@happier-dev/release-runtime/dist/github.js +60 -0
  46. package/node_modules/@happier-dev/release-runtime/dist/github.js.map +1 -0
  47. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts +7 -0
  48. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts.map +1 -0
  49. package/node_modules/@happier-dev/release-runtime/dist/index.js +7 -0
  50. package/node_modules/@happier-dev/release-runtime/dist/index.js.map +1 -0
  51. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts +7 -0
  52. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts.map +1 -0
  53. package/node_modules/@happier-dev/release-runtime/dist/minisign.js +92 -0
  54. package/node_modules/@happier-dev/release-runtime/dist/minisign.js.map +1 -0
  55. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts +26 -0
  56. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts.map +1 -0
  57. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js +53 -0
  58. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js.map +1 -0
  59. package/node_modules/@happier-dev/release-runtime/package.json +38 -0
  60. package/package.json +4 -2
  61. package/scripts/auth.mjs +3 -2
  62. package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +1 -0
  63. package/scripts/auth_copy_from_runCapture.integration.test.mjs +8 -1
  64. package/scripts/auth_login_guided_server_no_expo.test.mjs +2 -0
  65. package/scripts/build.mjs +3 -18
  66. package/scripts/bundleWorkspaceDeps.mjs +5 -1
  67. package/scripts/bundleWorkspaceDeps.test.mjs +42 -1
  68. package/scripts/mobile.mjs +30 -2
  69. package/scripts/mobile_dev_client.mjs +7 -32
  70. package/scripts/mobile_dev_client_help_smoke.test.mjs +24 -0
  71. package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
  72. package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
  73. package/scripts/mobile_run_ios_passes_port.integration.test.mjs +103 -0
  74. package/scripts/mobile_run_ios_uses_long_port_flag.test.mjs +106 -0
  75. package/scripts/providers_cmd.mjs +262 -0
  76. package/scripts/release_binary_smoke.integration.test.mjs +45 -37
  77. package/scripts/remote_cmd.mjs +352 -0
  78. package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
  79. package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
  80. package/scripts/self_host_runtime.mjs +1829 -327
  81. package/scripts/self_host_runtime.test.mjs +523 -1
  82. package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
  83. package/scripts/self_host_service_e2e_harness.mjs +93 -0
  84. package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
  85. package/scripts/service.mjs +156 -26
  86. package/scripts/stack/command_arguments.mjs +1 -0
  87. package/scripts/stack/help_text.mjs +3 -1
  88. package/scripts/stack.mjs +2 -1
  89. package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
  90. package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
  91. package/scripts/stack_pr_help_cmd.test.mjs +38 -0
  92. package/scripts/stop.mjs +2 -3
  93. package/scripts/utils/auth/credentials_paths.mjs +9 -9
  94. package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
  95. package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +64 -3
  96. package/scripts/utils/auth/stable_scope_id.mjs +1 -1
  97. package/scripts/utils/cli/cli_registry.mjs +21 -0
  98. package/scripts/utils/cli/progress.mjs +8 -1
  99. package/scripts/utils/cli/progress.test.mjs +43 -0
  100. package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
  101. package/scripts/utils/dev/expo_dev.mjs +35 -5
  102. package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
  103. package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
  104. package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
  105. package/scripts/utils/mobile/dev_client_install_invocation.mjs +68 -0
  106. package/scripts/utils/mobile/dev_client_install_invocation.test.mjs +27 -0
  107. package/scripts/utils/server/port.mjs +20 -2
  108. package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
  109. package/scripts/utils/service/service_manager.mjs +96 -0
  110. package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
  111. package/scripts/utils/service/service_manager.test.mjs +20 -0
  112. package/scripts/utils/service/systemd_service_unit.mjs +1 -0
  113. package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
  114. package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
  115. package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
  116. package/scripts/utils/ui/ui_export_env.mjs +29 -0
  117. package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
  118. package/scripts/worktrees.mjs +3 -0
  119. package/scripts/worktrees_status_default_target.test.mjs +56 -0
@@ -3,9 +3,11 @@ import { run, runCapture } from './utils/proc/proc.mjs';
3
3
  import { getComponentDir, getDefaultAutostartPaths, getRootDir, getSystemdUnitInfo, resolveStackEnvPath } from './utils/paths/paths.mjs';
4
4
  import { getInternalServerUrl, getPublicServerUrlEnvOverride } from './utils/server/urls.mjs';
5
5
  import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/service/autostart_darwin.mjs';
6
+ import { installService as installManagedService, uninstallService as uninstallManagedService } from './utils/service/service_manager.mjs';
6
7
  import { getCanonicalHomeDir } from './utils/env/config.mjs';
7
8
  import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
8
9
  import { expandHome } from './utils/paths/canonical_home.mjs';
10
+ import { resolveInstalledCliRoot, resolveInstalledPath } from './utils/paths/runtime.mjs';
9
11
  import { spawn } from 'node:child_process';
10
12
  import { homedir } from 'node:os';
11
13
  import { existsSync } from 'node:fs';
@@ -109,6 +111,33 @@ function ensureLinuxSystemModeSupported({ mode }) {
109
111
  }
110
112
  }
111
113
 
114
+ async function resolveStackAutostartProgramArgs({ rootDir, mode, systemUser }) {
115
+ const base = getCanonicalHomeDir();
116
+ const candidates =
117
+ process.platform === 'win32'
118
+ ? [join(base, 'bin', 'hstack.exe'), join(base, 'bin', 'hstack.cmd'), join(base, 'bin', 'hstack')]
119
+ : [join(base, 'bin', 'hstack')];
120
+
121
+ let shimPath = candidates.find((p) => existsSync(p)) ?? '';
122
+ if (mode === 'system' && systemUser) {
123
+ const home = await resolveHomeDirForUser(systemUser);
124
+ if (home) {
125
+ const systemCandidates =
126
+ process.platform === 'win32'
127
+ ? [
128
+ join(home, '.happier-stack', 'bin', 'hstack.exe'),
129
+ join(home, '.happier-stack', 'bin', 'hstack.cmd'),
130
+ join(home, '.happier-stack', 'bin', 'hstack'),
131
+ ]
132
+ : [join(home, '.happier-stack', 'bin', 'hstack')];
133
+ shimPath = systemCandidates.find((p) => existsSync(p)) ?? shimPath;
134
+ }
135
+ }
136
+
137
+ if (shimPath) return [shimPath, 'start'];
138
+ return [process.execPath, resolveInstalledPath(rootDir, 'bin/hstack.mjs'), 'start'];
139
+ }
140
+
112
141
  export async function installService({ mode = 'user', systemUser = null } = {}) {
113
142
  if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
114
143
  throw new Error(
@@ -118,11 +147,8 @@ export async function installService({ mode = 'user', systemUser = null } = {})
118
147
  );
119
148
  }
120
149
  ensureLinuxSystemModeSupported({ mode });
121
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
122
- throw new Error('[local] service install is only supported on macOS (launchd) and Linux (systemd).');
123
- }
124
150
  const rootDir = getRootDir(import.meta.url);
125
- const { label } = getDefaultAutostartPaths();
151
+ const { label, stdoutPath, stderrPath, baseDir } = getDefaultAutostartPaths();
126
152
  const env = getAutostartEnv({ rootDir, mode });
127
153
  // Ensure the env file exists so the service never points at a missing path.
128
154
  try {
@@ -137,17 +163,43 @@ export async function installService({ mode = 'user', systemUser = null } = {})
137
163
  } catch {
138
164
  // ignore
139
165
  }
166
+ const programArgs = await resolveStackAutostartProgramArgs({ rootDir, mode, systemUser });
167
+ const workingDirectory =
168
+ process.platform === 'linux'
169
+ ? '%h'
170
+ : process.platform === 'darwin'
171
+ ? resolveInstalledCliRoot(rootDir)
172
+ : baseDir;
173
+
174
+ await installManagedService({
175
+ platform: process.platform,
176
+ mode,
177
+ homeDir: homedir(),
178
+ spec: {
179
+ label,
180
+ description: `Happier Stack (${label})`,
181
+ programArgs,
182
+ workingDirectory,
183
+ env,
184
+ runAsUser: mode === 'system' && systemUser ? systemUser : '',
185
+ stdoutPath,
186
+ stderrPath,
187
+ },
188
+ persistent: true,
189
+ });
190
+
191
+ if (process.platform === 'win32') {
192
+ console.log(`${green('✓')} service installed ${dim('(Windows scheduled task)')}`);
193
+ return;
194
+ }
140
195
  if (process.platform === 'darwin') {
141
- await ensureMacAutostartEnabled({ rootDir, label, env });
142
196
  console.log(`${green('✓')} service installed ${dim('(macOS launchd)')}`);
143
197
  return;
144
198
  }
145
199
  if (mode === 'system') {
146
- await ensureSystemdSystemServiceEnabled({ rootDir, label, env, systemUser });
147
200
  console.log(`${green('✓')} service installed ${dim('(Linux systemd system)')}`);
148
201
  return;
149
202
  }
150
- await ensureSystemdUserServiceEnabled({ rootDir, label, env });
151
203
  console.log(`${green('✓')} service installed ${dim('(Linux systemd --user)')}`);
152
204
  }
153
205
 
@@ -157,25 +209,40 @@ export async function uninstallService({ mode = 'user' } = {}) {
157
209
  return;
158
210
  }
159
211
  ensureLinuxSystemModeSupported({ mode });
160
- if (process.platform !== 'darwin' && process.platform !== 'linux') return;
212
+ const rootDir = getRootDir(import.meta.url);
213
+ const { label, stdoutPath, stderrPath, baseDir } = getDefaultAutostartPaths();
214
+ const env = getAutostartEnv({ rootDir, mode });
215
+ const programArgs = await resolveStackAutostartProgramArgs({ rootDir, mode, systemUser: null });
216
+ const workingDirectory =
217
+ process.platform === 'linux'
218
+ ? '%h'
219
+ : process.platform === 'darwin'
220
+ ? resolveInstalledCliRoot(rootDir)
221
+ : baseDir;
222
+
223
+ await uninstallManagedService({
224
+ platform: process.platform,
225
+ mode,
226
+ homeDir: homedir(),
227
+ spec: {
228
+ label,
229
+ description: `Happier Stack (${label})`,
230
+ programArgs,
231
+ workingDirectory,
232
+ env,
233
+ stdoutPath,
234
+ stderrPath,
235
+ },
236
+ persistent: true,
237
+ });
161
238
 
162
- if (process.platform === 'linux') {
163
- if (mode === 'system') {
164
- await ensureSystemdSystemServiceDisabled({ remove: true });
165
- console.log(`${green('✓')} service uninstalled ${dim('(systemd system unit removed)')}`);
166
- return;
167
- }
168
- await ensureSystemdUserServiceDisabled({ remove: true });
169
- console.log(`${green('✓')} service uninstalled ${dim('(systemd user unit removed)')}`);
239
+ if (process.platform === 'win32') {
240
+ console.log(`${green('✓')} service uninstalled ${dim('(Windows task removed)')}`);
170
241
  return;
171
242
  }
172
- const { plistPath, label } = getDefaultAutostartPaths();
173
-
174
- await ensureMacAutostartDisabled({ label });
175
- try {
176
- await rm(plistPath, { force: true });
177
- } catch {
178
- // ignore
243
+ if (process.platform === 'linux') {
244
+ console.log(`${green('✓')} service uninstalled ${dim('(systemd unit removed)')}`);
245
+ return;
179
246
  }
180
247
  console.log(`${green('✓')} service uninstalled ${dim('(plist removed)')}`);
181
248
  }
@@ -633,8 +700,8 @@ async function main() {
633
700
  const systemUser = resolveSystemUser(helpScopeArgv);
634
701
  ensureLinuxSystemModeSupported({ mode });
635
702
 
636
- if (process.platform !== 'darwin' && process.platform !== 'linux') {
637
- throw new Error('[local] service commands are only supported on macOS (launchd) and Linux (systemd).');
703
+ if (process.platform !== 'darwin' && process.platform !== 'linux' && process.platform !== 'win32') {
704
+ throw new Error('[local] service commands are only supported on macOS (launchd), Linux (systemd), and Windows (schtasks).');
638
705
  }
639
706
  const positionals = helpScopeArgv.filter((a) => a && a !== '--' && !a.startsWith('-'));
640
707
  const cmd = positionals[0] ?? 'help';
@@ -711,7 +778,17 @@ async function main() {
711
778
  health = { ok: false, status: null, body: null };
712
779
  }
713
780
 
714
- if (process.platform === 'darwin') {
781
+ if (process.platform === 'win32') {
782
+ const { label, stdoutPath, stderrPath } = getDefaultAutostartPaths();
783
+ const taskName = `Happier\\${label}`;
784
+ let schtasksStatus = null;
785
+ try {
786
+ schtasksStatus = await runCapture('schtasks', ['/Query', '/TN', taskName, '/FO', 'LIST', '/V']);
787
+ } catch (e) {
788
+ schtasksStatus = e && typeof e === 'object' && 'out' in e ? e.out : null;
789
+ }
790
+ printResult({ json, data: { label, taskName, stdoutPath, stderrPath, internalUrl, schtasksStatus, health } });
791
+ } else if (process.platform === 'darwin') {
715
792
  const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
716
793
  let launchctlLine = null;
717
794
  try {
@@ -736,6 +813,11 @@ async function main() {
736
813
  printResult({ json, data: { mode, unitName, unitPath, internalUrl, systemctlStatus, health } });
737
814
  }
738
815
  } else {
816
+ if (process.platform === 'win32') {
817
+ const { label } = getDefaultAutostartPaths();
818
+ await run('schtasks', ['/Query', '/TN', `Happier\\${label}`]);
819
+ return;
820
+ }
739
821
  if (process.platform === 'darwin') {
740
822
  await showStatus();
741
823
  } else {
@@ -749,6 +831,13 @@ async function main() {
749
831
  }
750
832
  return;
751
833
  case 'start':
834
+ if (process.platform === 'win32') {
835
+ const { label } = getDefaultAutostartPaths();
836
+ await run('schtasks', ['/Run', '/TN', `Happier\\${label}`]).catch(() => {});
837
+ await postStartDiagnostics();
838
+ if (json) printResult({ json, data: { ok: true, action: 'start' } });
839
+ return;
840
+ }
752
841
  if (process.platform === 'darwin') {
753
842
  await startLaunchAgent({ persistent: false });
754
843
  } else {
@@ -763,6 +852,12 @@ async function main() {
763
852
  if (json) printResult({ json, data: { ok: true, action: 'start' } });
764
853
  return;
765
854
  case 'stop':
855
+ if (process.platform === 'win32') {
856
+ const { label } = getDefaultAutostartPaths();
857
+ await run('schtasks', ['/End', '/TN', `Happier\\${label}`]).catch(() => {});
858
+ if (json) printResult({ json, data: { ok: true, action: 'stop' } });
859
+ return;
860
+ }
766
861
  if (process.platform === 'darwin') {
767
862
  await stopLaunchAgent({ persistent: false });
768
863
  } else {
@@ -776,6 +871,14 @@ async function main() {
776
871
  if (json) printResult({ json, data: { ok: true, action: 'stop' } });
777
872
  return;
778
873
  case 'restart':
874
+ if (process.platform === 'win32') {
875
+ const { label } = getDefaultAutostartPaths();
876
+ await run('schtasks', ['/End', '/TN', `Happier\\${label}`]).catch(() => {});
877
+ await run('schtasks', ['/Run', '/TN', `Happier\\${label}`]).catch(() => {});
878
+ await postStartDiagnostics();
879
+ if (json) printResult({ json, data: { ok: true, action: 'restart' } });
880
+ return;
881
+ }
779
882
  if (process.platform === 'darwin') {
780
883
  if (!(await restartLaunchAgentBestEffort())) {
781
884
  await stopLaunchAgent({ persistent: false });
@@ -794,6 +897,14 @@ async function main() {
794
897
  if (json) printResult({ json, data: { ok: true, action: 'restart' } });
795
898
  return;
796
899
  case 'enable':
900
+ if (process.platform === 'win32') {
901
+ const { label } = getDefaultAutostartPaths();
902
+ await run('schtasks', ['/Change', '/TN', `Happier\\${label}`, '/Enable']).catch(() => {});
903
+ await run('schtasks', ['/Run', '/TN', `Happier\\${label}`]).catch(() => {});
904
+ await postStartDiagnostics();
905
+ if (json) printResult({ json, data: { ok: true, action: 'enable' } });
906
+ return;
907
+ }
797
908
  if (process.platform === 'darwin') {
798
909
  await startLaunchAgent({ persistent: true });
799
910
  } else {
@@ -808,6 +919,13 @@ async function main() {
808
919
  if (json) printResult({ json, data: { ok: true, action: 'enable' } });
809
920
  return;
810
921
  case 'disable':
922
+ if (process.platform === 'win32') {
923
+ const { label } = getDefaultAutostartPaths();
924
+ await run('schtasks', ['/End', '/TN', `Happier\\${label}`]).catch(() => {});
925
+ await run('schtasks', ['/Change', '/TN', `Happier\\${label}`, '/Disable']).catch(() => {});
926
+ if (json) printResult({ json, data: { ok: true, action: 'disable' } });
927
+ return;
928
+ }
811
929
  if (process.platform === 'darwin') {
812
930
  await stopLaunchAgent({ persistent: true });
813
931
  } else {
@@ -821,6 +939,15 @@ async function main() {
821
939
  if (json) printResult({ json, data: { ok: true, action: 'disable' } });
822
940
  return;
823
941
  case 'logs':
942
+ if (process.platform === 'win32') {
943
+ const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
944
+ const out = await readLastLines(stdoutPath, 200).catch(() => '');
945
+ const err = await readLastLines(stderrPath, 200).catch(() => '');
946
+ console.log(bullets([kv('stdout:', stdoutPath), kv('stderr:', stderrPath)]));
947
+ if (out.trim()) console.log(out.trimEnd());
948
+ if (err.trim()) console.log(err.trimEnd());
949
+ return;
950
+ }
824
951
  if (process.platform === 'darwin') {
825
952
  await showLogs();
826
953
  } else {
@@ -834,6 +961,9 @@ async function main() {
834
961
  }
835
962
  return;
836
963
  case 'tail':
964
+ if (process.platform === 'win32') {
965
+ throw new Error('[local] service tail is not supported on Windows yet. Use: hstack service logs');
966
+ }
837
967
  if (process.platform === 'darwin') {
838
968
  await tailLogs();
839
969
  } else {
@@ -13,6 +13,7 @@ const STACK_NAME_FIRST_SUPPORTED_COMMANDS = new Set([
13
13
  'create-dev-auth-seed',
14
14
  'daemon',
15
15
  'happier',
16
+ 'bug-report',
16
17
  'env',
17
18
  'auth',
18
19
  'dev',
@@ -6,10 +6,11 @@ const STACK_HELP_USAGE_LINES = [
6
6
  'hstack stack archive <name> [--dry-run] [--date=YYYY-MM-DD] [--json]',
7
7
  'hstack stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
8
8
  'hstack stack info <name> [--json]',
9
- 'hstack stack pr <name> --repo=<pr-url|number> [--server-flavor=light|full] [--dev|--start] [--json] [-- ...]',
9
+ 'hstack stack pr <name> --repo=<pr-url|number> [--server-flavor=light|full] [--dev|--start] [--reuse] [--update] [--force] [--background] [--mobile] [--expo-tailscale] [--json] [-- ...]',
10
10
  'hstack stack create-dev-auth-seed [name] [--server=happier-server|happier-server-light] [--login|--no-login] [--skip-default-seed] [--non-interactive] [--json]',
11
11
  'hstack stack daemon <name> start|stop|restart|status [--json]',
12
12
  'hstack stack happier <name> [-- ...]',
13
+ 'hstack stack bug-report <name> [-- ...]',
13
14
  'hstack stack env <name> set KEY=VALUE [KEY2=VALUE2...] | unset KEY [KEY2...] | get KEY | list | path [--json]',
14
15
  'hstack stack auth <name> status|login|copy-from [--json]',
15
16
  'hstack stack dev <name> [-- ...]',
@@ -48,6 +49,7 @@ export const STACK_HELP_COMMANDS = [
48
49
  'daemon',
49
50
  'eas',
50
51
  'happier',
52
+ 'bug-report',
51
53
  'env',
52
54
  'auth',
53
55
  'dev',
package/scripts/stack.mjs CHANGED
@@ -1622,7 +1622,7 @@ async function cmdPrStack({ rootDir, argv }) {
1622
1622
  '[stack] usage:',
1623
1623
  ' hstack stack pr <name> --repo=<pr-url|number> [--dev|--start]',
1624
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]',
1625
+ ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--reuse] [--update] [--force] [--background]',
1626
1626
  ' [--mobile] # also start Expo dev-client Metro for mobile',
1627
1627
  ' [--expo-tailscale] # forward Expo to Tailscale interface for remote access',
1628
1628
  ' [--json] [-- <stack dev/start args...>]',
@@ -1639,6 +1639,7 @@ async function cmdPrStack({ rootDir, argv }) {
1639
1639
  '',
1640
1640
  'notes:',
1641
1641
  ' - This composes existing commands: `hstack stack new`, `hstack stack wt ...`, and `hstack stack auth ...`',
1642
+ ' - `--reuse` reuses an existing PR stack (otherwise `stack pr` fails closed if the stack already exists)',
1642
1643
  ' - For auth seeding, pass `--seed-auth` and optionally `--copy-auth-from=dev-auth` (or main)',
1643
1644
  ' - `--link-auth` symlinks auth files instead of copying (keeps credentials in sync, but reduces isolation)',
1644
1645
  ].join('\n'),
@@ -448,6 +448,43 @@ test('hstack stack daemon <name> start uses runtime server port when env port is
448
448
  );
449
449
  });
450
450
 
451
+ test('hstack stack daemon <name> start uses explicit HAPPIER_SERVER_URL when env port and runtime port are missing', async (t) => {
452
+ const fixture = await createDaemonFixture(t, {
453
+ prefix: 'happy-stacks-stack-daemon-explicit-server-url-',
454
+ stackName: 'exp-test',
455
+ serverPort: 4101,
456
+ });
457
+
458
+ await writeDummyAuth({ cliHomeDir: fixture.stackCliHome });
459
+
460
+ const explicitPort = fixture.serverPort + 9;
461
+ const envPath = join(fixture.storageDir, fixture.stackName, 'env');
462
+ await mkdir(dirname(envPath), { recursive: true });
463
+ await writeFile(
464
+ envPath,
465
+ [
466
+ `HAPPIER_STACK_REPO_DIR=${fixture.baseEnv.HAPPIER_STACK_WORKSPACE_DIR}/happier`,
467
+ `HAPPIER_STACK_CLI_HOME_DIR=${fixture.stackCliHome}`,
468
+ `HAPPIER_SERVER_URL=http://127.0.0.1:${explicitPort}`,
469
+ `HAPPIER_WEBAPP_URL=http://happier-exp-test.localhost:${explicitPort}`,
470
+ '',
471
+ ].join('\n'),
472
+ 'utf-8'
473
+ );
474
+
475
+ registerDaemonCleanup(t, { env: fixture.baseEnv, stackName: fixture.stackName });
476
+
477
+ const startRes = await runHstack(['stack', 'daemon', fixture.stackName, 'start', '--json'], { env: fixture.baseEnv });
478
+ assertExitOk(startRes, 'stack daemon start uses explicit HAPPIER_SERVER_URL');
479
+
480
+ const logPath = join(fixture.stackCliHome, 'stub-daemon.log');
481
+ const logText = await readLogText(logPath);
482
+ assert.ok(
483
+ logText.includes(`server_url=http://127.0.0.1:${explicitPort}`),
484
+ `expected daemon env to target explicit HAPPIER_SERVER_URL port ${explicitPort}\n${logText}`
485
+ );
486
+ });
487
+
451
488
  test('hstack stack auth <name> login --identity=<name> --print prints identity-scoped HAPPIER_HOME_DIR', async (t) => {
452
489
  const fixture = await createDaemonFixture(t, {
453
490
  prefix: 'happier-stack-auth-identity-',
@@ -25,6 +25,7 @@ async function writeStubHappyCli({ cliDir, message }) {
25
25
  [
26
26
  `console.log(JSON.stringify({`,
27
27
  ` message: ${JSON.stringify(message)},`,
28
+ ` args: process.argv.slice(2),`,
28
29
  ` stack: process.env.HAPPIER_STACK_STACK || null,`,
29
30
  ` envFile: process.env.HAPPIER_STACK_ENV_FILE || null,`,
30
31
  ` homeDir: process.env.HAPPIER_HOME_DIR || null,`,
@@ -261,3 +262,38 @@ test('hstack stack <name> happier ... stack-name-first shorthand works', async (
261
262
  assert.equal(out.message, 'name-first');
262
263
  assert.equal(out.stack, fixture.stackName);
263
264
  });
265
+
266
+ test('hstack stack bug-report <name> forwards bug-report command under stack env', async (t) => {
267
+ const fixture = await createHappyStackFixture(t, {
268
+ prefix: 'happier-stack-stack-bug-report-',
269
+ message: 'bug-report-alias',
270
+ serverPort: 4099,
271
+ });
272
+
273
+ const res = await runNodeCapture(
274
+ [
275
+ join(rootDir, 'bin', 'hstack.mjs'),
276
+ 'stack',
277
+ 'bug-report',
278
+ fixture.stackName,
279
+ '--',
280
+ '--title',
281
+ 'CLI bug',
282
+ '--summary',
283
+ 'summary',
284
+ '--current-behavior',
285
+ 'current',
286
+ '--expected-behavior',
287
+ 'expected',
288
+ '--accept-privacy-notice',
289
+ '--no-include-diagnostics',
290
+ ],
291
+ { cwd: rootDir, env: fixture.baseEnv }
292
+ );
293
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
294
+
295
+ const out = JSON.parse(res.stdout.trim());
296
+ assert.equal(out.message, 'bug-report-alias');
297
+ assert.equal(out.stack, fixture.stackName);
298
+ assert.equal(out.args[0], 'bug-report');
299
+ });
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { join } from 'node:path';
4
+
5
+ import { getStackRootFromMeta, hstackBinPath, runNodeCapture } from './testkit/auth_testkit.mjs';
6
+
7
+ test('hstack stack pr --help surfaces --reuse (supported flag)', async () => {
8
+ const rootDir = getStackRootFromMeta(import.meta.url);
9
+
10
+ const env = {
11
+ ...process.env,
12
+ // Prevent env.mjs from auto-loading a real machine stack env file (keeps the test hermetic).
13
+ HAPPIER_STACK_STACK: 'test-stack',
14
+ HAPPIER_STACK_ENV_FILE: join(rootDir, 'scripts', 'nonexistent-env'),
15
+ };
16
+
17
+ const res = await runNodeCapture([hstackBinPath(rootDir), 'stack', 'pr', '--help'], { cwd: rootDir, env });
18
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstderr:\n${res.stderr}\nstdout:\n${res.stdout}`);
19
+ assert.match(res.stdout, /--reuse/, `expected stack pr help to include --reuse\nstdout:\n${res.stdout}`);
20
+ });
21
+
22
+ test('hstack stack --help shows --reuse on the stack pr usage line', async () => {
23
+ const rootDir = getStackRootFromMeta(import.meta.url);
24
+
25
+ const env = {
26
+ ...process.env,
27
+ HAPPIER_STACK_STACK: 'test-stack',
28
+ HAPPIER_STACK_ENV_FILE: join(rootDir, 'scripts', 'nonexistent-env'),
29
+ };
30
+
31
+ const res = await runNodeCapture([hstackBinPath(rootDir), 'stack', '--help'], { cwd: rootDir, env });
32
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstderr:\n${res.stderr}\nstdout:\n${res.stdout}`);
33
+ assert.match(
34
+ res.stdout,
35
+ /hstack stack pr[^\n]*--reuse\b/,
36
+ `expected stack root help to include --reuse on the stack pr usage line\nstdout:\n${res.stdout}`
37
+ );
38
+ });
package/scripts/stop.mjs CHANGED
@@ -57,7 +57,7 @@ async function listAllStackNames() {
57
57
  async function main() {
58
58
  const rootDir = getRootDir(import.meta.url);
59
59
  const argv = process.argv.slice(2);
60
- const { flags, kv } = parseArgs(argv);
60
+ const { flags, kv: argsKv } = parseArgs(argv);
61
61
  const json = wantsJson(argv, { flags });
62
62
 
63
63
  if (wantsHelp(argv, { flags })) {
@@ -65,7 +65,7 @@ async function main() {
65
65
  return;
66
66
  }
67
67
 
68
- const exceptStacks = new Set(parseCsv(kv.get('--except-stacks')));
68
+ const exceptStacks = new Set(parseCsv(argsKv.get('--except-stacks')));
69
69
  const yes = flags.has('--yes');
70
70
  const aggressive = flags.has('--aggressive');
71
71
  const sweepOwned = flags.has('--sweep-owned');
@@ -187,4 +187,3 @@ main().catch((err) => {
187
187
  console.error('[stop] failed:', err);
188
188
  process.exit(1);
189
189
  });
190
-
@@ -8,12 +8,12 @@ function normalizeServerUrl(url) {
8
8
  return String(url ?? '').trim().replace(/\/+$/, '');
9
9
  }
10
10
 
11
- function sanitizeServerIdForFilesystem(raw, fallback = 'cloud') {
11
+ function sanitizeServerIdForFilesystem(raw, fallback = 'default') {
12
12
  const value = String(raw ?? '').trim();
13
- if (!value) return String(fallback ?? '').trim() || 'cloud';
14
- if (value === '.' || value === '..') return String(fallback ?? '').trim() || 'cloud';
15
- if (value.includes('/') || value.includes('\\')) return String(fallback ?? '').trim() || 'cloud';
16
- if (!SERVER_ID_SAFE_RE.test(value)) return String(fallback ?? '').trim() || 'cloud';
13
+ if (!value) return String(fallback ?? '').trim() || 'default';
14
+ if (value === '.' || value === '..') return String(fallback ?? '').trim() || 'default';
15
+ if (value.includes('/') || value.includes('\\')) return String(fallback ?? '').trim() || 'default';
16
+ if (!SERVER_ID_SAFE_RE.test(value)) return String(fallback ?? '').trim() || 'default';
17
17
  return value;
18
18
  }
19
19
 
@@ -46,8 +46,8 @@ export function resolveStackCredentialPaths({ cliHomeDir, serverUrl = '', env =
46
46
  const legacyPath = join(home, 'access.key');
47
47
  const normalizedServerUrl = normalizeServerUrl(serverUrl);
48
48
  const urlHashServerId = sanitizeServerIdForFilesystem(
49
- normalizedServerUrl ? deriveServerIdFromUrl(normalizedServerUrl) : 'cloud',
50
- 'cloud'
49
+ normalizedServerUrl ? deriveServerIdFromUrl(normalizedServerUrl) : 'default',
50
+ 'default'
51
51
  );
52
52
  const overrideServerId = resolveActiveServerIdOverride(env);
53
53
  const activeServerId = overrideServerId || urlHashServerId;
@@ -71,8 +71,8 @@ export function resolveStackDaemonStatePaths({ cliHomeDir, serverUrl = '', env =
71
71
  const home = requireCliHomeDir(cliHomeDir);
72
72
  const normalizedServerUrl = normalizeServerUrl(serverUrl);
73
73
  const urlHashServerId = sanitizeServerIdForFilesystem(
74
- normalizedServerUrl ? deriveServerIdFromUrl(normalizedServerUrl) : 'cloud',
75
- 'cloud'
74
+ normalizedServerUrl ? deriveServerIdFromUrl(normalizedServerUrl) : 'default',
75
+ 'default'
76
76
  );
77
77
  const overrideServerId = resolveActiveServerIdOverride(env);
78
78
  const activeServerId = overrideServerId || urlHashServerId;
@@ -22,6 +22,14 @@ test('resolveStackCredentialPaths returns legacy + server-scoped paths', async (
22
22
  assert.deepEqual(out.paths, [out.serverScopedPath, out.legacyPath]);
23
23
  });
24
24
 
25
+ test('resolveStackCredentialPaths uses a neutral default server id when serverUrl is empty', async () => {
26
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-cred-paths-'));
27
+ const out = resolveStackCredentialPaths({ cliHomeDir: dir, serverUrl: '' });
28
+ assert.equal(out.urlHashServerId, 'default');
29
+ assert.equal(out.activeServerId, 'default');
30
+ assert.ok(out.serverScopedPath.endsWith('/servers/default/access.key'));
31
+ });
32
+
25
33
  test('findExistingStackCredentialPath prefers server-scoped credentials', async () => {
26
34
  const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-cred-paths-'));
27
35
  const serverUrl = 'http://127.0.0.1:3009';
@@ -19,15 +19,76 @@ function appendCauseText(baseMessage, cause) {
19
19
  return `${msg}\n\n[auth] Cause: ${c}`;
20
20
  }
21
21
 
22
+ async function readTextWithTimeout(path, { timeoutMs = 1200 } = {}) {
23
+ try {
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
26
+ const res = await fetch(path, { signal: controller.signal });
27
+ clearTimeout(timer);
28
+ return { ok: res.ok, status: res.status };
29
+ } catch (error) {
30
+ return { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
31
+ }
32
+ }
33
+
34
+ function formatPidStatus(label, pidRaw) {
35
+ const pid = Number(pidRaw);
36
+ if (!Number.isFinite(pid) || pid <= 1) return `[auth] ${label}: unavailable`;
37
+ return `[auth] ${label}: ${pid} (${isPidAlive(pid) ? 'alive' : 'stale/dead'})`;
38
+ }
39
+
40
+ function formatLogPath(label, logPath) {
41
+ const p = String(logPath ?? '').trim();
42
+ return p ? `[auth] ${label}: ${p}` : `[auth] ${label}: unavailable`;
43
+ }
44
+
45
+ async function appendRuntimeHealthDiagnostics(message, stackName) {
46
+ const name = String(stackName ?? '').trim() || 'main';
47
+ const statePath = getStackRuntimeStatePath(name);
48
+
49
+ const state = await readStackRuntimeStateFile(statePath).catch(() => null);
50
+ if (!state || typeof state !== 'object') {
51
+ return `${message}\n\n[auth] Stack runtime state unavailable: ${statePath}`;
52
+ }
53
+
54
+ const serverPort = Number(state?.ports?.server);
55
+ let serverHealth = 'not configured';
56
+ if (Number.isFinite(serverPort) && serverPort > 0) {
57
+ const probe = await readTextWithTimeout(`http://127.0.0.1:${serverPort}/health`, { timeoutMs: 1200 });
58
+ if (probe.ok) {
59
+ serverHealth = `HTTP ${probe.status}`;
60
+ } else if (probe.error) {
61
+ serverHealth = probe.error;
62
+ } else {
63
+ serverHealth = `HTTP ${probe.status}`;
64
+ }
65
+ }
66
+
67
+ const runtimeSummary = [
68
+ `[auth] Stack runtime path: ${statePath}`,
69
+ formatPidStatus('ownerPid', state?.ownerPid),
70
+ formatPidStatus('serverPid', state?.processes?.serverPid),
71
+ formatPidStatus('expoPid', state?.processes?.expoPid),
72
+ formatPidStatus('expoTailscaleForwarderPid', state?.processes?.expoTailscaleForwarderPid),
73
+ `[auth] server port: ${Number.isFinite(serverPort) && serverPort > 0 ? serverPort : 'unconfigured'}`,
74
+ `[auth] server health: ${serverHealth}`,
75
+ formatLogPath('runner log', state?.logs?.runner),
76
+ formatLogPath('cli log', state?.logs?.cli),
77
+ ].join('\n');
78
+
79
+ return `${String(message ?? '').trim()}\n\n${runtimeSummary}`;
80
+ }
81
+
22
82
  async function appendRunnerLogTailDiagnostics({ message, stackName, lines = 140 }) {
23
83
  const base = String(message ?? '').trim();
24
84
  const logPath = await resolveRunnerLogPathFromRuntime({ stackName, waitMs: 1000, pollMs: 100 }).catch(() => '');
25
- if (!logPath) return base;
85
+ const withState = await appendRuntimeHealthDiagnostics(base, stackName).catch(() => base);
86
+ if (!logPath) return withState;
26
87
  const tail = await readLastLines(logPath, lines).catch(() => null);
27
88
  if (!tail || !String(tail).trim()) {
28
- return `${base}\n\n[auth] Stack runner log: ${logPath}`;
89
+ return `${withState}\n\n[auth] Stack runner log: ${logPath}`;
29
90
  }
30
- return `${base}\n\n[auth] Stack runner log: ${logPath}\n\n[auth] Last runner log lines:\n${String(tail).trimEnd()}`;
91
+ return `${withState}\n\n[auth] Stack runner log: ${logPath}\n\n[auth] Last runner log lines:\n${String(tail).trimEnd()}`;
31
92
  }
32
93
 
33
94
  async function tryStartStackUiInBackgroundForAuth({ rootDir, stackName, env = process.env } = {}) {
@@ -50,7 +50,7 @@ export function buildStackStableScopeId({ stackName = 'main', cliIdentity = 'def
50
50
  if (isScopeIdSafe(compact)) return compact;
51
51
 
52
52
  const fallback = `stack_${hash}`;
53
- return isScopeIdSafe(fallback) ? fallback : 'cloud';
53
+ return isScopeIdSafe(fallback) ? fallback : 'stack-default';
54
54
  }
55
55
 
56
56
  export function isStableScopeDisabled(env = process.env) {