@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
@@ -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';
@@ -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) {
@@ -244,6 +244,24 @@ export function gethstackRegistry() {
244
244
  rootUsage: 'hstack self-host install|status|update|rollback|uninstall [--json]',
245
245
  description: 'Happier Self-Host guided install and lifecycle',
246
246
  },
247
+ {
248
+ name: 'remote',
249
+ kind: 'node',
250
+ scriptRelPath: 'scripts/remote_cmd.mjs',
251
+ rootUsage:
252
+ 'hstack remote daemon setup --ssh <user@host> [--preview|--stable] [--channel <stable|preview>] [--service <user|none>] [--server-url=<url>] [--webapp-url=<url>] [--public-server-url=<url>] [--json]',
253
+ description: 'Remote setup helpers (SSH pairing + daemon setup)',
254
+ },
255
+ {
256
+ name: 'providers',
257
+ kind: 'node',
258
+ scriptRelPath: 'scripts/providers_cmd.mjs',
259
+ rootUsage: [
260
+ 'hstack providers list [--json]',
261
+ 'hstack providers install --providers=<id1,id2> [--dry-run] [--force] [--json]',
262
+ ],
263
+ description: 'Install and manage provider CLIs',
264
+ },
247
265
  {
248
266
  name: 'auth',
249
267
  kind: 'node',
@@ -27,6 +27,14 @@ function colorSpinner(frame) {
27
27
  }
28
28
 
29
29
  export function createStepPrinter({ enabled = true } = {}) {
30
+ if (!enabled) {
31
+ return {
32
+ start: () => {},
33
+ stop: () => {},
34
+ info: () => {},
35
+ };
36
+ }
37
+
30
38
  const tty = enabled && isTty();
31
39
  const frames = spinnerFrames();
32
40
  let timer = null;
@@ -138,4 +146,3 @@ export async function runCommandLogged({
138
146
  err.logPath = logPath;
139
147
  throw err;
140
148
  }
141
-
@@ -0,0 +1,43 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ import { runCommandLogged } from './progress.mjs';
8
+
9
+ test('runCommandLogged does not print progress when showSteps=false', async () => {
10
+ const dir = await mkdtemp(join(tmpdir(), 'happier-stack-progress-'));
11
+ const logPath = join(dir, 'log.txt');
12
+
13
+ const origWrite = process.stdout.write;
14
+ let stdout = '';
15
+ process.stdout.write = ((chunk, encoding, cb) => {
16
+ stdout += typeof chunk === 'string' ? chunk : chunk.toString(typeof encoding === 'string' ? encoding : 'utf8');
17
+ if (typeof cb === 'function') cb();
18
+ return true;
19
+ });
20
+
21
+ try {
22
+ const res = await runCommandLogged({
23
+ label: 'noop',
24
+ cmd: process.execPath,
25
+ args: ['-e', "process.stdout.write('hello')"],
26
+ cwd: process.cwd(),
27
+ env: process.env,
28
+ logPath,
29
+ showSteps: false,
30
+ quiet: true,
31
+ });
32
+
33
+ assert.equal(res.ok, true);
34
+ assert.equal(stdout, '');
35
+
36
+ const logged = await readFile(logPath, 'utf8');
37
+ assert.ok(logged.includes('hello'));
38
+ } finally {
39
+ process.stdout.write = origWrite;
40
+ await rm(dir, { recursive: true, force: true });
41
+ }
42
+ });
43
+
@@ -33,3 +33,20 @@ test('buildExpoDevEnv does not inject auth auto-restore env vars', () => {
33
33
  assert.equal(env.EXPO_PUBLIC_HAPPIER_STACK_DEV_AUTH_ENCRYPTION_MACHINE_KEY, undefined);
34
34
  });
35
35
 
36
+ test('buildExpoDevEnv forces stack context in stack mode even when base env sets a different context', () => {
37
+ const baseEnv = {
38
+ ...process.env,
39
+ EXPO_PUBLIC_HAPPY_SERVER_CONTEXT: 'custom',
40
+ };
41
+
42
+ const env = buildExpoDevEnv({
43
+ baseEnv,
44
+ apiServerUrl: 'http://localhost:3013',
45
+ wantDevClient: false,
46
+ wantWeb: true,
47
+ stackMode: true,
48
+ stackName: 'qa-agent-2',
49
+ });
50
+
51
+ assert.equal(env.EXPO_PUBLIC_HAPPY_SERVER_CONTEXT, 'stack');
52
+ });
@@ -10,7 +10,7 @@ import {
10
10
  writePidState,
11
11
  } from '../expo/expo.mjs';
12
12
  import { pickExpoDevMetroPort } from '../expo/metro_ports.mjs';
13
- import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
13
+ import { isPidAlive, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
14
14
  import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
15
15
  import { expoSpawn } from '../expo/command.mjs';
16
16
  import { resolveMobileExpoConfig } from '../mobile/config.mjs';
@@ -204,6 +204,10 @@ function expoModeLabel({ wantWeb, wantDevClient }) {
204
204
  return 'disabled';
205
205
  }
206
206
 
207
+ function normalizeApiServerUrl(raw) {
208
+ return String(raw ?? '').trim().replace(/\/+$/, '');
209
+ }
210
+
207
211
  export function buildExpoDevEnv({
208
212
  baseEnv,
209
213
  apiServerUrl,
@@ -229,7 +233,7 @@ export function buildExpoDevEnv({
229
233
  : apiServerUrl;
230
234
 
231
235
  env.EXPO_PUBLIC_HAPPY_SERVER_URL = effectiveApiServerUrl;
232
- if (stackMode && !env.EXPO_PUBLIC_HAPPY_SERVER_CONTEXT) {
236
+ if (stackMode) {
233
237
  env.EXPO_PUBLIC_HAPPY_SERVER_CONTEXT = 'stack';
234
238
  }
235
239
  env.EXPO_PUBLIC_DEBUG = env.EXPO_PUBLIC_DEBUG ?? '1';
@@ -314,6 +318,7 @@ export async function ensureDevExpoServer({
314
318
 
315
319
  const running = await isStateProcessRunning(paths.statePath);
316
320
  const alreadyRunning = Boolean(running.running);
321
+ const desiredApiServerUrl = normalizeApiServerUrl(apiServerUrl);
317
322
 
318
323
  // Resolve Tailscale forwarding preference
319
324
  const wantTailscale = resolveExpoTailscaleEnabled({ env: baseEnv, expoTailscale });
@@ -344,8 +349,25 @@ export async function ensureDevExpoServer({
344
349
  }).catch(() => {});
345
350
  };
346
351
 
347
- if (alreadyRunning && !restart) {
348
- const pid = Number(running.state?.pid);
352
+ const runningStateApiServerUrl = normalizeApiServerUrl(running.state?.apiServerUrl);
353
+ const shouldRestartForApiServerMismatch =
354
+ alreadyRunning &&
355
+ !restart &&
356
+ stackMode &&
357
+ wantWeb &&
358
+ desiredApiServerUrl &&
359
+ runningStateApiServerUrl !== desiredApiServerUrl;
360
+ // In stack mode, never adopt "running by port probe only" state. It may belong to a
361
+ // different stack/session and has no reliable owned pid for lifecycle control.
362
+ const shouldRestartForPortFallbackInStackMode =
363
+ alreadyRunning &&
364
+ !restart &&
365
+ stackMode &&
366
+ running.reason === 'port';
367
+
368
+ if (alreadyRunning && !restart && !shouldRestartForApiServerMismatch && !shouldRestartForPortFallbackInStackMode) {
369
+ const statePid = Number(running.state?.pid);
370
+ const pid = Number.isFinite(statePid) && statePid > 1 && isPidAlive(statePid) ? statePid : null;
349
371
  const port = Number(running.state?.port);
350
372
 
351
373
  // Capability check: refuse to spawn a second Expo, so if the existing process doesn't match the
@@ -369,12 +391,19 @@ export async function ensureDevExpoServer({
369
391
  ok: true,
370
392
  skipped: true,
371
393
  reason: 'already_running',
372
- pid: Number.isFinite(pid) ? pid : null,
394
+ pid: Number.isFinite(pid) && pid > 1 ? pid : null,
373
395
  port: Number.isFinite(port) ? port : null,
374
396
  mode: expoModeLabel({ wantWeb, wantDevClient }),
375
397
  };
376
398
  }
377
399
 
400
+ if (shouldRestartForApiServerMismatch && !quiet) {
401
+ // eslint-disable-next-line no-console
402
+ console.log(
403
+ `[local] expo: restarting to align API server URL (running=${runningStateApiServerUrl || 'unset'}, wanted=${desiredApiServerUrl}).`
404
+ );
405
+ }
406
+
378
407
  const reservedMetroPorts = new Set();
379
408
 
380
409
  if (restart && running.state?.pid) {
@@ -457,6 +486,7 @@ export async function ensureDevExpoServer({
457
486
  webEnabled: wantWeb,
458
487
  devClientEnabled: wantDevClient,
459
488
  host,
489
+ apiServerUrl: desiredApiServerUrl || null,
460
490
  scheme: wantDevClient ? scheme : null,
461
491
  tailscaleEnabled: wantTailscale,
462
492
  tailscaleForwarderPid: tailscaleResult?.pid ?? null,
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtemp, mkdir, rm, writeFile, chmod } from 'node:fs/promises';
3
+ import { mkdtemp, mkdir, rm, writeFile, chmod, readFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawn } from 'node:child_process';
@@ -42,6 +42,32 @@ function killProcessTreeByPid(pid) {
42
42
  }
43
43
  }
44
44
 
45
+ function listenMetroStatusServer() {
46
+ return new Promise((resolve, reject) => {
47
+ const server = net.createServer((socket) => {
48
+ socket.once('data', () => {
49
+ socket.write(
50
+ 'HTTP/1.1 200 OK\r\n' +
51
+ 'Content-Type: text/plain\r\n' +
52
+ 'Content-Length: 23\r\n' +
53
+ '\r\n' +
54
+ 'packager-status:running'
55
+ );
56
+ socket.end();
57
+ });
58
+ });
59
+ server.on('error', reject);
60
+ server.listen(0, '127.0.0.1', () => {
61
+ const addr = server.address();
62
+ if (!addr || typeof addr === 'string') {
63
+ server.close(() => reject(new Error('failed to resolve metro status server port')));
64
+ return;
65
+ }
66
+ resolve({ server, port: Number(addr.port) });
67
+ });
68
+ });
69
+ }
70
+
45
71
  test('ensureDevExpoServer reserves prior metro port when restart cannot kill previous pid', async () => {
46
72
  const tmp = await mkdtemp(join(tmpdir(), 'hstack-expo-reserve-port-'));
47
73
  const children = [];
@@ -118,3 +144,156 @@ test('ensureDevExpoServer reserves prior metro port when restart cannot kill pre
118
144
  await rm(tmp, { recursive: true, force: true });
119
145
  }
120
146
  });
147
+
148
+ test('ensureDevExpoServer restarts when running Expo state targets a different API server URL', async () => {
149
+ const tmp = await mkdtemp(join(tmpdir(), 'hstack-expo-restart-api-url-'));
150
+ const children = [];
151
+ let metro = null;
152
+ try {
153
+ const uiDir = join(tmp, 'ui');
154
+ await mkdir(join(uiDir, 'node_modules', '.bin'), { recursive: true });
155
+ await mkdir(join(uiDir, 'node_modules'), { recursive: true });
156
+ await writeFile(join(uiDir, 'package.json'), JSON.stringify({ name: 'fake-ui', private: true }) + '\n', 'utf-8');
157
+
158
+ const expoBin = join(uiDir, 'node_modules', '.bin', 'expo');
159
+ await writeFile(
160
+ expoBin,
161
+ [
162
+ '#!/usr/bin/env node',
163
+ "setInterval(() => {}, 1000);",
164
+ ].join('\n') + '\n',
165
+ 'utf-8'
166
+ );
167
+ await chmod(expoBin, 0o755);
168
+
169
+ const status = await listenMetroStatusServer();
170
+ metro = status.server;
171
+ const priorPort = status.port;
172
+
173
+ const projectDir = uiDir;
174
+ const paths = getExpoStatePaths({
175
+ baseDir: tmp,
176
+ kind: 'expo-dev',
177
+ projectDir,
178
+ stateFileName: 'expo.state.json',
179
+ });
180
+ await writePidState(paths.statePath, {
181
+ pid: 999999,
182
+ port: priorPort,
183
+ uiDir,
184
+ projectDir,
185
+ startedAt: new Date().toISOString(),
186
+ webEnabled: true,
187
+ devClientEnabled: false,
188
+ host: 'lan',
189
+ apiServerUrl: 'http://localhost:3012',
190
+ });
191
+
192
+ const result = await ensureDevExpoServer({
193
+ startUi: true,
194
+ startMobile: false,
195
+ uiDir,
196
+ autostart: { baseDir: tmp },
197
+ baseEnv: {
198
+ ...process.env,
199
+ HAPPIER_STACK_EXPO_DEV_PORT: String(priorPort),
200
+ },
201
+ apiServerUrl: 'http://localhost:3014',
202
+ restart: false,
203
+ stackMode: true,
204
+ runtimeStatePath: null,
205
+ stackName: 'qa-agent-1',
206
+ envPath: join(tmp, 'stack.env'),
207
+ children,
208
+ quiet: true,
209
+ });
210
+
211
+ assert.equal(result.ok, true);
212
+ assert.equal(result.skipped, false);
213
+ assert.notEqual(result.port, priorPort);
214
+
215
+ const nextState = JSON.parse(await readFile(paths.statePath, 'utf-8'));
216
+ assert.equal(nextState.apiServerUrl, 'http://localhost:3014');
217
+ } finally {
218
+ for (const child of children) {
219
+ killProcessTreeByPid(child?.pid);
220
+ }
221
+ await new Promise((resolve) => metro?.close(() => resolve())).catch(() => {});
222
+ await rm(tmp, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ test('ensureDevExpoServer in stack mode does not adopt port-only fallback as already running', async () => {
227
+ const tmp = await mkdtemp(join(tmpdir(), 'hstack-expo-port-fallback-stack-mode-'));
228
+ const children = [];
229
+ let metro = null;
230
+ try {
231
+ const uiDir = join(tmp, 'ui');
232
+ await mkdir(join(uiDir, 'node_modules', '.bin'), { recursive: true });
233
+ await mkdir(join(uiDir, 'node_modules'), { recursive: true });
234
+ await writeFile(join(uiDir, 'package.json'), JSON.stringify({ name: 'fake-ui', private: true }) + '\n', 'utf-8');
235
+
236
+ const expoBin = join(uiDir, 'node_modules', '.bin', 'expo');
237
+ await writeFile(
238
+ expoBin,
239
+ [
240
+ '#!/usr/bin/env node',
241
+ "setInterval(() => {}, 1000);",
242
+ ].join('\n') + '\n',
243
+ 'utf-8'
244
+ );
245
+ await chmod(expoBin, 0o755);
246
+
247
+ const status = await listenMetroStatusServer();
248
+ metro = status.server;
249
+ const priorPort = status.port;
250
+
251
+ const projectDir = uiDir;
252
+ const paths = getExpoStatePaths({
253
+ baseDir: tmp,
254
+ kind: 'expo-dev',
255
+ projectDir,
256
+ stateFileName: 'expo.state.json',
257
+ });
258
+ await writePidState(paths.statePath, {
259
+ pid: 999999,
260
+ port: priorPort,
261
+ uiDir,
262
+ projectDir,
263
+ startedAt: new Date().toISOString(),
264
+ webEnabled: true,
265
+ devClientEnabled: false,
266
+ host: 'lan',
267
+ apiServerUrl: 'http://localhost:3014',
268
+ });
269
+
270
+ const result = await ensureDevExpoServer({
271
+ startUi: true,
272
+ startMobile: false,
273
+ uiDir,
274
+ autostart: { baseDir: tmp },
275
+ baseEnv: {
276
+ ...process.env,
277
+ HAPPIER_STACK_EXPO_DEV_PORT: String(priorPort),
278
+ },
279
+ apiServerUrl: 'http://localhost:3014',
280
+ restart: false,
281
+ stackMode: true,
282
+ runtimeStatePath: null,
283
+ stackName: 'qa-agent-4',
284
+ envPath: join(tmp, 'stack.env'),
285
+ children,
286
+ quiet: true,
287
+ });
288
+
289
+ assert.equal(result.ok, true);
290
+ assert.equal(result.skipped, false);
291
+ assert.notEqual(result.port, priorPort);
292
+ } finally {
293
+ for (const child of children) {
294
+ killProcessTreeByPid(child?.pid);
295
+ }
296
+ await new Promise((resolve) => metro?.close(() => resolve())).catch(() => {});
297
+ await rm(tmp, { recursive: true, force: true });
298
+ }
299
+ });
@@ -0,0 +1,126 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { mkdtemp, mkdir, rm, writeFile, chmod } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ import { ensureDevExpoServer } from './expo_dev.mjs';
9
+ import { getExpoStatePaths, writePidState } from '../expo/expo.mjs';
10
+ import { readStackRuntimeStateFile } from '../stack/runtime_state.mjs';
11
+
12
+ function listen(server) {
13
+ return new Promise((resolve, reject) => {
14
+ server.on('error', reject);
15
+ server.listen(0, '127.0.0.1', () => resolve());
16
+ });
17
+ }
18
+
19
+ function close(server) {
20
+ return new Promise((resolve) => server.close(() => resolve()));
21
+ }
22
+
23
+ function killProcessTreeByPid(pid) {
24
+ const n = Number(pid);
25
+ if (!Number.isFinite(n) || n <= 1) return;
26
+ try {
27
+ process.kill(-n, 'SIGKILL');
28
+ } catch {
29
+ try {
30
+ process.kill(n, 'SIGKILL');
31
+ } catch {
32
+ // ignore
33
+ }
34
+ }
35
+ }
36
+
37
+ test('ensureDevExpoServer in stack mode starts managed Expo instead of adopting port-fallback state', async () => {
38
+ const tmp = await mkdtemp(join(tmpdir(), 'hstack-expo-runtime-meta-'));
39
+ const uiDir = join(tmp, 'ui');
40
+ const projectDir = uiDir;
41
+ const runtimeStatePath = join(tmp, 'stack.runtime.json');
42
+ const envPath = join(tmp, 'stack.env');
43
+ const children = [];
44
+ const metro = http.createServer((req, res) => {
45
+ if (req.url === '/status') {
46
+ res.writeHead(200, { 'content-type': 'text/plain' });
47
+ res.end('packager-status:running');
48
+ return;
49
+ }
50
+ res.writeHead(200, { 'content-type': 'text/plain' });
51
+ res.end('ok');
52
+ });
53
+
54
+ try {
55
+ await mkdir(join(uiDir, 'node_modules', '.bin'), { recursive: true });
56
+ await mkdir(join(uiDir, 'node_modules'), { recursive: true });
57
+ await writeFile(join(uiDir, 'package.json'), JSON.stringify({ name: 'fake-ui', private: true }) + '\n', 'utf-8');
58
+ const expoBin = join(uiDir, 'node_modules', '.bin', 'expo');
59
+ await writeFile(
60
+ expoBin,
61
+ [
62
+ '#!/usr/bin/env node',
63
+ "setInterval(() => {}, 1000);",
64
+ ].join('\n') + '\n',
65
+ 'utf-8'
66
+ );
67
+ await chmod(expoBin, 0o755);
68
+
69
+ await listen(metro);
70
+ const addr = metro.address();
71
+ assert.ok(addr && typeof addr === 'object' && typeof addr.port === 'number');
72
+ const metroPort = addr.port;
73
+
74
+ const paths = getExpoStatePaths({
75
+ baseDir: tmp,
76
+ kind: 'expo-dev',
77
+ projectDir,
78
+ stateFileName: 'expo.state.json',
79
+ });
80
+ await writePidState(paths.statePath, {
81
+ pid: 999999, // intentionally stale
82
+ port: metroPort,
83
+ uiDir,
84
+ projectDir,
85
+ startedAt: new Date().toISOString(),
86
+ webEnabled: true,
87
+ devClientEnabled: false,
88
+ host: 'lan',
89
+ apiServerUrl: 'http://127.0.0.1:3009',
90
+ });
91
+
92
+ const result = await ensureDevExpoServer({
93
+ startUi: true,
94
+ startMobile: false,
95
+ uiDir,
96
+ expoProjectDir: projectDir,
97
+ autostart: { baseDir: tmp },
98
+ baseEnv: { ...process.env },
99
+ apiServerUrl: 'http://127.0.0.1:3009',
100
+ restart: false,
101
+ stackMode: true,
102
+ runtimeStatePath,
103
+ stackName: 'qa-agent-4',
104
+ envPath,
105
+ children,
106
+ quiet: true,
107
+ });
108
+
109
+ assert.equal(result.ok, true);
110
+ assert.equal(result.skipped, false);
111
+ assert.equal(Number.isFinite(Number(result.pid)) && Number(result.pid) > 1, true);
112
+ assert.notEqual(result.port, metroPort);
113
+
114
+ const runtime = await readStackRuntimeStateFile(runtimeStatePath);
115
+ assert.ok(runtime && typeof runtime === 'object');
116
+ assert.equal(Number.isFinite(Number(runtime?.processes?.expoPid)) && Number(runtime?.processes?.expoPid) > 1, true);
117
+ assert.equal(runtime?.expo?.webPort, result.port);
118
+ assert.notEqual(runtime?.expo?.webPort, metroPort);
119
+ } finally {
120
+ for (const child of children) {
121
+ killProcessTreeByPid(child?.pid);
122
+ }
123
+ await close(metro).catch(() => {});
124
+ await rm(tmp, { recursive: true, force: true });
125
+ }
126
+ });
@@ -51,8 +51,15 @@ test('ensureDevExpoServer does not drop Expo output when spawnOptions stdio is i
51
51
  quiet: true,
52
52
  });
53
53
 
54
- await delay(400);
55
- const log = await readFile(teeFile, 'utf-8').catch(() => '');
54
+ // CI and local stacks can be noisy/slow (Corepack/Yarn probes, filesystem contention),
55
+ // so wait deterministically for the tee output instead of using a fixed delay.
56
+ const deadlineMs = Date.now() + 3000;
57
+ let log = '';
58
+ while (Date.now() < deadlineMs) {
59
+ log = await readFile(teeFile, 'utf-8').catch(() => '');
60
+ if (/hello-from-fake-expo/.test(log)) break;
61
+ await delay(100);
62
+ }
56
63
  assert.match(log, /hello-from-fake-expo/);
57
64
  } finally {
58
65
  await rm(tmp, { recursive: true, force: true });
@@ -26,8 +26,26 @@ export function coercePort(v) {
26
26
 
27
27
  export function resolveServerPortFromEnv({ env = process.env, defaultPort = 3005 } = {}) {
28
28
  const raw = (env.HAPPIER_STACK_SERVER_PORT ?? '').toString().trim() || '';
29
- const n = raw ? Number(raw) : Number(defaultPort);
30
- return Number.isFinite(n) && n > 0 ? n : Number(defaultPort);
29
+ const explicitPort = raw ? Number(raw) : NaN;
30
+ if (Number.isFinite(explicitPort) && explicitPort > 0) {
31
+ return explicitPort;
32
+ }
33
+
34
+ const serverUrlRaw = (env.HAPPIER_SERVER_URL ?? '').toString().trim();
35
+ if (serverUrlRaw) {
36
+ try {
37
+ const parsed = new URL(serverUrlRaw);
38
+ const urlPort = Number(parsed.port);
39
+ if (Number.isFinite(urlPort) && urlPort > 0) {
40
+ return urlPort;
41
+ }
42
+ } catch {
43
+ // Ignore invalid URL and use the default fallback below.
44
+ }
45
+ }
46
+
47
+ const fallback = Number(defaultPort);
48
+ return Number.isFinite(fallback) && fallback > 0 ? fallback : 3005;
31
49
  }
32
50
 
33
51
  export function listPortsFromEnvObject(env, keys) {