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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/docs/server-flavors.md +6 -6
  2. package/node_modules/@happier-dev/cli-common/dist/index.d.ts +2 -0
  3. package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -1
  4. package/node_modules/@happier-dev/cli-common/dist/index.js +2 -0
  5. package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -1
  6. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts +51 -0
  7. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts.map +1 -0
  8. package/node_modules/@happier-dev/cli-common/dist/providers/index.js +129 -0
  9. package/node_modules/@happier-dev/cli-common/dist/providers/index.js.map +1 -0
  10. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts +5 -0
  11. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts.map +1 -0
  12. package/node_modules/@happier-dev/cli-common/dist/service/index.js +5 -0
  13. package/node_modules/@happier-dev/cli-common/dist/service/index.js.map +1 -0
  14. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +19 -0
  15. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -0
  16. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +117 -0
  17. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -0
  18. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts +55 -0
  19. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts.map +1 -0
  20. package/node_modules/@happier-dev/cli-common/dist/service/manager.js +302 -0
  21. package/node_modules/@happier-dev/cli-common/dist/service/manager.js.map +1 -0
  22. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts +12 -0
  23. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts.map +1 -0
  24. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js +75 -0
  25. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js.map +1 -0
  26. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts +8 -0
  27. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts.map +1 -0
  28. package/node_modules/@happier-dev/cli-common/dist/service/windows.js +29 -0
  29. package/node_modules/@happier-dev/cli-common/dist/service/windows.js.map +1 -0
  30. package/node_modules/@happier-dev/cli-common/package.json +11 -0
  31. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts +22 -0
  32. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts.map +1 -0
  33. package/node_modules/@happier-dev/release-runtime/dist/assets.js +44 -0
  34. package/node_modules/@happier-dev/release-runtime/dist/assets.js.map +1 -0
  35. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts +5 -0
  36. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts.map +1 -0
  37. package/node_modules/@happier-dev/release-runtime/dist/checksums.js +21 -0
  38. package/node_modules/@happier-dev/release-runtime/dist/checksums.js.map +1 -0
  39. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts +14 -0
  40. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts.map +1 -0
  41. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js +39 -0
  42. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js.map +1 -0
  43. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts +20 -0
  44. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts.map +1 -0
  45. package/node_modules/@happier-dev/release-runtime/dist/github.js +60 -0
  46. package/node_modules/@happier-dev/release-runtime/dist/github.js.map +1 -0
  47. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts +7 -0
  48. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts.map +1 -0
  49. package/node_modules/@happier-dev/release-runtime/dist/index.js +7 -0
  50. package/node_modules/@happier-dev/release-runtime/dist/index.js.map +1 -0
  51. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts +7 -0
  52. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts.map +1 -0
  53. package/node_modules/@happier-dev/release-runtime/dist/minisign.js +92 -0
  54. package/node_modules/@happier-dev/release-runtime/dist/minisign.js.map +1 -0
  55. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts +26 -0
  56. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts.map +1 -0
  57. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js +53 -0
  58. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js.map +1 -0
  59. package/node_modules/@happier-dev/release-runtime/package.json +38 -0
  60. package/package.json +4 -2
  61. package/scripts/auth.mjs +3 -2
  62. package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +1 -0
  63. package/scripts/auth_copy_from_runCapture.integration.test.mjs +8 -1
  64. package/scripts/auth_login_guided_server_no_expo.test.mjs +2 -0
  65. package/scripts/build.mjs +3 -18
  66. package/scripts/bundleWorkspaceDeps.mjs +5 -1
  67. package/scripts/bundleWorkspaceDeps.test.mjs +42 -1
  68. package/scripts/mobile.mjs +30 -2
  69. package/scripts/mobile_dev_client.mjs +7 -32
  70. package/scripts/mobile_dev_client_help_smoke.test.mjs +24 -0
  71. package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
  72. package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
  73. package/scripts/mobile_run_ios_passes_port.integration.test.mjs +103 -0
  74. package/scripts/mobile_run_ios_uses_long_port_flag.test.mjs +106 -0
  75. package/scripts/providers_cmd.mjs +262 -0
  76. package/scripts/release_binary_smoke.integration.test.mjs +45 -37
  77. package/scripts/remote_cmd.mjs +352 -0
  78. package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
  79. package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
  80. package/scripts/self_host_runtime.mjs +1829 -327
  81. package/scripts/self_host_runtime.test.mjs +523 -1
  82. package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
  83. package/scripts/self_host_service_e2e_harness.mjs +93 -0
  84. package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
  85. package/scripts/service.mjs +156 -26
  86. package/scripts/stack/command_arguments.mjs +1 -0
  87. package/scripts/stack/help_text.mjs +3 -1
  88. package/scripts/stack.mjs +2 -1
  89. package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
  90. package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
  91. package/scripts/stack_pr_help_cmd.test.mjs +38 -0
  92. package/scripts/stop.mjs +2 -3
  93. package/scripts/utils/auth/credentials_paths.mjs +9 -9
  94. package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
  95. package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +64 -3
  96. package/scripts/utils/auth/stable_scope_id.mjs +1 -1
  97. package/scripts/utils/cli/cli_registry.mjs +21 -0
  98. package/scripts/utils/cli/progress.mjs +8 -1
  99. package/scripts/utils/cli/progress.test.mjs +43 -0
  100. package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
  101. package/scripts/utils/dev/expo_dev.mjs +35 -5
  102. package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
  103. package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
  104. package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
  105. package/scripts/utils/mobile/dev_client_install_invocation.mjs +68 -0
  106. package/scripts/utils/mobile/dev_client_install_invocation.test.mjs +27 -0
  107. package/scripts/utils/server/port.mjs +20 -2
  108. package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
  109. package/scripts/utils/service/service_manager.mjs +96 -0
  110. package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
  111. package/scripts/utils/service/service_manager.test.mjs +20 -0
  112. package/scripts/utils/service/systemd_service_unit.mjs +1 -0
  113. package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
  114. package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
  115. package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
  116. package/scripts/utils/ui/ui_export_env.mjs +29 -0
  117. package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
  118. package/scripts/worktrees.mjs +3 -0
  119. package/scripts/worktrees_status_default_target.test.mjs +56 -0
