@happier-dev/stack 0.1.0-preview.100.1 → 0.1.0-preview.134.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 (109) 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 +15 -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 +97 -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/build.mjs +3 -18
  65. package/scripts/bundleWorkspaceDeps.mjs +5 -1
  66. package/scripts/bundleWorkspaceDeps.test.mjs +16 -0
  67. package/scripts/mobile.mjs +32 -1
  68. package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
  69. package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
  70. package/scripts/mobile_run_ios_passes_port.integration.test.mjs +101 -0
  71. package/scripts/providers_cmd.mjs +262 -0
  72. package/scripts/release_binary_smoke.integration.test.mjs +25 -6
  73. package/scripts/remote_cmd.mjs +240 -0
  74. package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
  75. package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
  76. package/scripts/self_host_runtime.mjs +1403 -312
  77. package/scripts/self_host_runtime.test.mjs +361 -1
  78. package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
  79. package/scripts/self_host_service_e2e_harness.mjs +93 -0
  80. package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
  81. package/scripts/service.mjs +156 -26
  82. package/scripts/stack/command_arguments.mjs +1 -0
  83. package/scripts/stack/help_text.mjs +2 -0
  84. package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
  85. package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
  86. package/scripts/utils/auth/credentials_paths.mjs +9 -9
  87. package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
  88. package/scripts/utils/auth/stable_scope_id.mjs +1 -1
  89. package/scripts/utils/cli/cli_registry.mjs +18 -0
  90. package/scripts/utils/cli/progress.mjs +8 -1
  91. package/scripts/utils/cli/progress.test.mjs +43 -0
  92. package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
  93. package/scripts/utils/dev/expo_dev.mjs +35 -5
  94. package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
  95. package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
  96. package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
  97. package/scripts/utils/server/port.mjs +20 -2
  98. package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
  99. package/scripts/utils/service/service_manager.mjs +96 -0
  100. package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
  101. package/scripts/utils/service/service_manager.test.mjs +20 -0
  102. package/scripts/utils/service/systemd_service_unit.mjs +1 -0
  103. package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
  104. package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
  105. package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
  106. package/scripts/utils/ui/ui_export_env.mjs +29 -0
  107. package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
  108. package/scripts/worktrees.mjs +3 -0
  109. package/scripts/worktrees_status_default_target.test.mjs +56 -0
@@ -14,6 +14,7 @@ import { expoExec } from './utils/expo/command.mjs';
14
14
  import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
15
15
  import { resolveMobileReachableServerUrl } from './utils/server/mobile_api_url.mjs';
16
16
  import { patchIosXcodeProjectsForSigningAndIdentity, resolveIosAppXcodeProjects } from './utils/mobile/ios_xcodeproj_patch.mjs';
17
+ import { pickMetroPort, resolveStablePortStart } from './utils/expo/metro_ports.mjs';
17
18
 
