@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,262 @@
1
+ import './utils/env/env.mjs';
2
+
3
+ import { parseArgs } from './utils/cli/args.mjs';
4
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
5
+ import { createStepPrinter, runCommandLogged } from './utils/cli/progress.mjs';
6
+ import { AGENT_IDS, getProviderCliInstallSpec } from '@happier-dev/agents';
7
+ import { installProviderCli, planProviderCliInstall, resolvePlatformFromNodePlatform } from '@happier-dev/cli-common/providers';
8
+ import { mkdirSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+
13
+ function usageText() {
14
+ return [
15
+ '[providers] usage:',
16
+ ' hstack providers list [--json]',
17
+ ' hstack providers install --providers=<id1,id2> [--dry-run] [--force] [--json]',
18
+ ' hstack providers install <id1> <id2> [--dry-run] [--force] [--json]',
19
+ '',
20
+ 'notes:',
21
+ ' - Provider CLIs are external binaries used by Happier backends (claude/codex/gemini/etc).',
22
+ ' - This command installs provider CLIs (best-effort). Some providers require manual installation.',
23
+ ' - Claude install uses the upstream native installer by default (not npm).',
24
+ ' - Use --force to re-run the installer even if the binary is already present on PATH.',
25
+ ].join('\n');
26
+ }
27
+
28
+ function splitProviders(raw) {
29
+ const v = String(raw ?? '').trim();
30
+ if (!v) return [];
31
+ return v
32
+ .split(',')
33
+ .map((s) => s.trim().toLowerCase())
34
+ .filter(Boolean);
35
+ }
36
+
37
+ function resolvePlatform() {
38
+ return resolvePlatformFromNodePlatform(process.platform) ?? 'unsupported';
39
+ }
40
+
41
+ function commandExists(cmd, env) {
42
+ const name = String(cmd ?? '').trim();
43
+ if (!name) return false;
44
+
45
+ const pathEnv = env?.PATH ?? process.env.PATH;
46
+ if (process.platform === 'win32') {
47
+ const res = spawnSync('where', [name], { stdio: 'ignore', env: { ...process.env, ...(env ?? {}), PATH: pathEnv } });
48
+ return (res.status ?? 1) === 0;
49
+ }
50
+ const res = spawnSync('sh', ['-lc', `command -v ${name} >/dev/null 2>&1`], { stdio: 'ignore', env: { ...process.env, ...(env ?? {}), PATH: pathEnv } });
51
+ return (res.status ?? 1) === 0;
52
+ }
53
+
54
+ function resolveProviderInstallLogPath(providerId) {
55
+ const base = join(tmpdir(), 'happier-provider-installs');
56
+ mkdirSync(base, { recursive: true });
57
+ return join(base, `install-provider-${providerId}-${Date.now()}.log`);
58
+ }
59
+
60
+ function planForProvider(providerId) {
61
+ const platform = resolvePlatform();
62
+ if (platform === 'unsupported') {
63
+ return { ok: false, provider: providerId, error: 'Unsupported platform' };
64
+ }
65
+ const planned = planProviderCliInstall({ providerId, platform });
66
+ if (!planned.ok) {
67
+ return { ok: false, provider: providerId, error: planned.errorMessage };
68
+ }
69
+ return { ok: true, provider: providerId, commands: planned.plan.commands };
70
+ }
71
+
72
+ async function cmdList({ argv }) {
73
+ const { flags } = parseArgs(argv);
74
+ const json = wantsJson(argv, { flags });
75
+ const platform = resolvePlatform();
76
+ const rows = AGENT_IDS.map((id) => {
77
+ const spec = getProviderCliInstallSpec(id);
78
+ const planned = planForProvider(id);
79
+ return {
80
+ id: spec.id,
81
+ title: spec.title,
82
+ binaries: spec.binaries,
83
+ autoInstall: planned.ok,
84
+ note: planned.ok ? null : planned.error,
85
+ platform,
86
+ };
87
+ });
88
+
89
+ printResult({
90
+ json,
91
+ data: { ok: true, platform, providers: rows },
92
+ text: json
93
+ ? null
94
+ : rows
95
+ .map((r) => `${r.autoInstall ? '✓' : '-'} ${r.id}${r.title ? ` (${r.title})` : ''}${r.note ? ` — ${r.note}` : ''}`)
96
+ .join('\n'),
97
+ });
98
+ }
99
+
100
+ async function cmdInstall({ argv }) {
101
+ const { flags, kv } = parseArgs(argv);
102
+ const json = wantsJson(argv, { flags });
103
+ const dryRun = flags.has('--dry-run') || flags.has('--plan');
104
+ const force = flags.has('--force') || flags.has('--reinstall');
105
+ const skipIfInstalled = !force;
106
+
107
+ const positionals = argv.filter((a) => a && a !== '--' && !a.startsWith('-'));
108
+ const inputFromFlag = kv.get('--providers') ?? '';
109
+ const inputFromPositional = positionals;
110
+
111
+ const wanted = [
112
+ ...splitProviders(inputFromFlag),
113
+ ...inputFromPositional.flatMap((s) => splitProviders(String(s).trim().toLowerCase())),
114
+ ];
115
+
116
+ if (wanted.length === 0) {
117
+ throw new Error('[providers] missing providers. Use --providers=claude,codex or pass ids as positionals.');
118
+ }
119
+
120
+ const resolved = wanted.map((id) => {
121
+ if (!AGENT_IDS.includes(id)) {
122
+ const e = new Error(`[providers] unknown provider: ${id}`);
123
+ e.code = 'EUNKNOWN_PROVIDER';
124
+ throw e;
125
+ }
126
+ return id;
127
+ });
128
+
129
+ const platform = resolvePlatform();
130
+ if (platform === 'unsupported') {
131
+ throw new Error('[providers] unsupported platform');
132
+ }
133
+
134
+ // In json mode, preserve the existing structured behavior (no progress output).
135
+ if (json) {
136
+ const results = resolved.map((providerId) =>
137
+ installProviderCli({ providerId, platform, dryRun, skipIfInstalled, env: process.env }),
138
+ );
139
+ const failures = results.filter((r) => !r.ok);
140
+ if (failures.length > 0) {
141
+ const first = failures[0];
142
+ const extra = first.logPath ? `\nlog: ${first.logPath}` : '';
143
+ throw new Error(`[providers] install failed: ${first.errorMessage}${extra}`.trim());
144
+ }
145
+
146
+ const plan = results.map((r) => (r.ok ? r.plan : null)).filter(Boolean);
147
+
148
+ printResult({
149
+ json,
150
+ data: {
151
+ ok: true,
152
+ providers: resolved,
153
+ dryRun,
154
+ skipIfInstalled,
155
+ plan,
156
+ results: results.map((r) => (r.ok ? { ok: true, providerId: r.plan.providerId, alreadyInstalled: r.alreadyInstalled, logPath: r.logPath } : r)),
157
+ },
158
+ text: null,
159
+ });
160
+ return;
161
+ }
162
+
163
+ // Human-friendly progress output (TTY spinner when interactive; simple lines otherwise).
164
+ const steps = createStepPrinter({ enabled: true });
165
+ const results = [];
166
+ for (const providerId of resolved) {
167
+ const spec = getProviderCliInstallSpec(providerId);
168
+ const planned = planProviderCliInstall({ providerId, platform });
169
+ if (!planned.ok) {
170
+ throw new Error(`[providers] install failed: ${planned.errorMessage}`);
171
+ }
172
+
173
+ const label = `Installing ${spec.title || `${providerId} CLI`}`;
174
+ const binariesPresent = skipIfInstalled && spec.binaries.every((b) => commandExists(b, process.env));
175
+ if (binariesPresent) {
176
+ steps.info(`- [✓] ${label} (already installed)`);
177
+ results.push({ ok: true, providerId, alreadyInstalled: true, logPath: null, plan: planned.plan });
178
+ continue;
179
+ }
180
+
181
+ steps.start(label);
182
+ if (dryRun) {
183
+ steps.stop('✓', `${label} (dry-run)`);
184
+ results.push({ ok: true, providerId, alreadyInstalled: false, logPath: null, plan: planned.plan });
185
+ continue;
186
+ }
187
+
188
+ const logPath = resolveProviderInstallLogPath(providerId);
189
+ writeFileSync(
190
+ logPath,
191
+ [`# providerId: ${providerId}`, `# platform: ${platform}`, ''].join('\n'),
192
+ 'utf8',
193
+ );
194
+
195
+ try {
196
+ for (const c of planned.plan.commands) {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ await runCommandLogged({
199
+ label,
200
+ cmd: c.cmd,
201
+ args: c.args,
202
+ cwd: process.cwd(),
203
+ env: process.env,
204
+ logPath,
205
+ showSteps: false,
206
+ quiet: true,
207
+ });
208
+ }
209
+ } catch (e) {
210
+ steps.stop('x', label);
211
+ const message = e instanceof Error ? e.message : String(e);
212
+ throw new Error(`[providers] install failed: ${message}\nlog: ${logPath}`);
213
+ }
214
+
215
+ steps.stop('✓', label);
216
+ results.push({ ok: true, providerId, alreadyInstalled: false, logPath, plan: planned.plan });
217
+ }
218
+
219
+ printResult({
220
+ json,
221
+ data: {
222
+ ok: true,
223
+ providers: resolved,
224
+ dryRun,
225
+ skipIfInstalled,
226
+ plan: results.map((r) => r.plan),
227
+ results: results.map((r) => ({ ok: true, providerId: r.providerId, alreadyInstalled: r.alreadyInstalled, logPath: r.logPath })),
228
+ },
229
+ text: json ? null : `✓ providers installed: ${resolved.join(', ')}`,
230
+ });
231
+ }
232
+
233
+ async function main() {
234
+ const argv = process.argv.slice(2);
235
+ const { flags } = parseArgs(argv);
236
+ const json = wantsJson(argv, { flags });
237
+
238
+ if (argv.length === 0 || wantsHelp(argv, { flags })) {
239
+ printResult({ json, data: { usage: usageText() }, text: usageText() });
240
+ return;
241
+ }
242
+
243
+ const positionals = argv.filter((a) => a && a !== '--' && !a.startsWith('-'));
244
+ const sub = String(positionals[0] ?? '').trim();
245
+ if (sub === 'list') {
246
+ await cmdList({ argv: argv.slice(1) });
247
+ return;
248
+ }
249
+ if (sub === 'install') {
250
+ await cmdInstall({ argv: argv.slice(1) });
251
+ return;
252
+ }
253
+
254
+ printResult({ json, data: { usage: usageText() }, text: usageText() });
255
+ process.exit(2);
256
+ }
257
+
258
+ main().catch((error) => {
259
+ const msg = error instanceof Error ? error.message : String(error);
260
+ process.stderr.write(`${msg}\n`);
261
+ process.exit(1);
262
+ });
@@ -27,23 +27,23 @@ function commandExists(cmd) {
27
27
  return spawnSync('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1`], { stdio: 'ignore' }).status === 0;
28
28
  }
29
29
 
30
- function runWithHardTimeout({ cmd, args, cwd, env, encoding, maxBuffer, timeoutMs }) {
31
- if (commandExists('timeout')) {
32
- const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
33
- return spawnSync('timeout', ['--signal=KILL', '--kill-after=30s', `${seconds}s`, cmd, ...args], {
34
- cwd,
35
- encoding,
36
- env,
37
- maxBuffer,
30
+ function runWithHardTimeout(command, args, options) {
31
+ const timeoutMs = Number(options.timeout ?? 0);
32
+ if (process.platform === 'linux' && commandExists('timeout') && timeoutMs > 0) {
33
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
34
+ return spawnSync('timeout', ['--signal=KILL', '--kill-after=30s', `${timeoutSeconds}s`, command, ...args], {
35
+ ...options,
36
+ timeout: undefined,
38
37
  });
39
38
  }
40
- return spawnSync(cmd, args, {
41
- cwd,
42
- encoding,
43
- env,
44
- maxBuffer,
45
- timeout: timeoutMs,
46
- });
39
+ return spawnSync(command, args, options);
40
+ }
41
+
42
+ function didCommandTimeout(result) {
43
+ if (result?.error?.code === 'ETIMEDOUT') return true;
44
+ if (result?.status === 124 || result?.status === 137) return true;
45
+ if (result?.signal === 'SIGKILL') return true;
46
+ return false;
47
47
  }
48
48
 
49
49
  function currentTarget() {
@@ -84,20 +84,25 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
84
84
  const repoRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)));
85
85
  const version = `0.0.0-smoke.${Date.now()}`;
86
86
 
87
- const buildCli = runWithHardTimeout({
88
- cmd: process.execPath,
89
- args: [
87
+ const buildCli = runWithHardTimeout(
88
+ process.execPath,
89
+ [
90
90
  'scripts/release/build-cli-binaries.mjs',
91
91
  '--channel=preview',
92
92
  `--version=${version}`,
93
93
  `--targets=${target}`,
94
94
  ],
95
- cwd: repoRoot,
96
- encoding: 'utf-8',
97
- env: { ...process.env },
98
- maxBuffer: 50 * 1024 * 1024,
99
- timeoutMs: 15 * 60 * 1000,
100
- });
95
+ {
96
+ cwd: repoRoot,
97
+ encoding: 'utf-8',
98
+ env: { ...process.env },
99
+ // `inherit` makes it easier to read interactively, but on CI failures we need the full output.
100
+ // Increase buffer because build logs can be large.
101
+ maxBuffer: 50 * 1024 * 1024,
102
+ // If this ever hangs on CI, fail with a clear timeout rather than blocking the entire suite.
103
+ timeout: 15 * 60 * 1000,
104
+ }
105
+ );
101
106
  assert.equal(buildCli.status, 0, formatSpawnSyncResult(buildCli));
102
107
 
103
108
  const cliArtifactPath = join(repoRoot, 'dist', 'release-assets', 'cli', `happier-v${version}-${target}.tar.gz`);
@@ -105,13 +110,13 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
105
110
  t.after(() => {
106
111
  spawnSync('bash', ['-lc', `rm -rf "${cliExtract.extractDir.replaceAll('"', '\\"')}"`], { stdio: 'ignore' });
107
112
  });
108
- const cliVersion = spawnSync(cliExtract.binaryPath, ['--version'], {
113
+ const cliVersion = runWithHardTimeout(cliExtract.binaryPath, ['--version'], {
109
114
  cwd: '/tmp',
110
115
  encoding: 'utf-8',
111
116
  env: { ...process.env, HAPPIER_NONINTERACTIVE: '1' },
112
117
  timeout: 7000,
113
118
  });
114
- const cliTimedOut = cliVersion.error && cliVersion.error.code === 'ETIMEDOUT';
119
+ const cliTimedOut = didCommandTimeout(cliVersion);
115
120
  const cliExited = (cliVersion.status ?? 1) === 0;
116
121
  assert.ok(cliTimedOut || cliExited, cliVersion.stderr || cliVersion.stdout);
117
122
  const versionText = `${cliVersion.stdout || ''}${cliVersion.stderr || ''}`.trim();
@@ -121,20 +126,23 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
121
126
  );
122
127
 
123
128
  if (isLinuxTarget(target)) {
124
- const buildServer = runWithHardTimeout({
125
- cmd: process.execPath,
126
- args: [
129
+ const buildServer = runWithHardTimeout(
130
+ process.execPath,
131
+ [
127
132
  'scripts/release/build-server-binaries.mjs',
128
133
  '--channel=preview',
129
134
  `--version=${version}`,
130
135
  `--targets=${target}`,
131
136
  ],
132
- cwd: repoRoot,
133
- encoding: 'utf-8',
134
- env: { ...process.env },
135
- maxBuffer: 50 * 1024 * 1024,
136
- timeoutMs: 15 * 60 * 1000,
137
- });
137
+ {
138
+ cwd: repoRoot,
139
+ encoding: 'utf-8',
140
+ env: { ...process.env },
141
+ maxBuffer: 50 * 1024 * 1024,
142
+ // If this ever hangs on CI, fail with a clear timeout rather than blocking the entire suite.
143
+ timeout: 15 * 60 * 1000,
144
+ }
145
+ );
138
146
  assert.equal(buildServer.status, 0, formatSpawnSyncResult(buildServer));
139
147
 
140
148
  const serverArtifactPath = join(repoRoot, 'dist', 'release-assets', 'server', `happier-server-v${version}-${target}.tar.gz`);
@@ -146,7 +154,7 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
146
154
  t.after(() => {
147
155
  spawnSync('bash', ['-lc', `rm -rf "${serverDataDir.replaceAll('"', '\\"')}"`], { stdio: 'ignore' });
148
156
  });
149
- const serverBoot = spawnSync(serverExtract.binaryPath, [], {
157
+ const serverBoot = runWithHardTimeout(serverExtract.binaryPath, [], {
150
158
  cwd: '/tmp',
151
159
  encoding: 'utf-8',
152
160
  env: {
@@ -161,7 +169,7 @@ test('compiled happier and server binaries execute from isolated cwd', async (t)
161
169
  },
162
170
  timeout: 7000,
163
171
  });
164
- const timedOut = serverBoot.error && serverBoot.error.code === 'ETIMEDOUT';
172
+ const timedOut = didCommandTimeout(serverBoot);
165
173
  const cleanExit = (serverBoot.status ?? 1) === 0;
166
174
  const serverOutput = `${serverBoot.stderr || ''}\n${serverBoot.stdout || ''}`;
167
175
  assert.ok(timedOut || cleanExit, serverOutput);