@happier-dev/stack 0.1.0-preview.100.1 → 0.1.0-preview.138.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
@@ -0,0 +1,240 @@
1
+ import './utils/env/env.mjs';
2
+
3
+ import { run, runCapture } from './utils/proc/proc.mjs';
4
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
5
+
6
+ function takeFlagValue(args, name) {
7
+ const rest = [];
8
+ let value = null;
9
+
10
+ for (let i = 0; i < args.length; i += 1) {
11
+ const a = String(args[i] ?? '');
12
+ if (a === name) {
13
+ const next = String(args[i + 1] ?? '');
14
+ if (!next || next.startsWith('--')) {
15
+ throw new Error(`Missing value for ${name}`);
16
+ }
17
+ value = next;
18
+ i += 1;
19
+ continue;
20
+ }
21
+ if (a.startsWith(`${name}=`)) {
22
+ const v = a.slice(`${name}=`.length);
23
+ if (!v) throw new Error(`Missing value for ${name}`);
24
+ value = v;
25
+ continue;
26
+ }
27
+ rest.push(a);
28
+ }
29
+
30
+ return { value, rest };
31
+ }
32
+
33
+ function safeBashSingleQuote(s) {
34
+ const raw = String(s ?? '');
35
+ if (raw === '') return "''";
36
+ // Wrap in single quotes and escape embedded single quotes safely.
37
+ // bash: 'foo'\''bar'
38
+ return `'${raw.replaceAll("'", `'\"'\"'`)}'`;
39
+ }
40
+
41
+ function parseJsonLinesBestEffort(stdout) {
42
+ const out = String(stdout ?? '');
43
+ const lines = out
44
+ .split(/\r?\n/)
45
+ .map((l) => l.trim())
46
+ .filter(Boolean);
47
+ for (const line of lines) {
48
+ try {
49
+ return JSON.parse(line);
50
+ } catch {
51
+ continue;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ async function runSsh({ target, command }) {
58
+ await run('ssh', [target, 'bash', '-lc', command], { env: process.env });
59
+ }
60
+
61
+ async function runSshJson({ target, command }) {
62
+ const out = await runCapture('ssh', [target, 'bash', '-lc', command], { env: process.env });
63
+ const parsed = parseJsonLinesBestEffort(out);
64
+ if (!parsed) {
65
+ throw new Error('Remote command did not return valid JSON');
66
+ }
67
+ return parsed;
68
+ }
69
+
70
+ async function runLocalJson({ args }) {
71
+ const out = await runCapture('happier', args, { env: process.env });
72
+ const parsed = parseJsonLinesBestEffort(out);
73
+ if (!parsed) {
74
+ throw new Error('Local command did not return valid JSON');
75
+ }
76
+ return parsed;
77
+ }
78
+
79
+ function usageText() {
80
+ return [
81
+ '[remote] usage:',
82
+ ' hstack remote daemon setup --ssh <user@host> [--preview|--stable] [--channel <stable|preview>]',
83
+ ' [--service <user|none>]',
84
+ ' [--server-url=<url>] [--webapp-url=<url>] [--public-server-url=<url>]',
85
+ ' [--json]',
86
+ '',
87
+ 'notes:',
88
+ ' - This command runs remote operations over ssh.',
89
+ ' - It installs the Happier CLI on the remote host, pairs credentials, and optionally installs/starts the daemon service.',
90
+ ' - Default service mode is user; set --service none to skip daemon service setup.',
91
+ ].join('\n');
92
+ }
93
+
94
+ function resolveChannel(argv) {
95
+ if (argv.includes('--preview')) return 'preview';
96
+ if (argv.includes('--stable')) return 'stable';
97
+ const picked = argv.find((a) => a === '--channel' || a.startsWith('--channel='));
98
+ if (!picked) return 'stable';
99
+ if (picked === '--channel') {
100
+ const idx = argv.indexOf('--channel');
101
+ const v = String(argv[idx + 1] ?? '').trim();
102
+ return v || 'stable';
103
+ }
104
+ const v = String(picked.slice('--channel='.length)).trim();
105
+ return v || 'stable';
106
+ }
107
+
108
+ function resolveService(argv) {
109
+ const picked = argv.find((a) => a === '--service' || a.startsWith('--service='));
110
+ if (!picked) return 'user';
111
+ if (picked === '--service') {
112
+ const idx = argv.indexOf('--service');
113
+ const v = String(argv[idx + 1] ?? '').trim().toLowerCase();
114
+ return v || 'user';
115
+ }
116
+ const v = String(picked.slice('--service='.length)).trim().toLowerCase();
117
+ return v || 'user';
118
+ }
119
+
120
+ async function runRemoteDaemonSetup(argvRaw) {
121
+ const argv0 = argvRaw.slice();
122
+ const json = wantsJson(argv0);
123
+
124
+ let args = argv0.slice();
125
+ const ssh = takeFlagValue(args, '--ssh');
126
+ args = ssh.rest;
127
+ if (!ssh.value) {
128
+ process.stderr.write('Missing required flag: --ssh <user@host>\n');
129
+ process.exit(2);
130
+ }
131
+
132
+ const channel = resolveChannel(argv0);
133
+ if (channel !== 'stable' && channel !== 'preview') {
134
+ throw new Error(`[remote] invalid --channel value: ${channel}`);
135
+ }
136
+
137
+ const service = resolveService(argv0);
138
+ if (service !== 'user' && service !== 'none') {
139
+ throw new Error(`[remote] invalid --service value: ${service} (expected user or none)`);
140
+ }
141
+
142
+ const serverUrlFlag = takeFlagValue(args, '--server-url');
143
+ args = serverUrlFlag.rest;
144
+ const webappUrlFlag = takeFlagValue(args, '--webapp-url');
145
+ args = webappUrlFlag.rest;
146
+ const publicServerUrlFlag = takeFlagValue(args, '--public-server-url');
147
+ args = publicServerUrlFlag.rest;
148
+
149
+ const serverFlags = {
150
+ serverUrl: serverUrlFlag.value,
151
+ webappUrl: webappUrlFlag.value,
152
+ publicServerUrl: publicServerUrlFlag.value,
153
+ localArgs: [
154
+ ...(serverUrlFlag.value ? [`--server-url=${serverUrlFlag.value}`] : []),
155
+ ...(webappUrlFlag.value ? [`--webapp-url=${webappUrlFlag.value}`] : []),
156
+ ...(publicServerUrlFlag.value ? [`--public-server-url=${publicServerUrlFlag.value}`] : []),
157
+ ],
158
+ };
159
+
160
+ const installUrl = 'https://happier.dev/install';
161
+ const remoteBin = '$HOME/.happier/bin/happier';
162
+
163
+ // Always disable auto-service setup in the installer so this command controls service behavior.
164
+ const installCmd = [
165
+ `curl -fsSL ${installUrl} |`,
166
+ `HAPPIER_CHANNEL=${channel} HAPPIER_WITH_DAEMON=0 HAPPIER_NONINTERACTIVE=1 bash`,
167
+ ].join(' ');
168
+
169
+ await runSsh({ target: ssh.value, command: installCmd });
170
+
171
+ const request = await runSshJson({ target: ssh.value, command: `${remoteBin} auth request --json` });
172
+ const publicKey = typeof request?.publicKey === 'string' ? request.publicKey : '';
173
+ if (!publicKey) {
174
+ throw new Error('Remote auth request did not include "publicKey"');
175
+ }
176
+
177
+ await runLocalJson({
178
+ args: [...serverFlags.localArgs, 'auth', 'approve', '--public-key', publicKey, '--json'],
179
+ });
180
+
181
+ await runSshJson({
182
+ target: ssh.value,
183
+ command: `${remoteBin} auth wait --public-key ${safeBashSingleQuote(publicKey)} --json`,
184
+ });
185
+
186
+ if (service === 'user') {
187
+ const envParts = [];
188
+ if (serverFlags.serverUrl) envParts.push(`HAPPIER_DAEMON_SERVICE_SERVER_URL=${safeBashSingleQuote(serverFlags.serverUrl)}`);
189
+ if (serverFlags.webappUrl) envParts.push(`HAPPIER_DAEMON_SERVICE_WEBAPP_URL=${safeBashSingleQuote(serverFlags.webappUrl)}`);
190
+ if (serverFlags.publicServerUrl) envParts.push(`HAPPIER_DAEMON_SERVICE_PUBLIC_SERVER_URL=${safeBashSingleQuote(serverFlags.publicServerUrl)}`);
191
+ const envPrefix = envParts.length ? `${envParts.join(' ')} ` : '';
192
+
193
+ await runSsh({ target: ssh.value, command: `${envPrefix}${remoteBin} daemon service install` });
194
+ await runSsh({ target: ssh.value, command: `${envPrefix}${remoteBin} daemon service start` });
195
+ }
196
+
197
+ printResult({
198
+ json,
199
+ data: { ok: true, ssh: ssh.value, channel, service, publicKey },
200
+ text: json
201
+ ? null
202
+ : [
203
+ '✓ Remote daemon setup complete',
204
+ `- ssh: ${ssh.value}`,
205
+ `- channel: ${channel}`,
206
+ `- service: ${service}`,
207
+ `- publicKey: ${publicKey}`,
208
+ ].join('\n'),
209
+ });
210
+ }
211
+
212
+ async function main() {
213
+ const argvRaw = process.argv.slice(2);
214
+ if (argvRaw.length === 0 || wantsHelp(argvRaw)) {
215
+ printResult({ json: wantsJson(argvRaw), data: { usage: usageText() }, text: usageText() });
216
+ return;
217
+ }
218
+
219
+ const positionals = argvRaw.filter((a) => a && a !== '--' && !a.startsWith('-'));
220
+ const top = String(positionals[0] ?? '').trim();
221
+ const sub = String(positionals[1] ?? '').trim();
222
+
223
+ if (top === 'daemon' && sub === 'setup') {
224
+ await runRemoteDaemonSetup(argvRaw);
225
+ return;
226
+ }
227
+
228
+ printResult({
229
+ json: wantsJson(argvRaw),
230
+ data: { usage: usageText() },
231
+ text: usageText(),
232
+ });
233
+ process.exit(2);
234
+ }
235
+
236
+ main().catch((error) => {
237
+ const msg = error instanceof Error ? error.message : String(error);
238
+ process.stderr.write(`${msg}\n`);
239
+ process.exit(1);
240
+ });
@@ -0,0 +1,296 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmod, cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, 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
+ import nacl from 'tweetnacl';
9
+
10
+ import { commandExists, extractBinaryFromArtifact, reserveLocalhostPort, run, waitForHealth } from './self_host_service_e2e_harness.mjs';
11
+
12
+ const SELF_HOST_INSTALL_TIMEOUT_MS = 420_000;
13
+
14
+ function currentTarget() {
15
+ if (process.platform === 'linux') {
16
+ if (process.arch === 'x64') return 'linux-x64';
17
+ if (process.arch === 'arm64') return 'linux-arm64';
18
+ return '';
19
+ }
20
+ if (process.platform === 'darwin') {
21
+ if (process.arch === 'x64') return 'darwin-x64';
22
+ if (process.arch === 'arm64') return 'darwin-arm64';
23
+ return '';
24
+ }
25
+ return '';
26
+ }
27
+
28
+ function deriveServerIdFromUrl(rawUrl) {
29
+ const normalized = String(rawUrl || '').trim().replace(/\/+$/, '');
30
+ let h = 2166136261;
31
+ for (let i = 0; i < normalized.length; i += 1) {
32
+ h ^= normalized.charCodeAt(i);
33
+ h = Math.imul(h, 16777619);
34
+ }
35
+ return `env_${(h >>> 0).toString(16)}`;
36
+ }
37
+
38
+ async function writeCredentialsForServer({ homeDir, serverUrl, token }) {
39
+ const happyHomeDir = String(homeDir ?? '').trim();
40
+ assert.ok(happyHomeDir, 'homeDir is required');
41
+ const id = deriveServerIdFromUrl(serverUrl);
42
+ const scopedDir = join(happyHomeDir, 'servers', id);
43
+ await mkdir(scopedDir, { recursive: true });
44
+ await mkdir(happyHomeDir, { recursive: true });
45
+
46
+ const secret = Buffer.alloc(32, 7).toString('base64');
47
+ const payload = `${JSON.stringify({ token, secret }, null, 2)}\n`;
48
+
49
+ const legacyPath = join(happyHomeDir, 'access.key');
50
+ const scopedPath = join(scopedDir, 'access.key');
51
+ await writeFile(legacyPath, payload, { mode: 0o600 });
52
+ await writeFile(scopedPath, payload, { mode: 0o600 });
53
+ return { serverId: id };
54
+ }
55
+
56
+ async function createAuthToken(serverUrl) {
57
+ const keyPair = nacl.sign.keyPair();
58
+ const challenge = nacl.randomBytes(32);
59
+ const signature = nacl.sign.detached(challenge, keyPair.secretKey);
60
+ const encode64 = (bytes) => Buffer.from(bytes).toString('base64');
61
+
62
+ const url = new URL('/v1/auth', String(serverUrl));
63
+ if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
64
+
65
+ const res = await fetch(url.toString(), {
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify({
69
+ publicKey: encode64(keyPair.publicKey),
70
+ challenge: encode64(challenge),
71
+ signature: encode64(signature),
72
+ }),
73
+ });
74
+ if (!res.ok) {
75
+ const text = await res.text().catch(() => '');
76
+ throw new Error(`[self-host-daemon] /v1/auth failed: ${res.status} ${res.statusText} ${text}`.trim());
77
+ }
78
+ const data = await res.json().catch(() => ({}));
79
+ const token = String(data?.token ?? '').trim();
80
+ if (!token) throw new Error('[self-host-daemon] /v1/auth response missing token');
81
+ return token;
82
+ }
83
+
84
+ function runAsRoot(cmd, args, { cwd, env, timeoutMs = 0, allowFail = false, stdio = 'pipe' } = {}) {
85
+ if (process.platform !== 'linux') {
86
+ return run(cmd, args, { label: 'self-host-daemon', cwd, env, timeoutMs, allowFail, stdio });
87
+ }
88
+ const mergedEnv = { ...process.env, ...(env ?? {}) };
89
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
90
+ return run(cmd, args, { label: 'self-host-daemon', cwd, env: mergedEnv, timeoutMs, allowFail, stdio });
91
+ }
92
+ if (!commandExists('sudo')) {
93
+ throw new Error('[self-host-daemon] sudo is required on linux system mode');
94
+ }
95
+ const envArgs = Object.entries(mergedEnv).map(([key, value]) => `${key}=${String(value ?? '')}`);
96
+ return run('sudo', ['-E', 'env', ...envArgs, cmd, ...args], {
97
+ label: 'self-host-daemon',
98
+ cwd,
99
+ env: process.env,
100
+ timeoutMs,
101
+ allowFail,
102
+ stdio,
103
+ });
104
+ }
105
+
106
+ test(
107
+ 'self-host server + daemon integration suite works against installed runtime',
108
+ { timeout: 25 * 60_000 },
109
+ async (t) => {
110
+ const target = currentTarget();
111
+ if (!target) {
112
+ t.skip(`unsupported platform for daemon E2E: ${process.platform}-${process.arch}`);
113
+ return;
114
+ }
115
+ if (!commandExists('bun')) {
116
+ t.skip('bun is required to build compiled binaries');
117
+ return;
118
+ }
119
+ if (process.platform === 'linux' && !commandExists('systemctl')) {
120
+ t.skip('systemctl is required on linux self-host daemon E2E');
121
+ return;
122
+ }
123
+
124
+ const repoRoot = resolve(fileURLToPath(new URL('../../..', import.meta.url)));
125
+ const version = `0.0.0-daemon.${Date.now()}`;
126
+
127
+ run(
128
+ process.execPath,
129
+ [
130
+ 'scripts/release/build-hstack-binaries.mjs',
131
+ '--channel=preview',
132
+ `--version=${version}`,
133
+ `--targets=${target}`,
134
+ ],
135
+ {
136
+ label: 'self-host-daemon',
137
+ cwd: repoRoot,
138
+ env: { ...process.env },
139
+ timeoutMs: 12 * 60_000,
140
+ }
141
+ );
142
+ run(
143
+ process.execPath,
144
+ [
145
+ 'scripts/release/build-server-binaries.mjs',
146
+ '--channel=preview',
147
+ `--version=${version}`,
148
+ `--targets=${target}`,
149
+ ],
150
+ {
151
+ label: 'self-host-daemon',
152
+ cwd: repoRoot,
153
+ env: { ...process.env },
154
+ timeoutMs: 12 * 60_000,
155
+ }
156
+ );
157
+
158
+ const hstackArtifact = join(repoRoot, 'dist', 'release-assets', 'stack', `hstack-v${version}-${target}.tar.gz`);
159
+ const serverArtifact = join(repoRoot, 'dist', 'release-assets', 'server', `happier-server-v${version}-${target}.tar.gz`);
160
+
161
+ const extractedHstack = await extractBinaryFromArtifact({ label: 'self-host-daemon', artifactPath: hstackArtifact, binaryName: process.platform === 'win32' ? 'hstack.exe' : 'hstack' });
162
+ const extractedServer = await extractBinaryFromArtifact({ label: 'self-host-daemon', artifactPath: serverArtifact, binaryName: process.platform === 'win32' ? 'happier-server.exe' : 'happier-server' });
163
+
164
+ t.after(async () => {
165
+ await rm(extractedHstack.extractDir, { recursive: true, force: true });
166
+ await rm(extractedServer.extractDir, { recursive: true, force: true });
167
+ });
168
+
169
+ const sandboxDir = await mkdtemp(join(tmpdir(), 'happier-self-host-daemon-'));
170
+ t.after(async () => {
171
+ await rm(sandboxDir, { recursive: true, force: true });
172
+ });
173
+
174
+ const installRoot = join(sandboxDir, 'self-host');
175
+ const binDir = join(sandboxDir, 'bin');
176
+ const configDir = join(sandboxDir, 'config');
177
+ const dataDir = join(sandboxDir, 'data');
178
+ const logDir = join(sandboxDir, 'logs');
179
+ const cliHomeDir = join(sandboxDir, 'cli-home');
180
+ await mkdir(binDir, { recursive: true });
181
+
182
+ const hstackPath = join(binDir, process.platform === 'win32' ? 'hstack.exe' : 'hstack');
183
+ await cp(extractedHstack.binaryPath, hstackPath);
184
+ await chmod(hstackPath, 0o755).catch(() => {});
185
+
186
+ const serviceName = `happier-server-e2e-${Date.now().toString(36).slice(-6)}`;
187
+ const serverPort = await reserveLocalhostPort();
188
+ const mode = process.platform === 'linux' ? 'system' : 'user';
189
+
190
+ const serverUrl = `http://127.0.0.1:${serverPort}`;
191
+ const commonEnv = {
192
+ PATH: process.env.PATH ?? '',
193
+ HAPPIER_SELF_HOST_INSTALL_ROOT: installRoot,
194
+ HAPPIER_SELF_HOST_BIN_DIR: binDir,
195
+ HAPPIER_SELF_HOST_CONFIG_DIR: configDir,
196
+ HAPPIER_SELF_HOST_DATA_DIR: dataDir,
197
+ HAPPIER_SELF_HOST_LOG_DIR: logDir,
198
+ HAPPIER_SELF_HOST_SERVICE_NAME: serviceName,
199
+ HAPPIER_SELF_HOST_SERVER_BINARY: extractedServer.binaryPath,
200
+ HAPPIER_SELF_HOST_AUTO_UPDATE: '0',
201
+ HAPPIER_SELF_HOST_HEALTH_TIMEOUT_MS: '240000',
202
+ HAPPIER_NONINTERACTIVE: '1',
203
+ HAPPIER_WITH_CLI: '0',
204
+ HAPPIER_SERVER_PORT: String(serverPort),
205
+ HAPPIER_SERVER_HOST: '127.0.0.1',
206
+ };
207
+
208
+ let installSucceeded = false;
209
+ t.after(() => {
210
+ if (!installSucceeded) return;
211
+ runAsRoot(
212
+ hstackPath,
213
+ ['self-host', 'uninstall', '--channel=preview', `--mode=${mode}`, '--yes', '--purge-data', '--json'],
214
+ {
215
+ env: commonEnv,
216
+ allowFail: true,
217
+ timeoutMs: 180_000,
218
+ stdio: 'ignore',
219
+ cwd: sandboxDir,
220
+ }
221
+ );
222
+ });
223
+
224
+ const installResult = runAsRoot(
225
+ hstackPath,
226
+ ['self-host', 'install', '--channel=preview', `--mode=${mode}`, '--no-auto-update', '--non-interactive', '--without-cli', '--json'],
227
+ {
228
+ env: commonEnv,
229
+ timeoutMs: SELF_HOST_INSTALL_TIMEOUT_MS,
230
+ allowFail: true,
231
+ cwd: sandboxDir,
232
+ }
233
+ );
234
+ if ((installResult.status ?? 1) !== 0) {
235
+ const recovered = await waitForHealth(`${serverUrl}/v1/version`, 120_000);
236
+ if (!recovered) {
237
+ throw new Error(
238
+ [
239
+ '[self-host-daemon] self-host install failed and never became healthy',
240
+ `install status: ${String(installResult.status ?? 'null')}`,
241
+ `install stdout:\n${String(installResult.stdout ?? '').trim()}`,
242
+ `install stderr:\n${String(installResult.stderr ?? '').trim()}`,
243
+ ].join('\n\n')
244
+ );
245
+ }
246
+ }
247
+ installSucceeded = true;
248
+
249
+ const healthOk = await waitForHealth(`${serverUrl}/v1/version`, 120_000);
250
+ assert.equal(healthOk, true, 'self-host service health endpoint did not become ready');
251
+
252
+ const token = await createAuthToken(serverUrl);
253
+ const { serverId } = await writeCredentialsForServer({ homeDir: cliHomeDir, serverUrl, token });
254
+
255
+ const daemonEnv = {
256
+ ...process.env,
257
+ HAPPIER_HOME_DIR: cliHomeDir,
258
+ HAPPIER_ACTIVE_SERVER_ID: serverId,
259
+ HAPPIER_SERVER_URL: serverUrl,
260
+ HAPPIER_WEBAPP_URL: serverUrl,
261
+ HAPPIER_PUBLIC_SERVER_URL: serverUrl,
262
+ HAPPIER_NO_BROWSER_OPEN: '1',
263
+ HAPPIER_NONINTERACTIVE: '1',
264
+ };
265
+
266
+ // The daemon suite is unix-only (signals/ps); fail closed on unsupported.
267
+ if (process.platform === 'linux' || process.platform === 'darwin') {
268
+ const daemonRun = run(
269
+ 'yarn',
270
+ ['--cwd', 'apps/cli', '-s', 'vitest', 'run', '--config', 'vitest.integration.config.ts', 'src/daemon/daemon.integration.test.ts'],
271
+ {
272
+ label: 'self-host-daemon',
273
+ cwd: repoRoot,
274
+ env: daemonEnv,
275
+ timeoutMs: 15 * 60_000,
276
+ allowFail: true,
277
+ stdio: 'inherit',
278
+ }
279
+ );
280
+ assert.equal(daemonRun.status, 0, 'daemon integration suite failed against self-host install');
281
+ } else {
282
+ t.skip(`daemon integration suite is unsupported on ${process.platform}`);
283
+ return;
284
+ }
285
+
286
+ runAsRoot(
287
+ hstackPath,
288
+ ['self-host', 'uninstall', '--channel=preview', `--mode=${mode}`, '--yes', '--purge-data', '--json'],
289
+ { env: commonEnv, timeoutMs: 180_000, cwd: sandboxDir }
290
+ );
291
+ installSucceeded = false;
292
+
293
+ const healthAfter = await waitForHealth(`${serverUrl}/v1/version`, 10_000);
294
+ assert.equal(healthAfter, false, 'server should not remain healthy after uninstall');
295
+ }
296
+ );