18
19
  /**
19
20
  * Mobile dev helper for the Happier UI Expo app (typically `apps/ui`).
@@ -75,6 +76,7 @@ async function main() {
75
76
  const uiRepoDir = getComponentDir(rootDir, 'happier-ui');
76
77
  await requireDir('happier-ui', uiRepoDir);
77
78
  await ensureDepsInstalled(uiRepoDir, 'happier-ui');
79
+ const happyDir = uiRepoDir;
78
80
 
79
81
  // Happy monorepo layouts (historical):
80
82
  // - legacy: <happyDir>/expo-app (split-repo era)
@@ -104,7 +106,9 @@ async function main() {
104
106
  // (Info.plist includes `dev.happier.app.dev`), so iOS will open the dev build instead of the App Store app.
105
107
  const appEnv = process.env.APP_ENV ?? kv.get('--app-env') ?? 'development';
106
108
  const host = kv.get('--host') ?? process.env.HAPPIER_STACK_MOBILE_HOST ?? 'lan';
107
- const portRaw = kv.get('--port') ?? process.env.HAPPIER_STACK_MOBILE_PORT ?? '8081';
109
+ const portFromArg = kv.get('--port') ?? '';
110
+ const portFromEnv = process.env.HAPPIER_STACK_MOBILE_PORT ?? '';
111
+ const portRaw = portFromArg || portFromEnv || '8081';
108
112
  // Default behavior:
109
113
  // - `hstack mobile` starts Metro and keeps running.
110
114
  // - `hstack mobile --run-ios` / `hstack mobile:ios` just builds/installs and exits (unless --metro is provided).
@@ -126,6 +130,29 @@ async function main() {
126
130
  const stackCtx = resolveStackContext({ env, autostart });
127
131
  const { stackMode, runtimeStatePath, stackName, envPath } = stackCtx;
128
132
 
133
+ // Expo/React Native native build steps can probe the Metro port even when we pass `--no-bundler`.
134
+ // Defaulting to 8081 makes builds much more likely to fail late if another Metro/Expo starts on the same port.
135
+ //
136
+ // Strategy:
137
+ // - If the user explicitly sets --port or HAPPIER_STACK_MOBILE_PORT, honor it.
138
+ // - Otherwise pick a stable, collision-resistant port in a higher range for build-only steps.
139
+ const needsNativeBuildPort = flags.has('--prebuild') || flags.has('--run-ios');
140
+ if (needsNativeBuildPort) {
141
+ const forcedPort = (portFromArg || portFromEnv).toString().trim();
142
+ const stableKey = (stackMode && stackName ? stackName : '') || iosBundleId || scheme || 'happier';
143
+ const startPort = resolveStablePortStart({
144
+ env,
145
+ stackName: stableKey,
146
+ baseKey: 'HAPPIER_STACK_MOBILE_BUILD_PORT_BASE',
147
+ rangeKey: 'HAPPIER_STACK_MOBILE_BUILD_PORT_RANGE',
148
+ defaultBase: 19000,
149
+ defaultRange: 10000,
150
+ });
151
+ const metroPort = await pickMetroPort({ startPort, forcedPort, host: '127.0.0.1' });
152
+ env.RCT_METRO_PORT = String(metroPort);
153
+ env.EXPO_PACKAGER_PORT = String(metroPort);
154
+ }
155
+
129
156
  // Ensure the built iOS app registers the same scheme we use for dev-client QR links.
130
157
  // (Happy app reads EXPO_APP_SCHEME in app.config.js; default remains unchanged when unset.)
131
158
  env.EXPO_APP_SCHEME = scheme;
@@ -306,7 +333,11 @@ async function main() {
306
333
  }
307
334
 
308
335
  const configuration = kv.get('--configuration') ?? 'Debug';
336
+ const buildMetroPort = (env.RCT_METRO_PORT ?? env.EXPO_PACKAGER_PORT ?? '').toString().trim();
309
337
  const args = ['run:ios', '--no-bundler', '--no-build-cache', '--configuration', configuration];
338
+ if (buildMetroPort) {
339
+ args.push('-p', buildMetroPort);
340
+ }
310
341
  if (device) {
311
342
  args.push('-d', device);
312
343
  }
@@ -0,0 +1,47 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getStackRootFromMeta, runNodeCapture } from './testkit/auth_testkit.mjs';
8
+
9
+ test('hstack mobile --prebuild does not crash with undefined happyDir', async () => {
10
+ const rootDir = getStackRootFromMeta(import.meta.url);
11
+ const mobileScript = join(rootDir, 'scripts', 'mobile.mjs');
12
+
13
+ const tmp = await mkdtemp(join(tmpdir(), 'hstack-mobile-'));
14
+ const repoDir = join(tmp, 'repo');
15
+ const storageDir = join(tmp, 'storage');
16
+
17
+ try {
18
+ // Minimal fixture: the script expects a "happier-ui" dir to exist at <repo>/apps/ui.
19
+ // Avoid creating package.json to keep the run hermetic (ensureDepsInstalled will no-op).
20
+ await mkdir(join(repoDir, 'apps', 'ui'), { recursive: true });
21
+
22
+ // Ensure stack storage stays within this tmp dir (avoid touching real ~/.happier paths).
23
+ await mkdir(join(storageDir, 'main'), { recursive: true });
24
+
25
+ const env = {
26
+ ...process.env,
27
+ HAPPIER_STACK_REPO_DIR: repoDir,
28
+ HAPPIER_STACK_HOME_DIR: join(tmp, 'home'),
29
+ HAPPIER_STACK_STORAGE_DIR: storageDir,
30
+ HAPPIER_STACK_STACK: 'main',
31
+ // Keep resolveServerUrls from probing tailscale in tests.
32
+ HAPPIER_STACK_TAILSCALE_PREFER_PUBLIC_URL: '0',
33
+ HAPPIER_STACK_TAILSCALE_SERVE: '0',
34
+ // Prevent env auto-loading from a real machine stack env file.
35
+ HAPPIER_STACK_ENV_FILE: join(tmp, 'nonexistent-env'),
36
+ };
37
+
38
+ const res = await runNodeCapture([mobileScript, '--prebuild', '--no-metro'], { cwd: rootDir, env });
39
+ assert.doesNotMatch(
40
+ res.stderr,
41
+ /\bhappyDir is not defined\b/,
42
+ `expected prebuild to fail for a real reason (e.g. missing expo), not a ReferenceError\nstderr:\n${res.stderr}\nstdout:\n${res.stdout}`
43
+ );
44
+ } finally {
45
+ await rm(tmp, { recursive: true, force: true });
46
+ }
47
+ });
@@ -0,0 +1,81 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getStackRootFromMeta, runNodeCapture } from './testkit/auth_testkit.mjs';
8
+
9
+ function parseKeyValueLines(text) {
10
+ const out = {};
11
+ for (const line of String(text ?? '').split(/\r?\n/)) {
12
+ const idx = line.indexOf('=');
13
+ if (idx === -1) continue;
14
+ const key = line.slice(0, idx).trim();
15
+ const value = line.slice(idx + 1).trim();
16
+ if (key) out[key] = value;
17
+ }
18
+ return out;
19
+ }
20
+
21
+ test('hstack mobile --prebuild sets RCT_METRO_PORT for native build steps', async () => {
22
+ const rootDir = getStackRootFromMeta(import.meta.url);
23
+ const mobileScript = join(rootDir, 'scripts', 'mobile.mjs');
24
+
25
+ const tmp = await mkdtemp(join(tmpdir(), 'hstack-mobile-port-'));
26
+ const repoDir = join(tmp, 'repo');
27
+ const storageDir = join(tmp, 'storage');
28
+
29
+ try {
30
+ const uiDir = join(repoDir, 'apps', 'ui');
31
+ const expoBin = join(uiDir, 'node_modules', '.bin', 'expo');
32
+ await mkdir(join(uiDir, 'node_modules', '.bin'), { recursive: true });
33
+
34
+ // Stub Expo CLI so the test doesn't require installing or running the real toolchain.
35
+ await writeFile(
36
+ expoBin,
37
+ `#!/usr/bin/env node
38
+ console.log('RCT_METRO_PORT=' + (process.env.RCT_METRO_PORT ?? ''));
39
+ console.log('EXPO_PACKAGER_PORT=' + (process.env.EXPO_PACKAGER_PORT ?? ''));
40
+ process.exit(0);
41
+ `,
42
+ 'utf-8'
43
+ );
44
+ if (process.platform !== 'win32') {
45
+ await chmod(expoBin, 0o755);
46
+ }
47
+
48
+ // Ensure stack storage stays within this tmp dir (avoid touching real ~/.happier paths).
49
+ await mkdir(join(storageDir, 'main'), { recursive: true });
50
+
51
+ const env = {
52
+ ...process.env,
53
+ HAPPIER_STACK_REPO_DIR: repoDir,
54
+ HAPPIER_STACK_HOME_DIR: join(tmp, 'home'),
55
+ HAPPIER_STACK_STORAGE_DIR: storageDir,
56
+ HAPPIER_STACK_STACK: 'main',
57
+ // Keep resolveServerUrls from probing tailscale in tests.
58
+ HAPPIER_STACK_TAILSCALE_PREFER_PUBLIC_URL: '0',
59
+ HAPPIER_STACK_TAILSCALE_SERVE: '0',
60
+ // Prevent env auto-loading from a real machine stack env file.
61
+ HAPPIER_STACK_ENV_FILE: join(tmp, 'nonexistent-env'),
62
+ };
63
+
64
+ const res = await runNodeCapture([mobileScript, '--prebuild', '--platform=android', '--no-metro'], { cwd: rootDir, env });
65
+ const kv = parseKeyValueLines(res.stdout);
66
+ assert.ok(kv.RCT_METRO_PORT, `expected stub expo to print RCT_METRO_PORT\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
67
+ assert.equal(
68
+ kv.EXPO_PACKAGER_PORT,
69
+ kv.RCT_METRO_PORT,
70
+ `expected EXPO_PACKAGER_PORT to match RCT_METRO_PORT\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`
71
+ );
72
+ assert.notEqual(
73
+ kv.RCT_METRO_PORT,
74
+ '8081',
75
+ `expected native build steps to avoid default Metro port 8081 (more collision-prone)\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`
76
+ );
77
+ } finally {
78
+ await rm(tmp, { recursive: true, force: true });
79
+ }
80
+ });
81
+
@@ -0,0 +1,101 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { getStackRootFromMeta, runNodeCapture } from './testkit/auth_testkit.mjs';
8
+
9
+ function parseKeyValueLines(text) {
10
+ const out = {};
11
+ for (const line of String(text ?? '').split(/\r?\n/)) {
12
+ const idx = line.indexOf('=');
13
+ if (idx === -1) continue;
14
+ const key = line.slice(0, idx).trim();
15
+ const value = line.slice(idx + 1).trim();
16
+ if (key) out[key] = value;
17
+ }
18
+ return out;
19
+ }
20
+
21
+ test('hstack mobile --run-ios passes -p/--port to Expo (avoids default 8081)', async () => {
22
+ const rootDir = getStackRootFromMeta(import.meta.url);
23
+ const mobileScript = join(rootDir, 'scripts', 'mobile.mjs');
24
+
25
+ const tmp = await mkdtemp(join(tmpdir(), 'hstack-mobile-runios-port-'));
26
+ const repoDir = join(tmp, 'repo');
27
+ const storageDir = join(tmp, 'storage');
28
+
29
+ try {
30
+ const binDir = join(tmp, 'bin');
31
+ await mkdir(binDir, { recursive: true });
32
+ const xcrunStub = join(binDir, 'xcrun');
33
+ await writeFile(
34
+ xcrunStub,
35
+ `#!/bin/bash
36
+ set -euo pipefail
37
+ if [[ "\${1:-}" == "xcdevice" && "\${2:-}" == "list" ]]; then
38
+ echo "[]"
39
+ exit 0
40
+ fi
41
+ echo "xcrun stub: unsupported args: $*" >&2
42
+ exit 1
43
+ `,
44
+ 'utf-8'
45
+ );
46
+ if (process.platform !== 'win32') {
47
+ await chmod(xcrunStub, 0o755);
48
+ }
49
+
50
+ const uiDir = join(repoDir, 'apps', 'ui');
51
+ const expoBin = join(uiDir, 'node_modules', '.bin', 'expo');
52
+ await mkdir(join(uiDir, 'node_modules', '.bin'), { recursive: true });
53
+
54
+ // Stub Expo CLI: print argv + env-derived ports and exit successfully.
55
+ // Use an absolute node shebang so the test can safely clear PATH.
56
+ await writeFile(
57
+ expoBin,
58
+ `#!${process.execPath}
59
+ console.log('ARGS=' + JSON.stringify(process.argv.slice(2)));
60
+ console.log('RCT_METRO_PORT=' + (process.env.RCT_METRO_PORT ?? ''));
61
+ console.log('EXPO_PACKAGER_PORT=' + (process.env.EXPO_PACKAGER_PORT ?? ''));
62
+ process.exit(0);
63
+ `,
64
+ 'utf-8'
65
+ );
66
+ if (process.platform !== 'win32') {
67
+ await chmod(expoBin, 0o755);
68
+ }
69
+
70
+ await mkdir(join(storageDir, 'main'), { recursive: true });
71
+
72
+ const env = {
73
+ ...process.env,
74
+ // Ensure xcrun runs fast/deterministically in tests.
75
+ PATH: binDir,
76
+ HAPPIER_STACK_REPO_DIR: repoDir,
77
+ HAPPIER_STACK_HOME_DIR: join(tmp, 'home'),
78
+ HAPPIER_STACK_STORAGE_DIR: storageDir,
79
+ HAPPIER_STACK_STACK: 'main',
80
+ HAPPIER_STACK_TAILSCALE_PREFER_PUBLIC_URL: '0',
81
+ HAPPIER_STACK_TAILSCALE_SERVE: '0',
82
+ HAPPIER_STACK_ENV_FILE: join(tmp, 'nonexistent-env'),
83
+ };
84
+
85
+ const res = await runNodeCapture([mobileScript, '--run-ios', '--no-metro'], { cwd: rootDir, env });
86
+ const kv = parseKeyValueLines(res.stdout);
87
+
88
+ const args = JSON.parse(kv.ARGS ?? '[]');
89
+ const port = kv.RCT_METRO_PORT ?? '';
90
+
91
+ assert.ok(port, `expected RCT_METRO_PORT to be set\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
92
+ assert.equal(kv.EXPO_PACKAGER_PORT, port, `expected EXPO_PACKAGER_PORT to match\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
93
+ assert.ok(args.includes('-p') || args.includes('--port'), `expected expo args to include -p/--port\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
94
+ const pIdx = args.indexOf('-p') !== -1 ? args.indexOf('-p') : args.indexOf('--port');
95
+ assert.equal(args[pIdx + 1], port, `expected expo -p to match RCT_METRO_PORT\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
96
+ assert.notEqual(port, '8081', `expected non-default port to reduce collisions\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
97
+ } finally {
98
+ await rm(tmp, { recursive: true, force: true });
99
+ }
100
+ });
101
+
@@ -0,0 +1,262 @@
1
+ import './utils/env/env.mjs';
2
+
3
+ import { parseArgs } from './utils/cli/args.mjs';
4
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
5
+ import { createStepPrinter, runCommandLogged } from './utils/cli/progress.mjs';
6
+ import { AGENT_IDS, getProviderCliInstallSpec } from '@happier-dev/agents';
7
+ import { installProviderCli, planProviderCliInstall, resolvePlatformFromNodePlatform } from '@happier-dev/cli-common/providers';
8
+ import { mkdirSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+
13
+ function usageText() {
14
+ return [
15
+ '[providers] usage:',
16
+ ' hstack providers list [--json]',
17
+ ' hstack providers install --providers=<id1,id2> [--dry-run] [--force] [--json]',
18
+ ' hstack providers install <id1> <id2> [--dry-run] [--force] [--json]',
19
+ '',
20
+ 'notes:',
21
+ ' - Provider CLIs are external binaries used by Happier backends (claude/codex/gemini/etc).',
22
+ ' - This command installs provider CLIs (best-effort). Some providers require manual installation.',
23
+ ' - Claude install uses the upstream native installer by default (not npm).',
24
+ ' - Use --force to re-run the installer even if the binary is already present on PATH.',
25
+ ].join('\n');
26
+ }
27
+
28
+ function splitProviders(raw) {
29
+ const v = String(raw ?? '').trim();
30
+ if (!v) return [];
31
+ return v
32
+ .split(',')
33
+ .map((s) => s.trim().toLowerCase())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ function resolvePlatform() {
38
+ return resolvePlatformFromNodePlatform(process.platform) ?? 'unsupported';
39
+ }
40
+
41
+ function commandExists(cmd, env) {
42
+ const name = String(cmd ?? '').trim();
43
+ if (!name) return false;
44
+
45
+ const pathEnv = env?.PATH ?? process.env.PATH;
46
+ if (process.platform === 'win32') {
47
+ const res = spawnSync('where', [name], { stdio: 'ignore', env: { ...process.env, ...(env ?? {}), PATH: pathEnv } });
48
+ return (res.status ?? 1) === 0;
49
+ }
50
+ const res = spawnSync('sh', ['-lc', `command -v ${name} >/dev/null 2>&1`], { stdio: 'ignore', env: { ...process.env, ...(env ?? {}), PATH: pathEnv } });
51
+ return (res.status ?? 1) === 0;
52
+ }
53
+
54
+ function resolveProviderInstallLogPath(providerId) {
55
+ const base = join(tmpdir(), 'happier-provider-installs');
56
+ mkdirSync(base, { recursive: true });
57
+ return join(base, `install-provider-${providerId}-${Date.now()}.log`);
58
+ }
59
+
60
+ function planForProvider(providerId) {
61
+ const platform = resolvePlatform();
62
+ if (platform === 'unsupported') {
63
+ return { ok: false, provider: providerId, error: 'Unsupported platform' };
64
+ }
65
+ const planned = planProviderCliInstall({ providerId, platform });
66
+ if (!planned.ok) {
67
+ return { ok: false, provider: providerId, error: planned.errorMessage };
68
+ }
69
+ return { ok: true, provider: providerId, commands: planned.plan.commands };
70
+ }
71
+
72
+ async function cmdList({ argv }) {
73
+ const { flags } = parseArgs(argv);
74
+ const json = wantsJson(argv, { flags });
75
+ const platform = resolvePlatform();
76
+ const rows = AGENT_IDS.map((id) => {
77
+ const spec = getProviderCliInstallSpec(id);
78
+ const planned = planForProvider(id);
79
+ return {
80
+ id: spec.id,
81
+ title: spec.title,
82
+ binaries: spec.binaries,
83
+ autoInstall: planned.ok,
84
+ note: planned.ok ? null : planned.error,
85
+ platform,
86
+ };
87
+ });
88
+
89
+ printResult({
90
+ json,
91
+ data: { ok: true, platform, providers: rows },
92
+ text: json
93
+ ? null
94
+ : rows
95
+ .map((r) => `${r.autoInstall ? '✓' : '-'} ${r.id}${r.title ? ` (${r.title})` : ''}${r.note ? ` — ${r.note}` : ''}`)
96
+ .join('\n'),
97
+ });
98
+ }
99
+
100
+ async function cmdInstall({ argv }) {
101
+ const { flags, kv } = parseArgs(argv);
102
+ const json = wantsJson(argv, { flags });
103
+ const dryRun = flags.has('--dry-run') || flags.has('--plan');
104
+ const force = flags.has('--force') || flags.has('--reinstall');
105
+ const skipIfInstalled = !force;
106
+
107
+ const positionals = argv.filter((a) => a && a !== '--' && !a.startsWith('-'));
108
+ const inputFromFlag = kv.get('--providers') ?? '';
109
+ const inputFromPositional = positionals;
110
+
111
+ const wanted = [
112
+ ...splitProviders(inputFromFlag),
113
+ ...inputFromPositional.flatMap((s) => splitProviders(String(s).trim().toLowerCase())),
114
+ ];
115
+
116
+ if (wanted.length === 0) {
117
+ throw new Error('[providers] missing providers. Use --providers=claude,codex or pass ids as positionals.');
118
+ }
119
+
120
+ const resolved = wanted.map((id) => {
121
+ if (!AGENT_IDS.includes(id)) {
122
+ const e = new Error(`[providers] unknown provider: ${id}`);
123
+ e.code = 'EUNKNOWN_PROVIDER';
124
+ throw e;
125
+ }
126
+ return id;
127
+ });
128
+
129
+ const platform = resolvePlatform();
130
+ if (platform === 'unsupported') {
131
+ throw new Error('[providers] unsupported platform');
132
+ }
133
+
134
+ // In json mode, preserve the existing structured behavior (no progress output).
135
+ if (json) {
136
+ const results = resolved.map((providerId) =>
137
+ installProviderCli({ providerId, platform, dryRun, skipIfInstalled, env: process.env }),
138
+ );
139
+ const failures = results.filter((r) => !r.ok);
140
+ if (failures.length > 0) {
141
+ const first = failures[0];
142
+ const extra = first.logPath ? `\nlog: ${first.logPath}` : '';
143
+ throw new Error(`[providers] install failed: ${first.errorMessage}${extra}`.trim());
144
+ }
145
+
146
+ const plan = results.map((r) => (r.ok ? r.plan : null)).filter(Boolean);
147
+
148
+ printResult({
149
+ json,
150
+ data: {
151
+ ok: true,
152
+ providers: resolved,
153
+ dryRun,
154
+ skipIfInstalled,
155
+ plan,
156
+ results: results.map((r) => (r.ok ? { ok: true, providerId: r.plan.providerId, alreadyInstalled: r.alreadyInstalled, logPath: r.logPath } : r)),
157
+ },
158
+ text: null,
159
+ });
160
+ return;
161
+ }
162
+
163
+ // Human-friendly progress output (TTY spinner when interactive; simple lines otherwise).
164
+ const steps = createStepPrinter({ enabled: true });
165
+ const results = [];
166
+ for (const providerId of resolved) {
167
+ const spec = getProviderCliInstallSpec(providerId);
168
+ const planned = planProviderCliInstall({ providerId, platform });
169
+ if (!planned.ok) {
170
+ throw new Error(`[providers] install failed: ${planned.errorMessage}`);
171
+ }
172
+
173
+ const label = `Installing ${spec.title || `${providerId} CLI`}`;
174
+ const binariesPresent = skipIfInstalled && spec.binaries.every((b) => commandExists(b, process.env));
175
+ if (binariesPresent) {
176
+ steps.info(`- [✓] ${label} (already installed)`);
177
+ results.push({ ok: true, providerId, alreadyInstalled: true, logPath: null, plan: planned.plan });
178
+ continue;
179
+ }
180
+
181
+ steps.start(label);
182
+ if (dryRun) {
183
+ steps.stop('✓', `${label} (dry-run)`);
184
+ results.push({ ok: true, providerId, alreadyInstalled: false, logPath: null, plan: planned.plan });
185
+ continue;
186
+ }
187
+
188
+ const logPath = resolveProviderInstallLogPath(providerId);
189
+ writeFileSync(
190
+ logPath,
191
+ [`# providerId: ${providerId}`, `# platform: ${platform}`, ''].join('\n'),
192
+ 'utf8',
193
+ );
194
+
195
+ try {
196
+ for (const c of planned.plan.commands) {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ await runCommandLogged({
199
+ label,
200
+ cmd: c.cmd,
201
+ args: c.args,
202
+ cwd: process.cwd(),
203
+ env: process.env,
204
+ logPath,
205
+ showSteps: false,
206
+ quiet: true,
207
+ });
208
+ }
209
+ } catch (e) {
210
+ steps.stop('x', label);
211
+ const message = e instanceof Error ? e.message : String(e);
212
+ throw new Error(`[providers] install failed: ${message}\nlog: ${logPath}`);
213
+ }
214
+
215
+ steps.stop('✓', label);
216
+ results.push({ ok: true, providerId, alreadyInstalled: false, logPath, plan: planned.plan });
217
+ }
218
+
219
+ printResult({
220
+ json,
221
+ data: {
222
+ ok: true,
223
+ providers: resolved,
224
+ dryRun,
225
+ skipIfInstalled,
226
+ plan: results.map((r) => r.plan),
227
+ results: results.map((r) => ({ ok: true, providerId: r.providerId, alreadyInstalled: r.alreadyInstalled, logPath: r.logPath })),
228
+ },
229
+ text: json ? null : `✓ providers installed: ${resolved.join(', ')}`,
230
+ });
231
+ }
232
+
233
+ async function main() {
234
+ const argv = process.argv.slice(2);
235
+ const { flags } = parseArgs(argv);
236
+ const json = wantsJson(argv, { flags });
237
+
238
+ if (argv.length === 0 || wantsHelp(argv, { flags })) {
239
+ printResult({ json, data: { usage: usageText() }, text: usageText() });
240
+ return;
241
+ }
242
+
243
+ const positionals = argv.filter((a) => a && a !== '--' && !a.startsWith('-'));
244
+ const sub = String(positionals[0] ?? '').trim();
245
+ if (sub === 'list') {
246
+ await cmdList({ argv: argv.slice(1) });
247
+ return;
248
+ }
249
+ if (sub === 'install') {
250
+ await cmdInstall({ argv: argv.slice(1) });
251
+ return;
252
+ }
253
+
254
+ printResult({ json, data: { usage: usageText() }, text: usageText() });
255
+ process.exit(2);
256
+ }
257
+
258
+ main().catch((error) => {
259
+ const msg = error instanceof Error ? error.message : String(error);
260
+ process.stderr.write(`${msg}\n`);
261
+ process.exit(1);
262
+ });
@@ -27,6 +27,25 @@ function commandExists(cmd) {
27
27
  return spawnSync('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1`], { stdio: 'ignore' }).status === 0;
28
28
  }
29
29
 
30
+ function runWithHardTimeout(command, args, options) {
31
+ const timeoutMs = Number(options.timeout ?? 0);
32
+ if (process.platform === 'linux' && commandExists('timeout') && timeoutMs > 0) {
33
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
34
+ return spawnSync('timeout', ['--signal=KILL', '--kill-after=30s', `${timeoutSeconds}s`, command, ...args], {
35
+ ...options,
36
+ timeout: undefined,
37
+ });
38
+ }
39
+ return spawnSync(command, args, options);
40
+ }
41
+
42
+ function didCommandTimeout(result) {
43
+ if (result?.error?.code === 'ETIMEDOUT') return true;
44
+ if (result?.status === 124 || result?.status === 137) return true;
45
+ if (result?.signal === 'SIGKILL') return true;
46
+ return false;
47
+ }
48
+
30
49
  function currentTarget() {
31
50
  const os = process.platform === 'linux' ? 'linux' : process.platform === 'darwin' ? 'darwin' : '';
32
51
  const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : '';
@@ -65,7 +84,7 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
65
84
  const repoRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)));
66
85
  const version = `0.0.0-smoke.${Date.now()}`;
67
86
 
68
- const buildCli = spawnSync(
87
+ const buildCli = runWithHardTimeout(
69
88
  process.execPath,
70
89
  [
71
90
  'scripts/release/build-cli-binaries.mjs',
@@ -91,13 +110,13 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
91
110
  t.after(() => {
92
111
  spawnSync('bash', ['-lc', `rm -rf "${cliExtract.extractDir.replaceAll('"', '\\"')}"`], { stdio: 'ignore' });
93
112
  });
94
- const cliVersion = spawnSync(cliExtract.binaryPath, ['--version'], {
113
+ const cliVersion = runWithHardTimeout(cliExtract.binaryPath, ['--version'], {
95
114
  cwd: '/tmp',
96
115
  encoding: 'utf-8',
97
116
  env: { ...process.env, HAPPIER_NONINTERACTIVE: '1' },
98
117
  timeout: 7000,
99
118
  });
100
- const cliTimedOut = cliVersion.error && cliVersion.error.code === 'ETIMEDOUT';
119
+ const cliTimedOut = didCommandTimeout(cliVersion);
101
120
  const cliExited = (cliVersion.status ?? 1) === 0;
102
121
  assert.ok(cliTimedOut || cliExited, cliVersion.stderr || cliVersion.stdout);
103
122
  const versionText = `${cliVersion.stdout || ''}${cliVersion.stderr || ''}`.trim();
@@ -107,7 +126,7 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
107
126
  );
108
127
 
109
128
  if (isLinuxTarget(target)) {
110
- const buildServer = spawnSync(
129
+ const buildServer = runWithHardTimeout(
111
130
  process.execPath,
112
131
  [
113
132
  'scripts/release/build-server-binaries.mjs',
@@ -135,7 +154,7 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
135
154
  t.after(() => {
136
155
  spawnSync('bash', ['-lc', `rm -rf "${serverDataDir.replaceAll('"', '\\"')}"`], { stdio: 'ignore' });
137
156
  });
138
- const serverBoot = spawnSync(serverExtract.binaryPath, [], {
157
+ const serverBoot = runWithHardTimeout(serverExtract.binaryPath, [], {
139
158
  cwd: '/tmp',
140
159
  encoding: 'utf-8',
141
160
  env: {
@@ -150,7 +169,7 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
150
169
  },
151
170
  timeout: 7000,
152
171
  });
153
- const timedOut = serverBoot.error && serverBoot.error.code === 'ETIMEDOUT';
172
+ const timedOut = didCommandTimeout(serverBoot);
154
173
  const cleanExit = (serverBoot.status ?? 1) === 0;
155
174
  const serverOutput = `${serverBoot.stderr || ''}\n${serverBoot.stdout || ''}`;
156
175
  assert.ok(timedOut || cleanExit, serverOutput);