@@ -0,0 +1,217 @@
1
+ import assert from 'node:assert/strict';
2
+ import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import test from 'node:test';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const SELF_HOST_INSTALL_TIMEOUT_MS = 420_000;
9
+
10
+ import { commandExists, extractBinaryFromArtifact, reserveLocalhostPort, run, waitForHealth } from './self_host_service_e2e_harness.mjs';
11
+
12
+ function currentTarget() {
13
+ if (process.platform !== 'win32') return '';
14
+ if (process.arch === 'x64') return 'windows-x64';
15
+ return '';
16
+ }
17
+
18
+ function readTail(path) {
19
+ const escaped = String(path ?? '').replaceAll("'", "''");
20
+ return run(
21
+ 'powershell',
22
+ ['-NoProfile', '-Command', `Get-Content -LiteralPath '${escaped}' -Tail 200 -ErrorAction SilentlyContinue`],
23
+ { label: 'self-host-schtasks', allowFail: true, timeoutMs: 20_000 }
24
+ );
25
+ }
26
+
27
+ test(
28
+ 'compiled hstack self-host install/uninstall works on Windows schtasks host without repo checkout',
29
+ { timeout: 15 * 60_000 },
30
+ async (t) => {
31
+ if (process.platform !== 'win32') {
32
+ t.skip(`windows-only test (current: ${process.platform})`);
33
+ return;
34
+ }
35
+ const target = currentTarget();
36
+ if (!target) {
37
+ t.skip(`unsupported Windows runner architecture: ${process.arch}`);
38
+ return;
39
+ }
40
+ if (!commandExists('schtasks')) {
41
+ t.skip('schtasks is required');
42
+ return;
43
+ }
44
+ if (!commandExists('powershell')) {
45
+ t.skip('powershell is required');
46
+ return;
47
+ }
48
+ if (!commandExists('bun')) {
49
+ t.skip('bun is required to build compiled binaries');
50
+ return;
51
+ }
52
+
53
+ const repoRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)));
54
+ const version = `0.0.0-schtasks.${Date.now()}`;
55
+
56
+ run(
57
+ process.execPath,
58
+ [
59
+ 'scripts/release/build-hstack-binaries.mjs',
60
+ '--channel=preview',
61
+ `--version=${version}`,
62
+ `--targets=${target}`,
63
+ ],
64
+ {
65
+ label: 'self-host-schtasks',
66
+ cwd: repoRoot,
67
+ env: { ...process.env },
68
+ timeoutMs: 10 * 60_000,
69
+ }
70
+ );
71
+ run(
72
+ process.execPath,
73
+ [
74
+ 'scripts/release/build-server-binaries.mjs',
75
+ '--channel=preview',
76
+ `--version=${version}`,
77
+ `--targets=${target}`,
78
+ ],
79
+ {
80
+ label: 'self-host-schtasks',
81
+ cwd: repoRoot,
82
+ env: { ...process.env },
83
+ timeoutMs: 10 * 60_000,
84
+ }
85
+ );
86
+
87
+ const hstackArtifact = join(repoRoot, 'dist', 'release-assets', 'stack', `hstack-v${version}-${target}.tar.gz`);
88
+ const serverArtifact = join(repoRoot, 'dist', 'release-assets', 'server', `happier-server-v${version}-${target}.tar.gz`);
89
+
90
+ const extractedHstack = await extractBinaryFromArtifact({ label: 'self-host-schtasks', artifactPath: hstackArtifact, binaryName: 'hstack.exe' });
91
+ const extractedServer = await extractBinaryFromArtifact({ label: 'self-host-schtasks', artifactPath: serverArtifact, binaryName: 'happier-server.exe' });
92
+
93
+ t.after(async () => {
94
+ await rm(extractedHstack.extractDir, { recursive: true, force: true });
95
+ await rm(extractedServer.extractDir, { recursive: true, force: true });
96
+ });
97
+
98
+ const sandboxDir = await mkdtemp(join(tmpdir(), 'happier-self-host-schtasks-'));
99
+ t.after(async () => {
100
+ await rm(sandboxDir, { recursive: true, force: true });
101
+ });
102
+
103
+ const installRoot = join(sandboxDir, 'self-host');
104
+ const binDir = join(sandboxDir, 'bin');
105
+ const configDir = join(sandboxDir, 'config');
106
+ const dataDir = join(sandboxDir, 'data');
107
+ const logDir = join(sandboxDir, 'logs');
108
+ await mkdir(binDir, { recursive: true });
109
+
110
+ const hstackPath = join(binDir, 'hstack.exe');
111
+ await cp(extractedHstack.binaryPath, hstackPath);
112
+
113
+ const serviceName = `happier-server-e2e-${Date.now().toString(36).slice(-6)}`;
114
+ const serverPort = await reserveLocalhostPort();
115
+ const commonEnv = {
116
+ PATH: process.env.PATH ?? '',
117
+ HAPPIER_SELF_HOST_INSTALL_ROOT: installRoot,
118
+ HAPPIER_SELF_HOST_BIN_DIR: binDir,
119
+ HAPPIER_SELF_HOST_CONFIG_DIR: configDir,
120
+ HAPPIER_SELF_HOST_DATA_DIR: dataDir,
121
+ HAPPIER_SELF_HOST_LOG_DIR: logDir,
122
+ HAPPIER_SELF_HOST_SERVICE_NAME: serviceName,
123
+ HAPPIER_SELF_HOST_SERVER_BINARY: extractedServer.binaryPath,
124
+ HAPPIER_SELF_HOST_AUTO_UPDATE: '0',
125
+ HAPPIER_SELF_HOST_HEALTH_TIMEOUT_MS: '240000',
126
+ HAPPIER_NONINTERACTIVE: '1',
127
+ HAPPIER_WITH_CLI: '0',
128
+ HAPPIER_SERVER_PORT: String(serverPort),
129
+ HAPPIER_SERVER_HOST: '127.0.0.1',
130
+ };
131
+ const serverOutLog = join(logDir, 'server.out.log');
132
+ const serverErrLog = join(logDir, 'server.err.log');
133
+
134
+ const taskName = `Happier\\${serviceName}`;
135
+
136
+ let installSucceeded = false;
137
+ t.after(() => {
138
+ if (!installSucceeded) return;
139
+ run(
140
+ hstackPath,
141
+ ['self-host', 'uninstall', '--channel=preview', '--mode=user', '--yes', '--purge-data', '--json'],
142
+ {
143
+ env: commonEnv,
144
+ allowFail: true,
145
+ timeoutMs: 180_000,
146
+ stdio: 'ignore',
147
+ cwd: sandboxDir,
148
+ }
149
+ );
150
+ });
151
+
152
+ const installResult = run(
153
+ hstackPath,
154
+ ['self-host', 'install', '--channel=preview', '--mode=user', '--no-auto-update', '--non-interactive', '--without-cli', '--json'],
155
+ {
156
+ label: 'self-host-schtasks',
157
+ env: commonEnv,
158
+ timeoutMs: SELF_HOST_INSTALL_TIMEOUT_MS,
159
+ allowFail: true,
160
+ cwd: sandboxDir,
161
+ }
162
+ );
163
+ if ((installResult.status ?? 1) !== 0) {
164
+ const recoveredHealth = await waitForHealth(`http://127.0.0.1:${serverPort}/v1/version`, 120_000);
165
+ if (!recoveredHealth) {
166
+ const statusResult = run(
167
+ hstackPath,
168
+ ['self-host', 'status', '--channel=preview', '--mode=user', '--json'],
169
+ { label: 'self-host-schtasks', env: commonEnv, allowFail: true, timeoutMs: 60_000, cwd: sandboxDir }
170
+ );
171
+ const schtasksQuery = run('schtasks', ['/Query', '/TN', taskName, '/FO', 'LIST', '/V'], { label: 'self-host-schtasks', allowFail: true, timeoutMs: 20_000 });
172
+ const outTail = readTail(serverOutLog);
173
+ const errTail = readTail(serverErrLog);
174
+ throw new Error(
175
+ [
176
+ '[self-host-schtasks] self-host install failed and service never became healthy',
177
+ `install status: ${String(installResult.status ?? 'null')}`,
178
+ `install stdout:\n${String(installResult.stdout ?? '').trim()}`,
179
+ `install stderr:\n${String(installResult.stderr ?? '').trim()}`,
180
+ `self-host status:\n${String(statusResult.stdout ?? '').trim()}\n${String(statusResult.stderr ?? '').trim()}`,
181
+ `schtasks query (${taskName}):\n${String(schtasksQuery.stdout ?? '').trim()}\n${String(schtasksQuery.stderr ?? '').trim()}`,
182
+ `server out tail (${serverOutLog}):\n${String(outTail.stdout ?? '').trim()}\n${String(outTail.stderr ?? '').trim()}`,
183
+ `server err tail (${serverErrLog}):\n${String(errTail.stdout ?? '').trim()}\n${String(errTail.stderr ?? '').trim()}`,
184
+ ].join('\n\n')
185
+ );
186
+ }
187
+ }
188
+ installSucceeded = true;
189
+
190
+ const healthOk = await waitForHealth(`http://127.0.0.1:${serverPort}/v1/version`, 120_000);
191
+ assert.equal(healthOk, true, 'self-host service health endpoint did not become ready');
192
+
193
+ const status = run(
194
+ hstackPath,
195
+ ['self-host', 'status', '--channel=preview', '--mode=user', '--json'],
196
+ { label: 'self-host-schtasks', env: commonEnv, timeoutMs: 60_000, cwd: sandboxDir }
197
+ );
198
+ const statusPayload = JSON.parse(String(status.stdout ?? '').trim());
199
+ assert.equal(statusPayload?.ok, true);
200
+ assert.equal(statusPayload?.service?.name, serviceName);
201
+ assert.equal(statusPayload?.service?.active, true);
202
+ assert.equal(statusPayload?.healthy, true);
203
+
204
+ const schtasksQueryAfter = run('schtasks', ['/Query', '/TN', taskName], { label: 'self-host-schtasks', allowFail: true, timeoutMs: 20_000 });
205
+ assert.equal(schtasksQueryAfter.status, 0, 'schtasks query should succeed after install');
206
+
207
+ run(
208
+ hstackPath,
209
+ ['self-host', 'uninstall', '--channel=preview', '--mode=user', '--yes', '--purge-data', '--json'],
210
+ { label: 'self-host-schtasks', env: commonEnv, timeoutMs: 180_000, cwd: sandboxDir }
211
+ );
212
+ installSucceeded = false;
213
+
214
+ const schtasksAfterUninstall = run('schtasks', ['/Query', '/TN', taskName], { label: 'self-host-schtasks', allowFail: true, timeoutMs: 20_000 });
215
+ assert.notEqual(schtasksAfterUninstall.status, 0, 'scheduled task should not remain after uninstall');
216
+ }
217
+ );
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtemp, readdir } from 'node:fs/promises';
4
+ import { createServer } from 'node:net';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ const SELF_HOST_PORT_MIN = 20_000;
9
+ const SELF_HOST_PORT_MAX = 29_999;
10
+ const SELF_HOST_PORT_ATTEMPTS = 40;
11
+
12
+ export function commandExists(cmd) {
13
+ const name = String(cmd ?? '').trim();
14
+ if (!name) return false;
15
+ if (process.platform === 'win32') {
16
+ return spawnSync('where', [name], { stdio: 'ignore' }).status === 0;
17
+ }
18
+ return spawnSync('sh', ['-lc', `command -v ${name} >/dev/null 2>&1`], { stdio: 'ignore' }).status === 0;
19
+ }
20
+
21
+ export function run(cmd, args, { label, cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
22
+ const prefix = label ? `[${label}] ` : '';
23
+ const result = spawnSync(cmd, args, {
24
+ cwd,
25
+ env: env ?? process.env,
26
+ encoding: 'utf-8',
27
+ stdio,
28
+ timeout: timeoutMs || undefined,
29
+ });
30
+ const timedOut = result.error && result.error.code === 'ETIMEDOUT';
31
+ if (!allowFail && (timedOut || (result.status ?? 1) !== 0)) {
32
+ const stderr = String(result.stderr ?? '').trim();
33
+ const stdout = String(result.stdout ?? '').trim();
34
+ const reason = timedOut ? 'timed out' : `exited with status ${result.status}`;
35
+ throw new Error(`${prefix}${cmd} ${args.join(' ')} ${reason}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
36
+ }
37
+ return result;
38
+ }
39
+
40
+ export async function extractBinaryFromArtifact({ artifactPath, binaryName, label } = {}) {
41
+ const path = String(artifactPath ?? '').trim();
42
+ const name = String(binaryName ?? '').trim();
43
+ assert.ok(path, 'artifactPath is required');
44
+ assert.ok(name, 'binaryName is required');
45
+ const extractDir = await mkdtemp(join(tmpdir(), 'happier-self-host-artifact-'));
46
+ run('tar', ['-xzf', path, '-C', extractDir], { label, timeoutMs: 60_000 });
47
+ const roots = await readdir(extractDir);
48
+ assert.ok(roots.length > 0, `expected extracted root directory for ${path}`);
49
+ return {
50
+ extractDir,
51
+ binaryPath: join(extractDir, roots[0], name),
52
+ };
53
+ }
54
+
55
+ export async function waitForHealth(url, timeoutMs = 60_000) {
56
+ const started = Date.now();
57
+ while (Date.now() - started < timeoutMs) {
58
+ try {
59
+ const response = await fetch(String(url), {
60
+ headers: { accept: 'application/json' },
61
+ });
62
+ if (response.ok) {
63
+ const payload = await response.json().catch(() => ({}));
64
+ if (payload?.ok === true) return true;
65
+ }
66
+ } catch {
67
+ // keep polling until timeout
68
+ }
69
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, 1500));
70
+ }
71
+ return false;
72
+ }
73
+
74
+ async function canBindLocalhostPort(port) {
75
+ return await new Promise((resolvePort) => {
76
+ const server = createServer();
77
+ server.unref();
78
+ server.once('error', () => resolvePort(false));
79
+ server.listen(port, '127.0.0.1', () => {
80
+ server.close((error) => resolvePort(!error));
81
+ });
82
+ });
83
+ }
84
+
85
+ export async function reserveLocalhostPort() {
86
+ for (let attempt = 0; attempt < SELF_HOST_PORT_ATTEMPTS; attempt += 1) {
87
+ const candidate =
88
+ SELF_HOST_PORT_MIN + Math.floor(Math.random() * (SELF_HOST_PORT_MAX - SELF_HOST_PORT_MIN + 1));
89
+ if (await canBindLocalhostPort(candidate)) return candidate;
90
+ }
91
+ throw new Error('failed to reserve localhost port from non-ephemeral range');
92
+ }
93
+
@@ -1,38 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
- import { spawnSync } from 'node:child_process';
3
- import { chmod, cp, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
4
- import { createServer } from 'node:net';
2
+ import { chmod, cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
5
3
  import { tmpdir } from 'node:os';
6
4
  import { join, resolve } from 'node:path';
7
5
  import test from 'node:test';
8
6
  import { fileURLToPath } from 'node:url';
9
7
 
10
- const SELF_HOST_PORT_MIN = 20_000;
11
- const SELF_HOST_PORT_MAX = 29_999;
12
- const SELF_HOST_PORT_ATTEMPTS = 40;
13
8
  const SELF_HOST_INSTALL_TIMEOUT_MS = 420_000;
14
9
 
15
- function commandExists(cmd) {
16
- return spawnSync('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1`], { stdio: 'ignore' }).status === 0;
17
- }
18
-
19
- function run(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
20
- const result = spawnSync(cmd, args, {
21
- cwd,
22
- env: env ?? process.env,
23
- encoding: 'utf-8',
24
- stdio,
25
- timeout: timeoutMs || undefined,
26
- });
27
- const timedOut = result.error && result.error.code === 'ETIMEDOUT';
28
- if (!allowFail && (timedOut || (result.status ?? 1) !== 0)) {
29
- const stderr = String(result.stderr ?? '').trim();
30
- const stdout = String(result.stdout ?? '').trim();
31
- const reason = timedOut ? 'timed out' : `exited with status ${result.status}`;
32
- throw new Error(`[self-host-systemd] ${cmd} ${args.join(' ')} ${reason}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
33
- }
34
- return result;
35
- }
10
+ import { commandExists, extractBinaryFromArtifact, reserveLocalhostPort, run, waitForHealth } from './self_host_service_e2e_harness.mjs';
36
11
 
37
12
  function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
38
13
  const mergedEnv = {
@@ -40,13 +15,14 @@ function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdi
40
15
  ...(env ?? {}),
41
16
  };
42
17
  if (typeof process.getuid === 'function' && process.getuid() === 0) {
43
- return run(cmd, args, { cwd, env: mergedEnv, timeoutMs, allowFail, stdio });
18
+ return run(cmd, args, { label: 'self-host-systemd', cwd, env: mergedEnv, timeoutMs, allowFail, stdio });
44
19
  }
45
20
  if (!commandExists('sudo')) {
46
21
  throw new Error('[self-host-systemd] sudo is required for systemd self-host test');
47
22
  }
48
23
  const envArgs = Object.entries(mergedEnv).map(([key, value]) => `${key}=${String(value ?? '')}`);
49
24
  return run('sudo', ['-E', 'env', ...envArgs, cmd, ...args], {
25
+ label: 'self-host-systemd',
50
26
  cwd,
51
27
  env: process.env,
52
28
  timeoutMs,
@@ -55,62 +31,6 @@ function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdi
55
31
  });
56
32
  }
57
33
 
58
- async function extractBinaryFromArtifact({ artifactPath, binaryName }) {
59
- const extractDir = await mkdtemp(join(tmpdir(), 'happier-self-host-systemd-artifact-'));
60
- run('tar', ['-xzf', artifactPath, '-C', extractDir], { timeoutMs: 30_000 });
61
- const roots = await readdir(extractDir);
62
- assert.ok(roots.length > 0, `expected extracted root directory for ${artifactPath}`);
63
- return {
64
- extractDir,
65
- binaryPath: join(extractDir, roots[0], binaryName),
66
- };
67
- }
68
-
69
- async function waitForHealth(url, timeoutMs = 60_000) {
70
- const started = Date.now();
71
- while (Date.now() - started < timeoutMs) {
72
- try {
73
- const response = await fetch(url, {
74
- headers: { accept: 'application/json' },
75
- });
76
- if (response.ok) {
77
- const payload = await response.json().catch(() => ({}));
78
- if (payload?.ok === true) {
79
- return true;
80
- }
81
- }
82
- } catch {
83
- // keep polling until timeout
84
- }
85
- await new Promise((resolve) => setTimeout(resolve, 1500));
86
- }
87
- return false;
88
- }
89
-
90
- async function canBindLocalhostPort(port) {
91
- return await new Promise((resolvePort) => {
92
- const server = createServer();
93
- server.unref();
94
- server.once('error', () => resolvePort(false));
95
- server.listen(port, '127.0.0.1', () => {
96
- server.close((error) => {
97
- resolvePort(!error);
98
- });
99
- });
100
- });
101
- }
102
-
103
- async function reserveLocalhostPort() {
104
- for (let attempt = 0; attempt < SELF_HOST_PORT_ATTEMPTS; attempt += 1) {
105
- const candidate =
106
- SELF_HOST_PORT_MIN + Math.floor(Math.random() * (SELF_HOST_PORT_MAX - SELF_HOST_PORT_MIN + 1));
107
- if (await canBindLocalhostPort(candidate)) {
108
- return candidate;
109
- }
110
- }
111
- throw new Error('failed to reserve localhost port from non-ephemeral range');
112
- }
113
-
114
34
  test(
115
35
  'compiled hstack self-host install/uninstall works on systemd host without repo checkout',
116
36
  { timeout: 15 * 60_000 },
@@ -148,6 +68,7 @@ test(
148
68
  '--targets=linux-x64',
149
69
  ],
150
70
  {
71
+ label: 'self-host-systemd',
151
72
  cwd: repoRoot,
152
73
  env: { ...process.env },
153
74
  timeoutMs: 8 * 60_000,
@@ -162,6 +83,7 @@ test(
162
83
  '--targets=linux-x64',
163
84
  ],
164
85
  {
86
+ label: 'self-host-systemd',
165
87
  cwd: repoRoot,
166
88
  env: { ...process.env },
167
89
  timeoutMs: 8 * 60_000,
@@ -171,8 +93,8 @@ test(
171
93
  const hstackArtifact = join(repoRoot, 'dist', 'release-assets', 'stack', `hstack-v${version}-linux-x64.tar.gz`);
172
94
  const serverArtifact = join(repoRoot, 'dist', 'release-assets', 'server', `happier-server-v${version}-linux-x64.tar.gz`);
173
95
 
174
- const extractedHstack = await extractBinaryFromArtifact({ artifactPath: hstackArtifact, binaryName: 'hstack' });
175
- const extractedServer = await extractBinaryFromArtifact({ artifactPath: serverArtifact, binaryName: 'happier-server' });
96
+ const extractedHstack = await extractBinaryFromArtifact({ label: 'self-host-systemd', artifactPath: hstackArtifact, binaryName: 'hstack' });
97
+ const extractedServer = await extractBinaryFromArtifact({ label: 'self-host-systemd', artifactPath: serverArtifact, binaryName: 'happier-server' });
176
98
 
177
99
  t.after(async () => {
178
100
  await rm(extractedHstack.extractDir, { recursive: true, force: true });