@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.
- package/docs/server-flavors.md +6 -6
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts +2 -0
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -1
- package/node_modules/@happier-dev/cli-common/dist/index.js +2 -0
- package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -1
- package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts +51 -0
- package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/providers/index.js +129 -0
- package/node_modules/@happier-dev/cli-common/dist/providers/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts +5 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.js +5 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +19 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +117 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts +55 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.js +302 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts +12 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.js +75 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts +8 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.js +29 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/package.json +11 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts +22 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.js +44 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts +5 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.js +21 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts +14 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js +39 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.d.ts +20 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.js +60 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.d.ts +7 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.js +7 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts +7 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.js +92 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts +26 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js +53 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/package.json +38 -0
- package/package.json +4 -2
- package/scripts/auth.mjs +3 -2
- package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +1 -0
- package/scripts/auth_copy_from_runCapture.integration.test.mjs +8 -1
- package/scripts/auth_login_guided_server_no_expo.test.mjs +2 -0
- package/scripts/build.mjs +3 -18
- package/scripts/bundleWorkspaceDeps.mjs +5 -1
- package/scripts/bundleWorkspaceDeps.test.mjs +42 -1
- package/scripts/mobile.mjs +30 -2
- package/scripts/mobile_dev_client.mjs +7 -32
- package/scripts/mobile_dev_client_help_smoke.test.mjs +24 -0
- package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
- package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
- package/scripts/mobile_run_ios_passes_port.integration.test.mjs +103 -0
- package/scripts/mobile_run_ios_uses_long_port_flag.test.mjs +106 -0
- package/scripts/providers_cmd.mjs +262 -0
- package/scripts/release_binary_smoke.integration.test.mjs +45 -37
- package/scripts/remote_cmd.mjs +352 -0
- package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
- package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
- package/scripts/self_host_runtime.mjs +1829 -327
- package/scripts/self_host_runtime.test.mjs +523 -1
- package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
- package/scripts/self_host_service_e2e_harness.mjs +93 -0
- package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
- package/scripts/service.mjs +156 -26
- package/scripts/stack/command_arguments.mjs +1 -0
- package/scripts/stack/help_text.mjs +3 -1
- package/scripts/stack.mjs +2 -1
- package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
- package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
- package/scripts/stack_pr_help_cmd.test.mjs +38 -0
- package/scripts/stop.mjs +2 -3
- package/scripts/utils/auth/credentials_paths.mjs +9 -9
- package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
- package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +64 -3
- package/scripts/utils/auth/stable_scope_id.mjs +1 -1
- package/scripts/utils/cli/cli_registry.mjs +21 -0
- package/scripts/utils/cli/progress.mjs +8 -1
- package/scripts/utils/cli/progress.test.mjs +43 -0
- package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
- package/scripts/utils/dev/expo_dev.mjs +35 -5
- package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
- package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
- package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
- package/scripts/utils/mobile/dev_client_install_invocation.mjs +68 -0
- package/scripts/utils/mobile/dev_client_install_invocation.test.mjs +27 -0
- package/scripts/utils/server/port.mjs +20 -2
- package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
- package/scripts/utils/service/service_manager.mjs +96 -0
- package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
- package/scripts/utils/service/service_manager.test.mjs +20 -0
- package/scripts/utils/service/systemd_service_unit.mjs +1 -0
- package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
- package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
- package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
- package/scripts/utils/ui/ui_export_env.mjs +29 -0
- package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
- package/scripts/worktrees.mjs +3 -0
- package/scripts/worktrees_status_default_target.test.mjs +56 -0
|
@@ -244,6 +244,27 @@ 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
|
+
[
|
|
253
|
+
'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]',
|
|
254
|
+
'hstack remote server setup --ssh <user@host> [--preview|--stable] [--channel <stable|preview>] [--mode <user|system>] [--env KEY=VALUE]... [--json]',
|
|
255
|
+
],
|
|
256
|
+
description: 'Remote setup helpers (SSH daemon/server setup)',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'providers',
|
|
260
|
+
kind: 'node',
|
|
261
|
+
scriptRelPath: 'scripts/providers_cmd.mjs',
|
|
262
|
+
rootUsage: [
|
|
263
|
+
'hstack providers list [--json]',
|
|
264
|
+
'hstack providers install --providers=<id1,id2> [--dry-run] [--force] [--json]',
|
|
265
|
+
],
|
|
266
|
+
description: 'Install and manage provider CLIs',
|
|
267
|
+
},
|
|
247
268
|
{
|
|
248
269
|
name: 'auth',
|
|
249
270
|
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
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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 });
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from '../cli/args.mjs';
|
|
4
|
+
import { defaultDevClientIdentity } from './identifiers.mjs';
|
|
5
|
+
|
|
6
|
+
function normalizePortArg(raw) {
|
|
7
|
+
const s = String(raw ?? '').trim();
|
|
8
|
+
if (!s) return '';
|
|
9
|
+
const n = Number(s);
|
|
10
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
11
|
+
return String(Math.floor(n));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildMobileDevClientInstallInvocation({
|
|
15
|
+
rootDir,
|
|
16
|
+
argv,
|
|
17
|
+
baseEnv = process.env,
|
|
18
|
+
} = {}) {
|
|
19
|
+
const r = String(rootDir ?? '').trim();
|
|
20
|
+
if (!r) {
|
|
21
|
+
throw new Error('[mobile-dev-client] missing rootDir');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const a = Array.isArray(argv) ? argv : [];
|
|
25
|
+
const { flags, kv } = parseArgs(a);
|
|
26
|
+
|
|
27
|
+
const device = (kv.get('--device') ?? '').toString();
|
|
28
|
+
const clean = flags.has('--clean');
|
|
29
|
+
const configuration = (kv.get('--configuration') ?? 'Debug').toString() || 'Debug';
|
|
30
|
+
const port = normalizePortArg(kv.get('--port'));
|
|
31
|
+
|
|
32
|
+
const user = (baseEnv.USER ?? baseEnv.USERNAME ?? 'user').toString();
|
|
33
|
+
const identity = defaultDevClientIdentity({ user });
|
|
34
|
+
|
|
35
|
+
const mobileScript = join(r, 'scripts', 'mobile.mjs');
|
|
36
|
+
|
|
37
|
+
const nodeArgs = [
|
|
38
|
+
mobileScript,
|
|
39
|
+
'--app-env=development',
|
|
40
|
+
`--ios-app-name=${identity.iosAppName}`,
|
|
41
|
+
`--ios-bundle-id=${identity.iosBundleId}`,
|
|
42
|
+
`--scheme=${identity.scheme}`,
|
|
43
|
+
...(port ? [`--port=${port}`] : []),
|
|
44
|
+
'--prebuild',
|
|
45
|
+
...(clean ? ['--clean'] : []),
|
|
46
|
+
'--run-ios',
|
|
47
|
+
`--configuration=${configuration}`,
|
|
48
|
+
'--no-metro',
|
|
49
|
+
...(device ? [`--device=${device}`] : []),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const env = {
|
|
53
|
+
...baseEnv,
|
|
54
|
+
EXPO_APP_SCHEME: identity.scheme,
|
|
55
|
+
EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: baseEnv.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ?? '',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
nodeArgs,
|
|
60
|
+
env,
|
|
61
|
+
identity,
|
|
62
|
+
device,
|
|
63
|
+
clean,
|
|
64
|
+
configuration,
|
|
65
|
+
port,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { buildMobileDevClientInstallInvocation } from './dev_client_install_invocation.mjs';
|
|
5
|
+
|
|
6
|
+
test('buildMobileDevClientInstallInvocation forwards --port to mobile.mjs args', async () => {
|
|
7
|
+
const invocation = buildMobileDevClientInstallInvocation({
|
|
8
|
+
rootDir: '/repo/apps/stack',
|
|
9
|
+
argv: ['--install', '--port=14362'],
|
|
10
|
+
baseEnv: { USER: 'leeroy' },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
assert.ok(Array.isArray(invocation.nodeArgs), 'expected nodeArgs array');
|
|
14
|
+
assert.ok(invocation.nodeArgs.includes('--port=14362'), 'expected --port to be forwarded to mobile.mjs');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('buildMobileDevClientInstallInvocation omits --port when not provided', async () => {
|
|
18
|
+
const invocation = buildMobileDevClientInstallInvocation({
|
|
19
|
+
rootDir: '/repo/apps/stack',
|
|
20
|
+
argv: ['--install'],
|
|
21
|
+
baseEnv: { USER: 'leeroy' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.ok(Array.isArray(invocation.nodeArgs), 'expected nodeArgs array');
|
|
25
|
+
assert.ok(!invocation.nodeArgs.some((a) => String(a).startsWith('--port=')), 'expected no --port arg by default');
|
|
26
|
+
});
|
|
27
|
+
|
|
@@ -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
|
|
30
|
-
|
|
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) {
|