@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
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { existsSync } from 'node:fs';
4
4
  import {
5
+ cp,
5
6
  chmod,
6
7
  copyFile,
7
8
  mkdir,
@@ -13,18 +14,33 @@ import {
13
14
  symlink,
14
15
  writeFile,
15
16
  } from 'node:fs/promises';
16
- import { tmpdir } from 'node:os';
17
- import { dirname, join } from 'node:path';
17
+ import { homedir, tmpdir } from 'node:os';
18
+ import { dirname, join, win32 as win32Path } from 'node:path';
18
19
 
19
20
  import { parseArgs } from './utils/cli/args.mjs';
20
21
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
21
22
  import { banner, sectionTitle } from './utils/ui/layout.mjs';
22
23
  import { cyan, dim, green, yellow } from './utils/ui/ansi.mjs';
24
+ import { installService as installManagedService, restartService as restartManagedService, uninstallService as uninstallManagedService } from './utils/service/service_manager.mjs';
25
+ import {
26
+ applyServicePlan,
27
+ buildLaunchdPath,
28
+ buildLaunchdPlistXml,
29
+ buildServiceDefinition,
30
+ planServiceAction,
31
+ renderSystemdServiceUnit,
32
+ renderWindowsScheduledTaskWrapperPs1,
33
+ resolveServiceBackend,
34
+ } from '@happier-dev/cli-common/service';
35
+ import { DEFAULT_MINISIGN_PUBLIC_KEY } from '@happier-dev/release-runtime/minisign';
36
+ import { resolveReleaseAssetBundle } from '@happier-dev/release-runtime/assets';
37
+ import { downloadVerifiedReleaseAssetBundle } from '@happier-dev/release-runtime/verifiedDownload';
38
+ import { planArchiveExtraction } from '@happier-dev/release-runtime/extractPlan';
39
+ import { fetchFirstGitHubReleaseByTags, fetchGitHubReleaseByTag } from '@happier-dev/release-runtime/github';
23
40
 
24
41
  const SUPPORTED_CHANNELS = new Set(['stable', 'preview']);
25
42
  const DEFAULTS = Object.freeze({
26
43
  githubRepo: 'happier-dev/happier',
27
- minisignPubKeyUrl: 'https://happier.dev/happier-release.pub',
28
44
  installRoot: '/opt/happier',
29
45
  binDir: '/usr/local/bin',
30
46
  configDir: '/etc/happier',
@@ -33,8 +49,39 @@ const DEFAULTS = Object.freeze({
33
49
  serviceName: 'happier-server',
34
50
  serverHost: '127.0.0.1',
35
51
  serverPort: 3005,
52
+ healthCheckTimeoutMs: 90_000,
53
+ autoUpdateIntervalMinutes: 1440,
54
+ uiWebProduct: 'happier-ui-web',
55
+ uiWebOs: 'web',
56
+ uiWebArch: 'any',
36
57
  });
37
58
 
59
+ export function resolveSelfHostDefaults({ platform = process.platform, mode = 'user', homeDir = homedir() } = {}) {
60
+ const p = String(platform ?? '').trim() || process.platform;
61
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
62
+ const home = String(homeDir ?? '').trim() || homedir();
63
+
64
+ if (m === 'system') {
65
+ return {
66
+ installRoot: DEFAULTS.installRoot,
67
+ binDir: DEFAULTS.binDir,
68
+ configDir: DEFAULTS.configDir,
69
+ dataDir: DEFAULTS.dataDir,
70
+ logDir: DEFAULTS.logDir,
71
+ };
72
+ }
73
+
74
+ const happierHome = p === 'win32' ? `${home}\\.happier` : join(home, '.happier');
75
+ const installRoot = p === 'win32' ? `${happierHome}\\self-host` : join(happierHome, 'self-host');
76
+ return {
77
+ installRoot,
78
+ binDir: p === 'win32' ? `${happierHome}\\bin` : join(happierHome, 'bin'),
79
+ configDir: p === 'win32' ? `${installRoot}\\config` : join(installRoot, 'config'),
80
+ dataDir: p === 'win32' ? `${installRoot}\\data` : join(installRoot, 'data'),
81
+ logDir: p === 'win32' ? `${installRoot}\\logs` : join(installRoot, 'logs'),
82
+ };
83
+ }
84
+
38
85
  function parseBoolean(raw, fallback = false) {
39
86
  const value = String(raw ?? '').trim().toLowerCase();
40
87
  if (!value) return fallback;
@@ -50,6 +97,83 @@ function parsePort(raw, fallback = DEFAULTS.serverPort) {
50
97
  return port > 0 && port <= 65535 ? port : fallback;
51
98
  }
52
99
 
100
+ function parseDailyAtTime(raw) {
101
+ const text = String(raw ?? '').trim();
102
+ if (!text) return null;
103
+ const m = /^(\d{1,2}):(\d{1,2})$/.exec(text);
104
+ if (!m) return null;
105
+ const hourRaw = Number(m[1]);
106
+ const minuteRaw = Number(m[2]);
107
+ const hour = Number.isFinite(hourRaw) ? Math.floor(hourRaw) : NaN;
108
+ const minute = Number.isFinite(minuteRaw) ? Math.floor(minuteRaw) : NaN;
109
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
110
+ if (hour < 0 || hour > 23) return null;
111
+ if (minute < 0 || minute > 59) return null;
112
+ const hh = String(hour).padStart(2, '0');
113
+ const mm = String(minute).padStart(2, '0');
114
+ return { hour, minute, normalized: `${hh}:${mm}` };
115
+ }
116
+
117
+ export function resolveSelfHostHealthTimeoutMs(env = process.env) {
118
+ const raw = String(env?.HAPPIER_SELF_HOST_HEALTH_TIMEOUT_MS ?? '').trim();
119
+ if (!raw) return DEFAULTS.healthCheckTimeoutMs;
120
+ const parsed = Number(raw);
121
+ return Number.isFinite(parsed) && parsed >= 10_000
122
+ ? Math.floor(parsed)
123
+ : DEFAULTS.healthCheckTimeoutMs;
124
+ }
125
+
126
+ export function resolveSelfHostAutoUpdateDefault(env = process.env) {
127
+ return parseBoolean(env?.HAPPIER_SELF_HOST_AUTO_UPDATE, false);
128
+ }
129
+
130
+ export function resolveSelfHostAutoUpdateIntervalMinutes(env = process.env) {
131
+ const raw = String(env?.HAPPIER_SELF_HOST_AUTO_UPDATE_INTERVAL_MINUTES ?? '').trim();
132
+ if (!raw) return DEFAULTS.autoUpdateIntervalMinutes;
133
+ const parsed = Number(raw);
134
+ const minutes = Number.isFinite(parsed) ? Math.floor(parsed) : NaN;
135
+ return Number.isFinite(minutes) && minutes >= 15
136
+ ? minutes
137
+ : DEFAULTS.autoUpdateIntervalMinutes;
138
+ }
139
+
140
+ export function resolveSelfHostAutoUpdateAt(env = process.env) {
141
+ const raw = String(env?.HAPPIER_SELF_HOST_AUTO_UPDATE_AT ?? '').trim();
142
+ const parsed = parseDailyAtTime(raw);
143
+ return parsed?.normalized || '';
144
+ }
145
+
146
+ export function normalizeSelfHostAutoUpdateState(state, { fallbackIntervalMinutes = DEFAULTS.autoUpdateIntervalMinutes } = {}) {
147
+ const fallbackRaw = Number(fallbackIntervalMinutes);
148
+ const fallback =
149
+ Number.isFinite(fallbackRaw) && Math.floor(fallbackRaw) >= 15
150
+ ? Math.floor(fallbackRaw)
151
+ : DEFAULTS.autoUpdateIntervalMinutes;
152
+
153
+ const raw = state?.autoUpdate;
154
+ if (raw != null && typeof raw === 'object') {
155
+ const enabled = Boolean(raw.enabled);
156
+ const parsed = Number(raw.intervalMinutes);
157
+ const intervalMinutes = Number.isFinite(parsed) && Math.floor(parsed) >= 15 ? Math.floor(parsed) : fallback;
158
+ const at = typeof raw.at === 'string' ? (parseDailyAtTime(raw.at)?.normalized || '') : '';
159
+ return { enabled, intervalMinutes, at };
160
+ }
161
+ if (raw === true || raw === false) {
162
+ return { enabled: raw, intervalMinutes: fallback, at: '' };
163
+ }
164
+ return { enabled: false, intervalMinutes: fallback, at: '' };
165
+ }
166
+
167
+ export function decideSelfHostAutoUpdateReconcile(state, { fallbackIntervalMinutes = DEFAULTS.autoUpdateIntervalMinutes } = {}) {
168
+ const normalized = normalizeSelfHostAutoUpdateState(state, { fallbackIntervalMinutes });
169
+ return {
170
+ action: normalized.enabled ? 'install' : 'uninstall',
171
+ enabled: normalized.enabled,
172
+ intervalMinutes: normalized.intervalMinutes,
173
+ at: normalized.at,
174
+ };
175
+ }
176
+
53
177
  function assertLinux() {
54
178
  if (process.platform !== 'linux') {
55
179
  throw new Error('[self-host] Happier Self-Host currently supports Linux only.');
@@ -82,7 +206,13 @@ function runCommand(cmd, args, { cwd, env, allowFail = false, stdio = 'pipe' } =
82
206
  }
83
207
 
84
208
  function commandExists(cmd) {
85
- const result = runCommand('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1`], { allowFail: true, stdio: 'ignore' });
209
+ const name = String(cmd ?? '').trim();
210
+ if (!name) return false;
211
+ if (process.platform === 'win32') {
212
+ const result = runCommand('where', [name], { allowFail: true, stdio: 'ignore' });
213
+ return (result.status ?? 1) === 0;
214
+ }
215
+ const result = runCommand('sh', ['-lc', `command -v ${name} >/dev/null 2>&1`], { allowFail: true, stdio: 'ignore' });
86
216
  return (result.status ?? 1) === 0;
87
217
  }
88
218
 
@@ -94,6 +224,14 @@ function normalizeArch() {
94
224
  return arch;
95
225
  }
96
226
 
227
+ function normalizeOs(platform = process.platform) {
228
+ const p = String(platform ?? '').trim() || process.platform;
229
+ if (p === 'linux') return 'linux';
230
+ if (p === 'darwin') return 'darwin';
231
+ if (p === 'win32') return 'windows';
232
+ throw new Error(`[self-host] unsupported platform: ${p}`);
233
+ }
234
+
97
235
  function normalizeChannel(raw) {
98
236
  const channel = String(raw ?? '').trim() || 'stable';
99
237
  if (!SUPPORTED_CHANNELS.has(channel)) {
@@ -102,6 +240,13 @@ function normalizeChannel(raw) {
102
240
  return channel;
103
241
  }
104
242
 
243
+ function normalizeMode(raw) {
244
+ const mode = String(raw ?? '').trim().toLowerCase();
245
+ if (!mode) return 'user';
246
+ if (mode === 'user' || mode === 'system') return mode;
247
+ throw new Error(`[self-host] invalid mode: ${mode} (expected user|system)`);
248
+ }
249
+
105
250
  export function parseSelfHostInvocation(argv) {
106
251
  const args = Array.isArray(argv) ? [...argv] : [];
107
252
  if (args[0] === 'self-host' || args[0] === 'selfhost') {
@@ -116,78 +261,154 @@ export function parseSelfHostInvocation(argv) {
116
261
  };
117
262
  }
118
263
 
119
- function escapeRegex(s) {
120
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
121
- }
122
-
123
264
  export function pickReleaseAsset({ assets, product, os, arch }) {
124
- const list = Array.isArray(assets) ? assets : [];
125
- const archiveRe = new RegExp(`^${escapeRegex(product)}-v.+-${escapeRegex(os)}-${escapeRegex(arch)}\\.tar\\.gz$`);
126
- const checksumsRe = new RegExp(`^checksums-${escapeRegex(product)}-v.+\\.txt$`);
127
- const signatureRe = new RegExp(`^checksums-${escapeRegex(product)}-v.+\\.txt\\.minisig$`);
128
-
129
- const archive = list.find((asset) => archiveRe.test(String(asset?.name ?? '')));
130
- const checksums = list.find((asset) => checksumsRe.test(String(asset?.name ?? '')));
131
- const signature = list.find((asset) => signatureRe.test(String(asset?.name ?? '')));
132
- if (!archive || !checksums || !signature) {
133
- throw new Error(
134
- `[self-host] release assets not found for ${product} ${os}-${arch} (archive/checksums/signature missing)`
135
- );
136
- }
137
- const archiveName = String(archive.name);
138
- const versionMatch = archiveName.match(/-v([^-/]+)-/);
265
+ const { version, archive, checksums, checksumsSig } = resolveReleaseAssetBundle({
266
+ assets,
267
+ product,
268
+ os,
269
+ arch,
270
+ });
139
271
  return {
140
- archiveUrl: String(archive.browser_download_url ?? ''),
141
- archiveName,
142
- checksumsUrl: String(checksums.browser_download_url ?? ''),
143
- signatureUrl: String(signature.browser_download_url ?? ''),
144
- version: versionMatch ? versionMatch[1] : '',
272
+ archiveUrl: archive.url,
273
+ archiveName: archive.name,
274
+ checksumsUrl: checksums.url,
275
+ signatureUrl: checksumsSig.url,
276
+ version,
145
277
  };
146
278
  }
147
279
 
148
- async function sha256(path) {
149
- const bytes = await readFile(path);
150
- return createHash('sha256').update(bytes).digest('hex');
280
+ function resolveSqliteDatabaseFilePath(databaseUrl) {
281
+ const raw = String(databaseUrl ?? '').trim();
282
+ if (!raw) return '';
283
+ if (!raw.startsWith('file:')) return '';
284
+ try {
285
+ const url = new URL(raw);
286
+ if (url.protocol !== 'file:') return '';
287
+ const pathname = url.pathname || '';
288
+ // On Windows, URL.pathname can start with /C:/...
289
+ return pathname.startsWith('/') && /^[A-Za-z]:\//.test(pathname.slice(1))
290
+ ? pathname.slice(1)
291
+ : pathname;
292
+ } catch {
293
+ const value = raw.slice('file:'.length);
294
+ return value.startsWith('//') ? value.replace(/^\/+/, '/') : value;
295
+ }
151
296
  }
152
297
 
153
- function parseChecksums(raw) {
154
- const map = new Map();
155
- for (const line of String(raw ?? '').split('\n')) {
156
- const trimmed = line.trim();
157
- if (!trimmed) continue;
158
- const match = /^([a-fA-F0-9]{64})\s{2}(.+)$/.exec(trimmed);
159
- if (!match) continue;
160
- map.set(match[2], match[1].toLowerCase());
298
+ async function applySelfHostSqliteMigrationsAtInstallTime({ env }) {
299
+ if (typeof globalThis.Bun === 'undefined') {
300
+ return { applied: [], skipped: true, reason: 'bun-unavailable' };
301
+ }
302
+ const databaseUrl = String(env?.DATABASE_URL ?? '').trim();
303
+ const migrationsDir = String(env?.HAPPIER_SQLITE_MIGRATIONS_DIR ?? env?.HAPPY_SQLITE_MIGRATIONS_DIR ?? '').trim();
304
+ if (!databaseUrl || !migrationsDir) {
305
+ return { applied: [], skipped: true, reason: 'missing-config' };
306
+ }
307
+ const dbPath = resolveSqliteDatabaseFilePath(databaseUrl);
308
+ if (!dbPath) {
309
+ return { applied: [], skipped: true, reason: 'unsupported-database-url' };
310
+ }
311
+ const migrationsInfo = await stat(migrationsDir).catch(() => null);
312
+ if (!migrationsInfo?.isDirectory()) {
313
+ return { applied: [], skipped: true, reason: 'migrations-dir-missing' };
161
314
  }
162
- return map;
163
- }
164
315
 
165
- async function downloadToFile(url, targetPath) {
166
- const response = await fetch(url, {
167
- headers: {
168
- 'user-agent': 'happier-self-host-installer',
169
- accept: 'application/json',
170
- },
171
- });
172
- if (!response.ok) {
173
- throw new Error(`[self-host] download failed: ${url} (${response.status} ${response.statusText})`);
316
+ const mod = await import('bun:sqlite');
317
+ const Database = mod?.Database;
318
+ if (!Database) {
319
+ return { applied: [], skipped: true, reason: 'bun-sqlite-unavailable' };
174
320
  }
175
- const bytes = Buffer.from(await response.arrayBuffer());
176
- await writeFile(targetPath, bytes);
177
- }
321
+ const db = new Database(dbPath);
322
+ db.exec(
323
+ [
324
+ 'CREATE TABLE IF NOT EXISTS _prisma_migrations (',
325
+ ' id TEXT PRIMARY KEY,',
326
+ ' checksum TEXT NOT NULL,',
327
+ ' finished_at DATETIME,',
328
+ ' migration_name TEXT NOT NULL,',
329
+ ' logs TEXT,',
330
+ ' rolled_back_at DATETIME,',
331
+ ' started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,',
332
+ ' applied_steps_count INTEGER NOT NULL DEFAULT 0',
333
+ ');',
334
+ ].join('\n'),
335
+ );
178
336
 
179
- async function readRelease(tag, githubRepo) {
180
- const url = `https://api.github.com/repos/${githubRepo}/releases/tags/${tag}`;
181
- const response = await fetch(url, {
182
- headers: {
183
- 'user-agent': 'happier-self-host-installer',
184
- accept: 'application/vnd.github+json',
185
- },
186
- });
187
- if (!response.ok) {
188
- throw new Error(`[self-host] failed to resolve GitHub release tag ${tag} (${response.status})`);
337
+ const tableNamesQuery = db.query(`SELECT name FROM sqlite_master WHERE type='table'`);
338
+ const appliedQuery = db.query(
339
+ `SELECT migration_name FROM _prisma_migrations WHERE rolled_back_at IS NULL AND finished_at IS NOT NULL`,
340
+ );
341
+ const insertQuery = db.query(
342
+ `INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name, applied_steps_count) VALUES (?, ?, CURRENT_TIMESTAMP, ?, 1)`,
343
+ );
344
+
345
+ const applied = new Set(
346
+ appliedQuery.all().map((row) => String(row?.migration_name ?? '').trim()).filter(Boolean),
347
+ );
348
+
349
+ const existingTables = new Set(
350
+ tableNamesQuery.all().map((row) => String(row?.name ?? '').trim()).filter(Boolean),
351
+ );
352
+ const hasCoreTables =
353
+ existingTables.has('Account')
354
+ || existingTables.has('account')
355
+ || existingTables.has('accounts');
356
+ const legacyMode = applied.size === 0 && hasCoreTables;
357
+
358
+ const isLikelyAlreadyAppliedError = (err) => {
359
+ const msg = String(err?.message ?? err ?? '').toLowerCase();
360
+ return msg.includes('already exists') || msg.includes('duplicate column') || msg.includes('duplicate');
361
+ };
362
+
363
+ const entries = await readdir(migrationsDir, { withFileTypes: true }).catch(() => []);
364
+ const dirs = entries
365
+ .filter((e) => e.isDirectory())
366
+ .map((e) => e.name)
367
+ .sort((a, b) => a.localeCompare(b));
368
+
369
+ const sha256Hex = (input) => createHash('sha256').update(String(input)).digest('hex');
370
+ const appliedNow = [];
371
+ for (const name of dirs) {
372
+ if (applied.has(name)) continue;
373
+ const sqlPath = join(migrationsDir, name, 'migration.sql');
374
+ const sql = await readFile(sqlPath, 'utf8').catch(() => '');
375
+ if (!sql.trim()) continue;
376
+ const checksum = sha256Hex(sql);
377
+ db.exec('BEGIN');
378
+ try {
379
+ db.exec(sql);
380
+ insertQuery.run(randomUUID(), checksum, name);
381
+ db.exec('COMMIT');
382
+ appliedNow.push(name);
383
+ applied.add(name);
384
+ } catch (e) {
385
+ try {
386
+ db.exec('ROLLBACK');
387
+ } catch {
388
+ // ignore
389
+ }
390
+ if (legacyMode && isLikelyAlreadyAppliedError(e)) {
391
+ db.exec('BEGIN');
392
+ try {
393
+ insertQuery.run(randomUUID(), checksum, name);
394
+ db.exec('COMMIT');
395
+ } catch (inner) {
396
+ try {
397
+ db.exec('ROLLBACK');
398
+ } catch {
399
+ // ignore
400
+ }
401
+ throw inner;
402
+ }
403
+ appliedNow.push(name);
404
+ applied.add(name);
405
+ continue;
406
+ }
407
+ throw e;
408
+ }
189
409
  }
190
- return await response.json();
410
+
411
+ return { applied: appliedNow, skipped: false, reason: 'ok' };
191
412
  }
192
413
 
193
414
  async function findExecutableByName(rootDir, binaryName) {
@@ -202,30 +423,39 @@ async function findExecutableByName(rootDir, binaryName) {
202
423
  if (!entry.isFile()) continue;
203
424
  if (entry.name !== binaryName) continue;
204
425
  const info = await stat(fullPath);
426
+ if (process.platform === 'win32') return fullPath;
205
427
  if ((info.mode & 0o111) !== 0) return fullPath;
206
428
  }
207
429
  return '';
208
430
  }
209
431
 
210
- function resolveConfig({ channel }) {
211
- const installRoot = String(process.env.HAPPIER_SELF_HOST_INSTALL_ROOT ?? DEFAULTS.installRoot).trim();
212
- const binDir = String(process.env.HAPPIER_SELF_HOST_BIN_DIR ?? DEFAULTS.binDir).trim();
213
- const configDir = String(process.env.HAPPIER_SELF_HOST_CONFIG_DIR ?? DEFAULTS.configDir).trim();
214
- const dataDir = String(process.env.HAPPIER_SELF_HOST_DATA_DIR ?? DEFAULTS.dataDir).trim();
215
- const logDir = String(process.env.HAPPIER_SELF_HOST_LOG_DIR ?? DEFAULTS.logDir).trim();
432
+ function resolveConfig({ channel, mode = 'user', platform = process.platform } = {}) {
433
+ const defaults = resolveSelfHostDefaults({ platform, mode, homeDir: homedir() });
434
+ const installRoot = String(process.env.HAPPIER_SELF_HOST_INSTALL_ROOT ?? defaults.installRoot).trim();
435
+ const binDir = String(process.env.HAPPIER_SELF_HOST_BIN_DIR ?? defaults.binDir).trim();
436
+ const configDir = String(process.env.HAPPIER_SELF_HOST_CONFIG_DIR ?? defaults.configDir).trim();
437
+ const dataDir = String(process.env.HAPPIER_SELF_HOST_DATA_DIR ?? defaults.dataDir).trim();
438
+ const logDir = String(process.env.HAPPIER_SELF_HOST_LOG_DIR ?? defaults.logDir).trim();
216
439
  const serviceName = String(process.env.HAPPIER_SELF_HOST_SERVICE_NAME ?? DEFAULTS.serviceName).trim();
217
440
  const serverHost = String(process.env.HAPPIER_SERVER_HOST ?? DEFAULTS.serverHost).trim();
218
441
  const serverPort = parsePort(process.env.HAPPIER_SERVER_PORT, DEFAULTS.serverPort);
219
442
  const githubRepo = String(process.env.HAPPIER_GITHUB_REPO ?? DEFAULTS.githubRepo).trim();
220
- const autoUpdate = parseBoolean(process.env.HAPPIER_SELF_HOST_AUTO_UPDATE, true);
443
+ const autoUpdate = resolveSelfHostAutoUpdateDefault(process.env);
444
+ const autoUpdateIntervalMinutes = resolveSelfHostAutoUpdateIntervalMinutes(process.env);
445
+ const autoUpdateAt = resolveSelfHostAutoUpdateAt(process.env);
446
+ const serverBinaryName = platform === 'win32' ? 'happier-server.exe' : 'happier-server';
447
+ const uiWebRootDir = join(installRoot, 'ui-web');
221
448
 
222
449
  return {
223
450
  channel,
451
+ mode,
452
+ platform,
224
453
  installRoot,
225
454
  versionsDir: join(installRoot, 'versions'),
226
455
  installBinDir: join(installRoot, 'bin'),
227
- serverBinaryPath: join(installRoot, 'bin', 'happier-server'),
228
- serverPreviousBinaryPath: join(installRoot, 'bin', 'happier-server.previous'),
456
+ serverBinaryName,
457
+ serverBinaryPath: join(installRoot, 'bin', serverBinaryName),
458
+ serverPreviousBinaryPath: join(installRoot, 'bin', `${serverBinaryName}.previous`),
229
459
  statePath: join(installRoot, 'self-host-state.json'),
230
460
  binDir,
231
461
  configDir,
@@ -234,27 +464,84 @@ function resolveConfig({ channel }) {
234
464
  filesDir: join(dataDir, 'files'),
235
465
  dbDir: join(dataDir, 'pglite'),
236
466
  logDir,
237
- serverLogPath: join(logDir, 'server.log'),
467
+ serverStdoutLogPath: join(logDir, 'server.out.log'),
468
+ serverStderrLogPath: join(logDir, 'server.err.log'),
238
469
  serviceName,
239
- serviceUnitName: `${serviceName}.service`,
240
- serviceUnitPath: join('/etc/systemd/system', `${serviceName}.service`),
241
- updaterServiceName: `${serviceName}-updater`,
242
- updaterServiceUnitPath: join('/etc/systemd/system', `${serviceName}-updater.service`),
243
- updaterTimerUnitName: `${serviceName}-updater.timer`,
244
- updaterTimerUnitPath: join('/etc/systemd/system', `${serviceName}-updater.timer`),
245
470
  serverHost,
246
471
  serverPort,
247
472
  githubRepo,
248
473
  autoUpdate,
474
+ autoUpdateIntervalMinutes,
475
+ autoUpdateAt,
476
+ uiWebProduct: DEFAULTS.uiWebProduct,
477
+ uiWebOs: DEFAULTS.uiWebOs,
478
+ uiWebArch: DEFAULTS.uiWebArch,
479
+ uiWebRootDir,
480
+ uiWebVersionsDir: join(uiWebRootDir, 'versions'),
481
+ uiWebCurrentDir: join(uiWebRootDir, 'current'),
249
482
  };
250
483
  }
251
484
 
252
- export function renderServerEnvFile({ port, host, dataDir, filesDir, dbDir }) {
485
+ export function renderServerEnvFile({
486
+ port,
487
+ host,
488
+ dataDir,
489
+ filesDir,
490
+ dbDir,
491
+ uiDir,
492
+ serverBinDir = '',
493
+ arch = process.arch,
494
+ platform = process.platform,
495
+ }) {
496
+ const normalizedDataDir = String(dataDir ?? '').replace(/\/+$/, '') || String(dataDir ?? '');
497
+ const p = String(platform ?? '').trim() || process.platform;
498
+ const a = String(arch ?? '').trim() || process.arch;
499
+ const hasBunRuntime = typeof globalThis.Bun !== 'undefined';
500
+ // NOTE: Bun's native sqlite module (`bun:sqlite`) can hang when used inside launchd-managed binaries on macOS.
501
+ // We pre-apply migrations at install time in the self-host installer on that path instead.
502
+ const autoMigrateSqlite = p === 'darwin' && hasBunRuntime ? '0' : '1';
503
+ const migrationsDir =
504
+ p === 'win32'
505
+ ? win32Path.join(String(dataDir ?? ''), 'migrations', 'sqlite')
506
+ : `${normalizedDataDir}/migrations/sqlite`;
507
+ const dbPath =
508
+ p === 'win32'
509
+ ? win32Path.join(String(dataDir ?? ''), 'happier-server-light.sqlite')
510
+ : `${normalizedDataDir}/happier-server-light.sqlite`;
511
+ const databaseUrl =
512
+ p === 'win32'
513
+ ? (() => {
514
+ const normalized = String(dbPath).replaceAll('\\', '/');
515
+ if (/^[a-zA-Z]:\//.test(normalized)) return `file:///${normalized}`;
516
+ if (normalized.startsWith('//')) return `file:${normalized}`;
517
+ return `file:///${normalized}`;
518
+ })()
519
+ : `file:${dbPath}`;
520
+ const uiDirRaw = typeof uiDir === 'string' && uiDir.trim() ? uiDir.trim() : '';
521
+ const serverBinDirRaw = typeof serverBinDir === 'string' && serverBinDir.trim() ? serverBinDir.trim() : '';
522
+ const prismaEngineCandidate = serverBinDirRaw && p === 'darwin' && a === 'arm64'
523
+ ? join(serverBinDirRaw, 'generated', 'sqlite-client', 'libquery_engine-darwin-arm64.dylib.node')
524
+ : serverBinDirRaw && p === 'linux' && a === 'arm64'
525
+ ? join(serverBinDirRaw, 'generated', 'sqlite-client', 'libquery_engine-linux-arm64-openssl-1.1.x.so.node')
526
+ : '';
527
+ const prismaEnginePath = prismaEngineCandidate && existsSync(prismaEngineCandidate) ? prismaEngineCandidate : '';
253
528
  return [
254
529
  `PORT=${port}`,
255
530
  `HAPPIER_SERVER_HOST=${host}`,
531
+ ...(uiDirRaw ? [`HAPPIER_SERVER_UI_DIR=${uiDirRaw}`] : []),
532
+ 'METRICS_ENABLED=false',
533
+ // Bun-compiled server binaries currently exhibit unstable pglite path resolution in systemd environments.
256
534
  'HAPPIER_DB_PROVIDER=sqlite',
535
+ `DATABASE_URL=${databaseUrl}`,
257
536
  'HAPPIER_FILES_BACKEND=local',
537
+ ...(prismaEnginePath
538
+ ? [
539
+ 'PRISMA_CLIENT_ENGINE_TYPE=library',
540
+ `PRISMA_QUERY_ENGINE_LIBRARY=${prismaEnginePath}`,
541
+ ]
542
+ : []),
543
+ `HAPPIER_SQLITE_AUTO_MIGRATE=${autoMigrateSqlite}`,
544
+ `HAPPIER_SQLITE_MIGRATIONS_DIR=${migrationsDir}`,
258
545
  `HAPPIER_SERVER_LIGHT_DATA_DIR=${dataDir}`,
259
546
  `HAPPIER_SERVER_LIGHT_FILES_DIR=${filesDir}`,
260
547
  `HAPPIER_SERVER_LIGHT_DB_DIR=${dbDir}`,
@@ -262,6 +549,252 @@ export function renderServerEnvFile({ port, host, dataDir, filesDir, dbDir }) {
262
549
  ].join('\n');
263
550
  }
264
551
 
552
+ function parseEnvText(raw) {
553
+ const env = {};
554
+ for (const line of String(raw ?? '').split('\n')) {
555
+ const trimmed = line.trim();
556
+ if (!trimmed) continue;
557
+ if (trimmed.startsWith('#')) continue;
558
+ const idx = trimmed.indexOf('=');
559
+ if (idx <= 0) continue;
560
+ const k = trimmed.slice(0, idx).trim();
561
+ const v = trimmed.slice(idx + 1);
562
+ if (!k) continue;
563
+ env[k] = v;
564
+ }
565
+ return env;
566
+ }
567
+
568
+ function listEnvKeysInOrder(raw) {
569
+ const keys = [];
570
+ const seen = new Set();
571
+ for (const line of String(raw ?? '').split('\n')) {
572
+ const trimmed = line.trim();
573
+ if (!trimmed) continue;
574
+ if (trimmed.startsWith('#')) continue;
575
+ const idx = trimmed.indexOf('=');
576
+ if (idx <= 0) continue;
577
+ const k = trimmed.slice(0, idx).trim();
578
+ if (!k || seen.has(k)) continue;
579
+ seen.add(k);
580
+ keys.push(k);
581
+ }
582
+ return keys;
583
+ }
584
+
585
+ function parseEnvKeyValue(raw) {
586
+ const text = String(raw ?? '');
587
+ const idx = text.indexOf('=');
588
+ if (idx <= 0) {
589
+ throw new Error(`[self-host] invalid env assignment (expected KEY=VALUE): ${text}`);
590
+ }
591
+ const key = text.slice(0, idx).trim();
592
+ const value = text.slice(idx + 1);
593
+ return { key, value };
594
+ }
595
+
596
+ function assertValidEnvKey(key) {
597
+ const k = String(key ?? '').trim();
598
+ if (!/^[A-Z][A-Z0-9_]*$/.test(k)) {
599
+ throw new Error(`[self-host] invalid env key: ${k || '(empty)'}`);
600
+ }
601
+ return k;
602
+ }
603
+
604
+ function assertValidEnvValue(value) {
605
+ const v = String(value ?? '');
606
+ if (v.includes('\n') || v.includes('\r')) {
607
+ throw new Error('[self-host] invalid env value (must not contain newlines)');
608
+ }
609
+ return v;
610
+ }
611
+
612
+ export function parseEnvOverridesFromArgv(argv) {
613
+ const args = Array.isArray(argv) ? argv.map(String) : [];
614
+ const overrides = [];
615
+ const rest = [];
616
+
617
+ for (let i = 0; i < args.length; i += 1) {
618
+ const a = args[i] ?? '';
619
+ if (a === '--env') {
620
+ const next = args[i + 1] ?? '';
621
+ if (!next || next.startsWith('--')) {
622
+ throw new Error('[self-host] missing value for --env (expected KEY=VALUE)');
623
+ }
624
+ const parsed = parseEnvKeyValue(next);
625
+ overrides.push({
626
+ key: assertValidEnvKey(parsed.key),
627
+ value: assertValidEnvValue(parsed.value),
628
+ });
629
+ i += 1;
630
+ continue;
631
+ }
632
+ if (a.startsWith('--env=')) {
633
+ const raw = a.slice('--env='.length);
634
+ if (!raw) {
635
+ throw new Error('[self-host] missing value for --env (expected KEY=VALUE)');
636
+ }
637
+ const parsed = parseEnvKeyValue(raw);
638
+ overrides.push({
639
+ key: assertValidEnvKey(parsed.key),
640
+ value: assertValidEnvValue(parsed.value),
641
+ });
642
+ continue;
643
+ }
644
+ rest.push(a);
645
+ }
646
+
647
+ return { overrides, rest };
648
+ }
649
+
650
+ export function applyEnvOverridesToEnvText(envText, overrides) {
651
+ const base = String(envText ?? '');
652
+ const list = Array.isArray(overrides) ? overrides : [];
653
+ if (!base.trim() || list.length === 0) return base.endsWith('\n') ? base : `${base}\n`;
654
+
655
+ const env = parseEnvText(base);
656
+ const keys = listEnvKeysInOrder(base);
657
+ const seen = new Set(keys);
658
+
659
+ for (const entry of list) {
660
+ const key = assertValidEnvKey(entry?.key);
661
+ const value = assertValidEnvValue(entry?.value);
662
+ env[key] = value;
663
+ if (!seen.has(key)) {
664
+ seen.add(key);
665
+ keys.push(key);
666
+ }
667
+ }
668
+
669
+ const lines = [];
670
+ for (const key of keys) {
671
+ if (!Object.prototype.hasOwnProperty.call(env, key)) continue;
672
+ lines.push(`${key}=${env[key]}`);
673
+ }
674
+ lines.push('');
675
+ return `${lines.join('\n')}\n`;
676
+ }
677
+
678
+ export function mergeEnvTextWithDefaults(existingText, defaultsText) {
679
+ const existingRaw = String(existingText ?? '');
680
+ const defaultsRaw = String(defaultsText ?? '');
681
+ if (!existingRaw.trim()) return defaultsRaw.endsWith('\n') ? defaultsRaw : `${defaultsRaw}\n`;
682
+
683
+ const existingEnv = parseEnvText(existingRaw);
684
+ const defaultsEnv = parseEnvText(defaultsRaw);
685
+ const defaultKeys = listEnvKeysInOrder(defaultsRaw);
686
+ const existingKeys = listEnvKeysInOrder(existingRaw);
687
+
688
+ const lines = [];
689
+ for (const key of defaultKeys) {
690
+ const fromExisting = Object.prototype.hasOwnProperty.call(existingEnv, key) ? existingEnv[key] : null;
691
+ const v = fromExisting != null ? fromExisting : defaultsEnv[key];
692
+ if (v == null) continue;
693
+ lines.push(`${key}=${v}`);
694
+ }
695
+ for (const key of existingKeys) {
696
+ if (Object.prototype.hasOwnProperty.call(defaultsEnv, key)) continue;
697
+ if (!Object.prototype.hasOwnProperty.call(existingEnv, key)) continue;
698
+ lines.push(`${key}=${existingEnv[key]}`);
699
+ }
700
+ lines.push('');
701
+ return `${lines.join('\n')}\n`;
702
+ }
703
+
704
+ function identity(s) {
705
+ return String(s ?? '');
706
+ }
707
+
708
+ function formatTriState(value, { yes, no, unknown }) {
709
+ if (value == null) return unknown('unknown');
710
+ return value ? yes('yes') : no('no');
711
+ }
712
+
713
+ function formatHealth(value, { ok, warn }) {
714
+ return value ? ok('ok') : warn('failed');
715
+ }
716
+
717
+ function formatJobState(value, { yes, no, unknown }) {
718
+ if (value == null) return unknown('unknown');
719
+ return value ? yes('enabled') : no('disabled');
720
+ }
721
+
722
+ function formatJobActive(value, { yes, no, unknown }) {
723
+ if (value == null) return unknown('unknown');
724
+ return value ? yes('active') : no('inactive');
725
+ }
726
+
727
+ export function renderSelfHostStatusText(report, { colors = true } = {}) {
728
+ const fmt = colors
729
+ ? {
730
+ label: cyan,
731
+ yes: green,
732
+ no: yellow,
733
+ ok: green,
734
+ warn: yellow,
735
+ unknown: dim,
736
+ dim,
737
+ }
738
+ : {
739
+ label: identity,
740
+ yes: identity,
741
+ no: identity,
742
+ ok: identity,
743
+ warn: identity,
744
+ unknown: identity,
745
+ dim: identity,
746
+ };
747
+
748
+ const channel = String(report?.channel ?? '').trim();
749
+ const mode = String(report?.mode ?? '').trim();
750
+ const serviceName = String(report?.serviceName ?? '').trim();
751
+ const serverUrl = String(report?.serverUrl ?? '').trim();
752
+ const healthy = Boolean(report?.healthy);
753
+ const updatedAt = report?.updatedAt ? String(report.updatedAt) : '';
754
+
755
+ const serviceActive = report?.service?.active ?? null;
756
+ const serviceEnabled = report?.service?.enabled ?? null;
757
+
758
+ const serverVersion = report?.versions?.server ? String(report.versions.server) : '';
759
+ const uiWebVersion = report?.versions?.uiWeb ? String(report.versions.uiWeb) : '';
760
+
761
+ const autoConfiguredEnabled = Boolean(report?.autoUpdate?.configured?.enabled);
762
+ const autoConfiguredInterval = report?.autoUpdate?.configured?.intervalMinutes ?? null;
763
+ const autoConfiguredAtRaw = typeof report?.autoUpdate?.configured?.at === 'string' ? report.autoUpdate.configured.at : '';
764
+ const autoConfiguredAt = parseDailyAtTime(autoConfiguredAtRaw)?.normalized || '';
765
+ const updaterEnabled = report?.autoUpdate?.job?.enabled ?? null;
766
+ const updaterActive = report?.autoUpdate?.job?.active ?? null;
767
+
768
+ const configuredLine = autoConfiguredEnabled
769
+ ? (
770
+ autoConfiguredAt
771
+ ? `configured enabled (daily at ${autoConfiguredAt})`
772
+ : `configured enabled${autoConfiguredInterval ? ` (every ${autoConfiguredInterval}m)` : ''}`
773
+ )
774
+ : 'configured disabled';
775
+
776
+ const jobLine =
777
+ updaterEnabled == null && updaterActive == null
778
+ ? 'job unknown'
779
+ : `job ${formatJobState(updaterEnabled, fmt)}, ${formatJobActive(updaterActive, fmt)}`;
780
+
781
+ return [
782
+ channel ? `${fmt.label('channel')}: ${channel}` : null,
783
+ mode ? `${fmt.label('mode')}: ${mode}` : null,
784
+ serviceName ? `${fmt.label('service')}: ${serviceName}` : null,
785
+ serverUrl ? `${fmt.label('url')}: ${serverUrl}` : null,
786
+ `${fmt.label('health')}: ${formatHealth(healthy, fmt)}`,
787
+ `${fmt.label('active')}: ${formatTriState(serviceActive, fmt)}`,
788
+ `${fmt.label('enabled')}: ${formatTriState(serviceEnabled, fmt)}`,
789
+ `${fmt.label('auto-update')}: ${configuredLine}; ${jobLine}`,
790
+ `${fmt.label('server')}: ${serverVersion || fmt.unknown('unknown')}`,
791
+ `${fmt.label('ui-web')}: ${uiWebVersion || fmt.unknown('unknown')}`,
792
+ updatedAt ? `${fmt.label('updated')}: ${updatedAt}` : null,
793
+ ]
794
+ .filter(Boolean)
795
+ .join('\n');
796
+ }
797
+
265
798
  export function renderServerServiceUnit({ serviceName, binaryPath, envFilePath, workingDirectory, logPath }) {
266
799
  return [
267
800
  '[Unit]',
@@ -286,29 +819,101 @@ export function renderServerServiceUnit({ serviceName, binaryPath, envFilePath,
286
819
  ].join('\n');
287
820
  }
288
821
 
289
- function renderUpdaterServiceUnit({ updaterServiceName, hstackPath, channel }) {
822
+ export function buildSelfHostDoctorChecks(config, { state, commandExists: commandExistsOverride, pathExists } = {}) {
823
+ const cfg = config ?? {};
824
+ const platform = String(cfg.platform ?? process.platform).trim() || process.platform;
825
+ const mode = String(cfg.mode ?? 'user').trim() || 'user';
826
+ const os = normalizeOs(platform);
827
+
828
+ const commandExistsFn = typeof commandExistsOverride === 'function' ? commandExistsOverride : commandExists;
829
+ const pathExistsFn = typeof pathExists === 'function'
830
+ ? pathExists
831
+ : (p) => existsSync(String(p ?? ''));
832
+ const stateObj = state ?? {};
833
+
834
+ const uiExpected = Boolean(stateObj?.uiWeb?.installed);
835
+ const uiIndexPath = cfg.uiWebCurrentDir ? join(String(cfg.uiWebCurrentDir), 'index.html') : '';
836
+
290
837
  return [
291
- '[Unit]',
292
- `Description=${updaterServiceName}`,
293
- 'After=network-online.target',
294
- '',
295
- '[Service]',
296
- 'Type=oneshot',
297
- `ExecStart=${hstackPath} self-host update --channel=${channel} --non-interactive`,
298
- '',
299
- ].join('\n');
838
+ { name: 'platform', ok: ['linux', 'darwin', 'windows'].includes(os) },
839
+ { name: 'mode', ok: mode === 'user' || (mode === 'system' && platform !== 'win32') },
840
+ // We verify minisign signatures using the bundled public key + node:crypto, so no external `minisign` dependency.
841
+ { name: 'tar', ok: commandExistsFn('tar') },
842
+ { name: 'powershell', ok: os === 'windows' ? commandExistsFn('powershell') : true },
843
+ { name: 'systemctl', ok: os === 'linux' ? commandExistsFn('systemctl') : true },
844
+ { name: 'launchctl', ok: os === 'darwin' ? commandExistsFn('launchctl') : true },
845
+ { name: 'schtasks', ok: os === 'windows' ? commandExistsFn('schtasks') : true },
846
+ { name: 'server-binary', ok: cfg.serverBinaryPath ? pathExistsFn(cfg.serverBinaryPath) : false },
847
+ { name: 'server-env', ok: cfg.configEnvPath ? pathExistsFn(cfg.configEnvPath) : false },
848
+ ...(uiExpected
849
+ ? [{ name: 'ui-web', ok: uiIndexPath ? pathExistsFn(uiIndexPath) : false }]
850
+ : []),
851
+ ];
852
+ }
853
+
854
+ export function renderUpdaterSystemdUnit({
855
+ updaterLabel,
856
+ hstackPath,
857
+ channel,
858
+ mode,
859
+ workingDirectory,
860
+ stdoutPath,
861
+ stderrPath,
862
+ wantedBy,
863
+ } = {}) {
864
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
865
+ const hstack = String(hstackPath ?? '').trim();
866
+ if (!hstack) throw new Error('[self-host] missing hstackPath for updater unit');
867
+ const ch = String(channel ?? '').trim() || 'stable';
868
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
869
+ const wd = String(workingDirectory ?? '').trim();
870
+ const out = String(stdoutPath ?? '').trim();
871
+ const err = String(stderrPath ?? '').trim();
872
+ const wb = String(wantedBy ?? '').trim() || 'default.target';
873
+
874
+ return renderSystemdServiceUnit({
875
+ description: `${label} (auto-update)`,
876
+ execStart: [
877
+ hstack,
878
+ 'self-host',
879
+ 'update',
880
+ `--channel=${ch}`,
881
+ `--mode=${m}`,
882
+ '--non-interactive',
883
+ ],
884
+ workingDirectory: wd,
885
+ env: {},
886
+ restart: 'no',
887
+ stdoutPath: out,
888
+ stderrPath: err,
889
+ wantedBy: wb,
890
+ });
300
891
  }
301
892
 
302
- function renderUpdaterTimerUnit({ updaterServiceName, updaterTimerName }) {
893
+ export function renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes = 1440, at } = {}) {
894
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
895
+ const parsedAt = parseDailyAtTime(at);
896
+ const minutesRaw = Number(intervalMinutes);
897
+ const minutes = Number.isFinite(minutesRaw) ? Math.max(15, Math.floor(minutesRaw)) : 1440;
898
+ const timerLines = parsedAt
899
+ ? [
900
+ '[Timer]',
901
+ `OnCalendar=*-*-* ${parsedAt.normalized}:00`,
902
+ `Unit=${label}.service`,
903
+ 'Persistent=true',
904
+ ]
905
+ : [
906
+ '[Timer]',
907
+ 'OnBootSec=5m',
908
+ `OnUnitActiveSec=${minutes}m`,
909
+ `Unit=${label}.service`,
910
+ 'Persistent=true',
911
+ ];
303
912
  return [
304
913
  '[Unit]',
305
- `Description=${updaterTimerName}`,
914
+ `Description=${label} (auto-update timer)`,
306
915
  '',
307
- '[Timer]',
308
- 'OnCalendar=weekly',
309
- 'RandomizedDelaySec=30m',
310
- 'Persistent=true',
311
- `Unit=${updaterServiceName}.service`,
916
+ ...timerLines,
312
917
  '',
313
918
  '[Install]',
314
919
  'WantedBy=timers.target',
@@ -316,15 +921,344 @@ function renderUpdaterTimerUnit({ updaterServiceName, updaterTimerName }) {
316
921
  ].join('\n');
317
922
  }
318
923
 
319
- async function restartAndCheckHealth({ serviceUnitName, serverPort }) {
320
- runCommand('systemctl', ['restart', serviceUnitName], { stdio: 'inherit' });
924
+ export function renderUpdaterLaunchdPlistXml({
925
+ updaterLabel,
926
+ hstackPath,
927
+ channel,
928
+ mode,
929
+ intervalMinutes,
930
+ at,
931
+ workingDirectory,
932
+ stdoutPath,
933
+ stderrPath,
934
+ } = {}) {
935
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
936
+ const hstack = String(hstackPath ?? '').trim();
937
+ if (!hstack) throw new Error('[self-host] missing hstackPath for updater launchd plist');
938
+ const ch = String(channel ?? '').trim() || 'stable';
939
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
940
+ const wd = String(workingDirectory ?? '').trim();
941
+ const out = String(stdoutPath ?? '').trim();
942
+ const err = String(stderrPath ?? '').trim();
943
+ const parsedAt = parseDailyAtTime(at);
944
+ const intervalRaw = Number(intervalMinutes);
945
+ const startIntervalSec = Number.isFinite(intervalRaw) && intervalRaw > 0 ? Math.max(15, Math.floor(intervalRaw)) * 60 : 0;
946
+
947
+ return buildLaunchdPlistXml({
948
+ label,
949
+ programArgs: [
950
+ hstack,
951
+ 'self-host',
952
+ 'update',
953
+ `--channel=${ch}`,
954
+ `--mode=${m}`,
955
+ '--non-interactive',
956
+ ],
957
+ env: {
958
+ PATH: buildLaunchdPath({ execPath: process.execPath, basePath: process.env.PATH }),
959
+ },
960
+ stdoutPath: out,
961
+ stderrPath: err,
962
+ workingDirectory: wd,
963
+ keepAliveOnFailure: false,
964
+ ...(parsedAt
965
+ ? { startCalendarInterval: { hour: parsedAt.hour, minute: parsedAt.minute } }
966
+ : { startIntervalSec: startIntervalSec || undefined }),
967
+ });
968
+ }
969
+
970
+ export function renderUpdaterScheduledTaskWrapperPs1({
971
+ updaterLabel,
972
+ hstackPath,
973
+ channel,
974
+ mode,
975
+ workingDirectory,
976
+ stdoutPath,
977
+ stderrPath,
978
+ } = {}) {
979
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
980
+ const hstack = String(hstackPath ?? '').trim();
981
+ if (!hstack) throw new Error('[self-host] missing hstackPath for updater scheduled task wrapper');
982
+ const ch = String(channel ?? '').trim() || 'stable';
983
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
984
+ const wd = String(workingDirectory ?? '').trim();
985
+ const out = String(stdoutPath ?? '').trim();
986
+ const err = String(stderrPath ?? '').trim();
987
+
988
+ return renderWindowsScheduledTaskWrapperPs1({
989
+ workingDirectory: wd,
990
+ programArgs: [
991
+ hstack,
992
+ 'self-host',
993
+ 'update',
994
+ `--channel=${ch}`,
995
+ `--mode=${m}`,
996
+ '--non-interactive',
997
+ ],
998
+ env: {},
999
+ stdoutPath: out,
1000
+ stderrPath: err,
1001
+ });
1002
+ }
1003
+
1004
+ export function buildUpdaterScheduledTaskCreateArgs({ backend, taskName, definitionPath, intervalMinutes = 1440, at } = {}) {
1005
+ const b = String(backend ?? '').trim();
1006
+ const name = String(taskName ?? '').trim();
1007
+ const definition = String(definitionPath ?? '').trim();
1008
+ if (!name) throw new Error('[self-host] missing taskName for updater scheduled task');
1009
+ if (!definition) throw new Error('[self-host] missing definitionPath for updater scheduled task');
1010
+
1011
+ const parsedAt = parseDailyAtTime(at);
1012
+ const minutesRaw = Number(intervalMinutes);
1013
+ const minutes = Number.isFinite(minutesRaw) ? Math.max(15, Math.floor(minutesRaw)) : 1440;
1014
+ const ps = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${definition}"`;
1015
+
1016
+ return [
1017
+ '/Create',
1018
+ '/F',
1019
+ '/SC',
1020
+ parsedAt ? 'DAILY' : 'MINUTE',
1021
+ ...(parsedAt ? [] : ['/MO', String(minutes)]),
1022
+ '/ST',
1023
+ parsedAt ? parsedAt.normalized : '00:00',
1024
+ '/TN',
1025
+ name,
1026
+ '/TR',
1027
+ ps,
1028
+ ...(b === 'schtasks-system' ? ['/RU', 'SYSTEM', '/RL', 'HIGHEST'] : []),
1029
+ ];
1030
+ }
1031
+
1032
+ function resolveAutoUpdateEnabled(argv, fallback) {
1033
+ const args = Array.isArray(argv) ? argv.map(String) : [];
1034
+ if (args.includes('--no-auto-update')) return false;
1035
+ if (args.includes('--auto-update')) return true;
1036
+ return Boolean(fallback);
1037
+ }
1038
+
1039
+ function resolveAutoUpdateIntervalMinutes(argv, fallback) {
1040
+ const args = Array.isArray(argv) ? argv.map(String) : [];
1041
+ const findEq = args.find((a) => a.startsWith('--auto-update-interval='));
1042
+ const value = findEq
1043
+ ? findEq.slice('--auto-update-interval='.length)
1044
+ : (() => {
1045
+ const idx = args.indexOf('--auto-update-interval');
1046
+ if (idx >= 0 && args[idx + 1] && !String(args[idx + 1]).startsWith('-')) return String(args[idx + 1]);
1047
+ return '';
1048
+ })();
1049
+ const raw = String(value ?? '').trim();
1050
+ if (!raw) return Number(fallback) || DEFAULTS.autoUpdateIntervalMinutes;
1051
+ const parsed = Number(raw);
1052
+ const minutes = Number.isFinite(parsed) ? Math.floor(parsed) : NaN;
1053
+ if (!Number.isFinite(minutes) || minutes < 15) return Number(fallback) || DEFAULTS.autoUpdateIntervalMinutes;
1054
+ return Math.min(minutes, 60 * 24 * 7);
1055
+ }
1056
+
1057
+ function resolveAutoUpdateAt(argv, fallback) {
1058
+ const args = Array.isArray(argv) ? argv.map(String) : [];
1059
+ const findEq = args.find((a) => a.startsWith('--auto-update-at='));
1060
+ const value = findEq
1061
+ ? findEq.slice('--auto-update-at='.length)
1062
+ : (() => {
1063
+ const idx = args.indexOf('--auto-update-at');
1064
+ if (idx >= 0 && args[idx + 1] && !String(args[idx + 1]).startsWith('-')) return String(args[idx + 1]);
1065
+ return '';
1066
+ })();
1067
+ const raw = String(value ?? '').trim();
1068
+ if (!raw) return String(fallback ?? '').trim();
1069
+ const parsed = parseDailyAtTime(raw);
1070
+ if (!parsed) {
1071
+ throw new Error(`[self-host] invalid --auto-update-at value: ${raw} (expected HH:MM)`);
1072
+ }
1073
+ return parsed.normalized;
1074
+ }
1075
+
1076
+ function resolveUpdaterLabel(config) {
1077
+ const override = String(process.env.HAPPIER_SELF_HOST_UPDATER_LABEL ?? '').trim();
1078
+ if (override) return override;
1079
+ const base = String(config?.serviceName ?? '').trim() || 'happier-server';
1080
+ return `${base}-updater`;
1081
+ }
1082
+
1083
+ function resolveHstackPathForUpdater(config) {
1084
+ const override = String(process.env.HAPPIER_SELF_HOST_HSTACK_PATH ?? '').trim();
1085
+ if (override) return override;
1086
+ const platform = String(config?.platform ?? '').trim() || process.platform;
1087
+ const exe = platform === 'win32' ? 'hstack.exe' : 'hstack';
1088
+ return join(String(config?.binDir ?? '').trim() || '', exe);
1089
+ }
1090
+
1091
+ async function installAutoUpdateJob({ config, enabled, intervalMinutes, at }) {
1092
+ if (!enabled) return { installed: false, reason: 'disabled' };
1093
+ const updaterLabel = resolveUpdaterLabel(config);
1094
+ const hstackPath = resolveHstackPathForUpdater(config);
1095
+ const stdoutPath = join(config.logDir, 'updater.out.log');
1096
+ const stderrPath = join(config.logDir, 'updater.err.log');
1097
+ const backend = resolveServiceBackend({ platform: config.platform, mode: config.mode });
1098
+ const interval = resolveAutoUpdateIntervalMinutes([], intervalMinutes ?? config.autoUpdateIntervalMinutes);
1099
+ const effectiveAt = resolveAutoUpdateAt([], at ?? config.autoUpdateAt ?? '');
1100
+
1101
+ const baseSpec = {
1102
+ label: updaterLabel,
1103
+ description: `Happier Self-Host (${updaterLabel})`,
1104
+ programArgs: [hstackPath],
1105
+ workingDirectory: config.installRoot,
1106
+ env: {},
1107
+ stdoutPath,
1108
+ stderrPath,
1109
+ };
1110
+ const definitionPath = buildServiceDefinition({ backend, homeDir: homedir(), spec: baseSpec }).path;
1111
+ const wantedBy =
1112
+ backend === 'systemd-system' ? 'multi-user.target' : backend === 'systemd-user' ? 'default.target' : '';
1113
+
1114
+ if (backend === 'systemd-system' || backend === 'systemd-user') {
1115
+ const timerPath = definitionPath.replace(/\.service$/, '.timer');
1116
+ const serviceContents = renderUpdaterSystemdUnit({
1117
+ updaterLabel,
1118
+ hstackPath,
1119
+ channel: config.channel,
1120
+ mode: config.mode,
1121
+ workingDirectory: config.installRoot,
1122
+ stdoutPath,
1123
+ stderrPath,
1124
+ wantedBy,
1125
+ });
1126
+ const timerContents = renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes: interval, at: effectiveAt });
1127
+ const prefix = backend === 'systemd-user' ? ['--user'] : [];
1128
+ const plan = {
1129
+ writes: [
1130
+ { path: definitionPath, contents: serviceContents, mode: 0o644 },
1131
+ { path: timerPath, contents: timerContents, mode: 0o644 },
1132
+ ],
1133
+ commands: [
1134
+ { cmd: 'systemctl', args: [...prefix, 'daemon-reload'] },
1135
+ { cmd: 'systemctl', args: [...prefix, 'enable', '--now', `${updaterLabel}.timer`] },
1136
+ { cmd: 'systemctl', args: [...prefix, 'start', `${updaterLabel}.service`], allowFail: true },
1137
+ ],
1138
+ };
1139
+ await applyServicePlan(plan);
1140
+ return { installed: true, backend, label: updaterLabel, definitionPath, timerPath, intervalMinutes: interval, at: effectiveAt };
1141
+ }
1142
+
1143
+ if (backend === 'launchd-system' || backend === 'launchd-user') {
1144
+ const definitionContents = renderUpdaterLaunchdPlistXml({
1145
+ updaterLabel,
1146
+ hstackPath,
1147
+ channel: config.channel,
1148
+ mode: config.mode,
1149
+ intervalMinutes: interval,
1150
+ at: effectiveAt,
1151
+ workingDirectory: config.installRoot,
1152
+ stdoutPath,
1153
+ stderrPath,
1154
+ });
1155
+ const plan = planServiceAction({
1156
+ backend,
1157
+ action: 'install',
1158
+ label: updaterLabel,
1159
+ definitionPath,
1160
+ definitionContents,
1161
+ persistent: true,
1162
+ });
1163
+ await applyServicePlan(plan);
1164
+ return { installed: true, backend, label: updaterLabel, definitionPath, intervalMinutes: interval, at: effectiveAt };
1165
+ }
1166
+
1167
+ const definitionContents = renderUpdaterScheduledTaskWrapperPs1({
1168
+ updaterLabel,
1169
+ hstackPath,
1170
+ channel: config.channel,
1171
+ mode: config.mode,
1172
+ workingDirectory: config.installRoot,
1173
+ stdoutPath,
1174
+ stderrPath,
1175
+ });
1176
+ const name = `Happier\\${updaterLabel}`;
1177
+ const args = buildUpdaterScheduledTaskCreateArgs({
1178
+ backend,
1179
+ taskName: name,
1180
+ definitionPath,
1181
+ intervalMinutes: interval,
1182
+ at: effectiveAt,
1183
+ });
1184
+ const plan = {
1185
+ writes: [{ path: definitionPath, contents: definitionContents, mode: 0o644 }],
1186
+ commands: [
1187
+ { cmd: 'schtasks', args },
1188
+ { cmd: 'schtasks', args: ['/Run', '/TN', name] },
1189
+ ],
1190
+ };
1191
+ await applyServicePlan(plan);
1192
+ return { installed: true, backend, label: updaterLabel, definitionPath, taskName: name, intervalMinutes: interval, at: effectiveAt };
1193
+ }
1194
+
1195
+ async function uninstallAutoUpdateJob({ config }) {
1196
+ const updaterLabel = resolveUpdaterLabel(config);
1197
+ const hstackPath = resolveHstackPathForUpdater(config);
1198
+ const stdoutPath = join(config.logDir, 'updater.out.log');
1199
+ const stderrPath = join(config.logDir, 'updater.err.log');
1200
+ const backend = resolveServiceBackend({ platform: config.platform, mode: config.mode });
1201
+ const baseSpec = {
1202
+ label: updaterLabel,
1203
+ description: `Happier Self-Host (${updaterLabel})`,
1204
+ programArgs: [hstackPath],
1205
+ workingDirectory: config.installRoot,
1206
+ env: {},
1207
+ stdoutPath,
1208
+ stderrPath,
1209
+ };
1210
+ const definitionPath = buildServiceDefinition({ backend, homeDir: homedir(), spec: baseSpec }).path;
1211
+
1212
+ if (backend === 'systemd-system' || backend === 'systemd-user') {
1213
+ const prefix = backend === 'systemd-user' ? ['--user'] : [];
1214
+ const timerPath = definitionPath.replace(/\.service$/, '.timer');
1215
+ const plan = {
1216
+ writes: [],
1217
+ commands: [
1218
+ { cmd: 'systemctl', args: [...prefix, 'disable', '--now', `${updaterLabel}.timer`], allowFail: true },
1219
+ { cmd: 'systemctl', args: [...prefix, 'disable', '--now', `${updaterLabel}.service`], allowFail: true },
1220
+ { cmd: 'systemctl', args: [...prefix, 'daemon-reload'] },
1221
+ ],
1222
+ };
1223
+ await applyServicePlan(plan);
1224
+ await rm(timerPath, { force: true }).catch(() => {});
1225
+ await rm(definitionPath, { force: true }).catch(() => {});
1226
+ return { uninstalled: true, backend, label: updaterLabel };
1227
+ }
1228
+
1229
+ if (backend === 'launchd-system' || backend === 'launchd-user') {
1230
+ const plan = planServiceAction({
1231
+ backend,
1232
+ action: 'uninstall',
1233
+ label: updaterLabel,
1234
+ definitionPath,
1235
+ persistent: true,
1236
+ });
1237
+ await applyServicePlan(plan);
1238
+ await rm(definitionPath, { force: true }).catch(() => {});
1239
+ return { uninstalled: true, backend, label: updaterLabel };
1240
+ }
1241
+
1242
+ const name = `Happier\\${updaterLabel}`;
1243
+ const plan = {
1244
+ writes: [],
1245
+ commands: [
1246
+ { cmd: 'schtasks', args: ['/End', '/TN', name], allowFail: true },
1247
+ { cmd: 'schtasks', args: ['/Delete', '/F', '/TN', name], allowFail: true },
1248
+ ],
1249
+ };
1250
+ await applyServicePlan(plan);
1251
+ await rm(definitionPath, { force: true }).catch(() => {});
1252
+ return { uninstalled: true, backend, label: updaterLabel };
1253
+ }
1254
+
1255
+ async function restartAndCheckHealth({ config, serviceSpec }) {
1256
+ await restartManagedService({ platform: config.platform, mode: config.mode, spec: serviceSpec }).catch(() => {});
1257
+ const timeoutMs = resolveSelfHostHealthTimeoutMs();
321
1258
  const startedAt = Date.now();
322
- while (Date.now() - startedAt < 45_000) {
323
- const active = runCommand('systemctl', ['is-active', '--quiet', serviceUnitName], { allowFail: true, stdio: 'ignore' });
324
- if ((active.status ?? 1) === 0) {
325
- const ok = await checkHealth({ port: serverPort });
326
- if (ok) return true;
327
- }
1259
+ while (Date.now() - startedAt < timeoutMs) {
1260
+ const ok = await checkHealth({ port: config.serverPort });
1261
+ if (ok) return true;
328
1262
  await new Promise((resolve) => setTimeout(resolve, 1500));
329
1263
  }
330
1264
  return false;
@@ -343,53 +1277,9 @@ async function checkHealth({ port }) {
343
1277
  }
344
1278
  }
345
1279
 
346
- async function resolveMinisignPublicKey() {
347
- const inline = String(process.env.HAPPIER_MINISIGN_PUBKEY ?? '').trim();
348
- if (inline) return inline;
349
- const publicKeyUrl = String(
350
- process.env.HAPPIER_MINISIGN_PUBKEY_URL ?? DEFAULTS.minisignPubKeyUrl
351
- ).trim();
352
- if (!publicKeyUrl) {
353
- throw new Error('[self-host] HAPPIER_MINISIGN_PUBKEY_URL is empty');
354
- }
355
- const response = await fetch(publicKeyUrl, {
356
- headers: {
357
- 'user-agent': 'happier-self-host-installer',
358
- accept: 'text/plain,application/octet-stream;q=0.9,*/*;q=0.8',
359
- },
360
- });
361
- if (!response.ok) {
362
- throw new Error(
363
- `[self-host] failed to download minisign public key (${response.status} ${response.statusText})`
364
- );
365
- }
366
- const raw = String(await response.text()).trim();
367
- if (!raw) {
368
- throw new Error('[self-host] downloaded minisign public key was empty');
369
- }
370
- return raw;
371
- }
372
-
373
- async function verifySignature({ checksumsPath, signatureUrl, publicKey }) {
374
- if (!signatureUrl) {
375
- throw new Error('[self-host] release signature URL is missing');
376
- }
377
- if (!publicKey) {
378
- throw new Error('[self-host] minisign public key is missing');
379
- }
380
- if (!commandExists('minisign')) {
381
- throw new Error('[self-host] minisign is required for self-host signature verification');
382
- }
383
- const tmp = await mkdtemp(join(tmpdir(), 'happier-self-host-signature-'));
384
- const pubKeyPath = join(tmp, 'minisign.pub');
385
- const signaturePath = join(tmp, 'checksums.txt.minisig');
386
- try {
387
- await writeFile(pubKeyPath, `${publicKey}\n`, 'utf-8');
388
- await downloadToFile(signatureUrl, signaturePath);
389
- runCommand('minisign', ['-Vm', checksumsPath, '-x', signaturePath, '-p', pubKeyPath], { stdio: 'ignore' });
390
- } finally {
391
- await rm(tmp, { recursive: true, force: true });
392
- }
1280
+ export function resolveMinisignPublicKeyText(env = process.env) {
1281
+ const inline = String(env?.HAPPIER_MINISIGN_PUBKEY ?? '').trim();
1282
+ return inline || DEFAULT_MINISIGN_PUBLIC_KEY;
393
1283
  }
394
1284
 
395
1285
  async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, previousBinaryPath, versionedTargetPath }) {
@@ -397,16 +1287,117 @@ async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, pre
397
1287
  await mkdir(dirname(versionedTargetPath), { recursive: true });
398
1288
  const stagedPath = `${targetBinaryPath}.new`;
399
1289
  await copyFile(sourceBinaryPath, stagedPath);
400
- await chmod(stagedPath, 0o755);
1290
+ await chmod(stagedPath, 0o755).catch(() => {});
401
1291
  if (existsSync(targetBinaryPath)) {
402
1292
  await copyFile(targetBinaryPath, previousBinaryPath);
403
- await chmod(previousBinaryPath, 0o755);
1293
+ await chmod(previousBinaryPath, 0o755).catch(() => {});
404
1294
  }
405
1295
  await copyFile(stagedPath, versionedTargetPath);
406
- await chmod(versionedTargetPath, 0o755);
1296
+ await chmod(versionedTargetPath, 0o755).catch(() => {});
407
1297
  await rm(stagedPath, { force: true });
408
1298
  await copyFile(versionedTargetPath, targetBinaryPath);
409
- await chmod(targetBinaryPath, 0o755);
1299
+ await chmod(targetBinaryPath, 0o755).catch(() => {});
1300
+ }
1301
+
1302
+ async function syncSelfHostSqliteMigrations({ artifactRootDir, targetDir }) {
1303
+ const root = String(artifactRootDir ?? '').trim();
1304
+ const dest = String(targetDir ?? '').trim();
1305
+ if (!root || !dest) return { copied: false, reason: 'missing-paths' };
1306
+
1307
+ const source = join(root, 'prisma', 'sqlite', 'migrations');
1308
+ if (!existsSync(source)) return { copied: false, reason: 'missing-source' };
1309
+
1310
+ await rm(dest, { recursive: true, force: true });
1311
+ await mkdir(dirname(dest), { recursive: true });
1312
+ await cp(source, dest, { recursive: true });
1313
+ return { copied: true, reason: 'ok' };
1314
+ }
1315
+
1316
+ async function syncSelfHostGeneratedClients({ artifactRootDir, targetDir }) {
1317
+ const root = String(artifactRootDir ?? '').trim();
1318
+ const dest = String(targetDir ?? '').trim();
1319
+ if (!root || !dest) return { copied: false, reason: 'missing-paths' };
1320
+
1321
+ const source = join(root, 'generated');
1322
+ if (!existsSync(source)) return { copied: false, reason: 'missing-source' };
1323
+
1324
+ await rm(dest, { recursive: true, force: true });
1325
+ await mkdir(dirname(dest), { recursive: true });
1326
+ await cp(source, dest, { recursive: true });
1327
+ return { copied: true, reason: 'ok' };
1328
+ }
1329
+
1330
+ export async function installSelfHostBinaryFromBundle({
1331
+ bundle,
1332
+ binaryName,
1333
+ config,
1334
+ pubkeyFile = resolveMinisignPublicKeyText(process.env),
1335
+ userAgent = 'happier-self-host-installer',
1336
+ } = {}) {
1337
+ const resolvedBundle = bundle;
1338
+ const name = String(binaryName ?? '').trim();
1339
+ if (!resolvedBundle?.archive?.url || !resolvedBundle?.archive?.name) {
1340
+ throw new Error('[self-host] invalid release bundle (missing archive)');
1341
+ }
1342
+ if (!resolvedBundle?.checksums?.url || !resolvedBundle?.checksumsSig?.url) {
1343
+ throw new Error('[self-host] invalid release bundle (missing checksums assets)');
1344
+ }
1345
+ if (!name) {
1346
+ throw new Error('[self-host] missing binary name');
1347
+ }
1348
+ const platform = String(config?.platform ?? process.platform).trim() || process.platform;
1349
+ const os = normalizeOs(platform);
1350
+
1351
+ const tempDir = await mkdtemp(join(tmpdir(), 'happier-self-host-release-'));
1352
+ try {
1353
+ const downloaded = await downloadVerifiedReleaseAssetBundle({
1354
+ bundle: resolvedBundle,
1355
+ destDir: tempDir,
1356
+ pubkeyFile,
1357
+ userAgent,
1358
+ });
1359
+
1360
+ const extractDir = join(tempDir, 'extract');
1361
+ await mkdir(extractDir, { recursive: true });
1362
+ const plan = planArchiveExtraction({
1363
+ archiveName: downloaded.archiveName,
1364
+ archivePath: downloaded.archivePath,
1365
+ destDir: extractDir,
1366
+ os,
1367
+ });
1368
+ if (!commandExists(plan.requiredCommand)) {
1369
+ throw new Error(`[self-host] ${plan.requiredCommand} is required to extract release artifacts`);
1370
+ }
1371
+ runCommand(plan.command.cmd, plan.command.args, { stdio: 'ignore' });
1372
+ const extractedBinary = await findExecutableByName(extractDir, name);
1373
+ if (!extractedBinary) {
1374
+ throw new Error('[self-host] failed to locate extracted server binary');
1375
+ }
1376
+
1377
+ const version = downloaded.version || String(resolvedBundle?.version ?? '').trim() || `${Date.now()}`;
1378
+ await installBinaryAtomically({
1379
+ sourceBinaryPath: extractedBinary,
1380
+ targetBinaryPath: config.serverBinaryPath,
1381
+ previousBinaryPath: config.serverPreviousBinaryPath,
1382
+ versionedTargetPath: join(config.versionsDir, `${name}-${version}`),
1383
+ });
1384
+ const roots = await readdir(extractDir).catch(() => []);
1385
+ const artifactRootDir = roots.length > 0 ? join(extractDir, roots[0]) : extractDir;
1386
+ await syncSelfHostSqliteMigrations({
1387
+ artifactRootDir,
1388
+ targetDir: join(config.dataDir, 'migrations', 'sqlite'),
1389
+ }).catch(() => {});
1390
+ const generated = await syncSelfHostGeneratedClients({
1391
+ artifactRootDir,
1392
+ targetDir: join(dirname(config.serverBinaryPath), 'generated'),
1393
+ });
1394
+ if (!generated.copied) {
1395
+ throw new Error('[self-host] server runtime is missing packaged generated clients');
1396
+ }
1397
+ return { version, source: resolvedBundle.archive.url };
1398
+ } finally {
1399
+ await rm(tempDir, { recursive: true, force: true });
1400
+ }
410
1401
  }
411
1402
 
412
1403
  async function installFromRelease({ product, binaryName, config, explicitBinaryPath = '' }) {
@@ -422,57 +1413,127 @@ async function installFromRelease({ product, binaryName, config, explicitBinaryP
422
1413
  previousBinaryPath: config.serverPreviousBinaryPath,
423
1414
  versionedTargetPath: join(config.versionsDir, `${binaryName}-${version}`),
424
1415
  });
1416
+ await syncSelfHostSqliteMigrations({
1417
+ artifactRootDir: dirname(srcPath),
1418
+ targetDir: join(config.dataDir, 'migrations', 'sqlite'),
1419
+ }).catch(() => {});
1420
+ const generated = await syncSelfHostGeneratedClients({
1421
+ artifactRootDir: dirname(srcPath),
1422
+ targetDir: join(dirname(config.serverBinaryPath), 'generated'),
1423
+ });
1424
+ if (!generated.copied) {
1425
+ throw new Error('[self-host] server runtime is missing packaged generated clients');
1426
+ }
425
1427
  return { version, source: 'local' };
426
1428
  }
427
1429
 
428
1430
  const channelTag = config.channel === 'preview' ? 'server-preview' : 'server-stable';
429
- const release = await readRelease(channelTag, config.githubRepo);
430
- const asset = pickReleaseAsset({
1431
+ const release = await fetchGitHubReleaseByTag({
1432
+ githubRepo: config.githubRepo,
1433
+ tag: channelTag,
1434
+ userAgent: 'happier-self-host-installer',
1435
+ githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
1436
+ });
1437
+ const os = normalizeOs(config.platform);
1438
+ const resolved = resolveReleaseAssetBundle({
431
1439
  assets: release?.assets,
432
1440
  product,
433
- os: 'linux',
1441
+ os,
434
1442
  arch: normalizeArch(),
435
1443
  });
1444
+ const result = await installSelfHostBinaryFromBundle({
1445
+ bundle: resolved,
1446
+ binaryName,
1447
+ config,
1448
+ pubkeyFile: resolveMinisignPublicKeyText(process.env),
1449
+ userAgent: 'happier-self-host-installer',
1450
+ });
1451
+ return { version: result.version || resolved.version || String(release?.tag_name ?? '').replace(/^server-v/, ''), source: result.source };
1452
+ }
436
1453
 
437
- const tempDir = await mkdtemp(join(tmpdir(), 'happier-self-host-release-'));
438
- try {
439
- const archivePath = join(tempDir, 'artifact.tar.gz');
440
- const checksumsPath = join(tempDir, 'checksums.txt');
441
- await downloadToFile(asset.archiveUrl, archivePath);
442
- await downloadToFile(asset.checksumsUrl, checksumsPath);
443
- const checksumsMap = parseChecksums(await readFile(checksumsPath, 'utf-8'));
444
- const expected = checksumsMap.get(asset.archiveName);
445
- if (!expected) {
446
- throw new Error(`[self-host] checksum entry missing for ${asset.archiveName}`);
447
- }
448
- const actual = await sha256(archivePath);
449
- if (actual !== expected) {
450
- throw new Error('[self-host] checksum verification failed for downloaded server artifact');
451
- }
1454
+ async function assertUiWebBundleIsValid(rootDir) {
1455
+ const indexPath = join(rootDir, 'index.html');
1456
+ const info = await stat(indexPath).catch(() => null);
1457
+ if (!info?.isFile()) {
1458
+ throw new Error(`[self-host] UI web bundle is missing index.html: ${indexPath}`);
1459
+ }
1460
+ }
1461
+
1462
+ async function installUiWebFromRelease({ config }) {
1463
+ const tags = config.channel === 'preview'
1464
+ ? ['ui-web-preview', 'ui-web-stable']
1465
+ : ['ui-web-stable'];
1466
+
1467
+ const resolvedRelease = await fetchFirstGitHubReleaseByTags({
1468
+ githubRepo: config.githubRepo,
1469
+ tags,
1470
+ userAgent: 'happier-self-host-installer',
1471
+ githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
1472
+ }).catch((e) => {
1473
+ const status = Number(e?.status);
1474
+ if (status === 404) return null;
1475
+ throw e;
1476
+ });
1477
+
1478
+ if (!resolvedRelease) {
1479
+ return {
1480
+ installed: false,
1481
+ version: null,
1482
+ source: null,
1483
+ reason: `ui web release tag not found (${tags.join(', ')})`,
1484
+ };
1485
+ }
1486
+ const { release, tag: channelTag } = resolvedRelease;
1487
+
1488
+ const resolved = resolveReleaseAssetBundle({
1489
+ assets: release?.assets,
1490
+ product: config.uiWebProduct,
1491
+ os: config.uiWebOs,
1492
+ arch: config.uiWebArch,
1493
+ });
452
1494
 
453
- const publicKey = await resolveMinisignPublicKey();
454
- await verifySignature({
455
- checksumsPath,
456
- signatureUrl: asset.signatureUrl,
457
- publicKey,
1495
+ const tempDir = await mkdtemp(join(tmpdir(), 'happier-self-host-ui-web-'));
1496
+ try {
1497
+ const pubkeyFile = resolveMinisignPublicKeyText(process.env);
1498
+ const downloaded = await downloadVerifiedReleaseAssetBundle({
1499
+ bundle: resolved,
1500
+ destDir: tempDir,
1501
+ pubkeyFile,
1502
+ userAgent: 'happier-self-host-installer',
458
1503
  });
459
1504
 
460
1505
  const extractDir = join(tempDir, 'extract');
461
1506
  await mkdir(extractDir, { recursive: true });
462
- runCommand('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'ignore' });
463
- const extractedBinary = await findExecutableByName(extractDir, binaryName);
464
- if (!extractedBinary) {
465
- throw new Error('[self-host] failed to locate extracted server binary');
1507
+ const plan = planArchiveExtraction({
1508
+ archiveName: downloaded.archiveName,
1509
+ archivePath: downloaded.archivePath,
1510
+ destDir: extractDir,
1511
+ os: normalizeOs(config.platform),
1512
+ });
1513
+ if (!commandExists(plan.requiredCommand)) {
1514
+ throw new Error(`[self-host] ${plan.requiredCommand} is required to extract ui web bundle artifacts`);
466
1515
  }
1516
+ runCommand(plan.command.cmd, plan.command.args, { stdio: 'ignore' });
467
1517
 
468
- const version = asset.version || String(release?.tag_name ?? '').replace(/^server-v/, '') || `${Date.now()}`;
469
- await installBinaryAtomically({
470
- sourceBinaryPath: extractedBinary,
471
- targetBinaryPath: config.serverBinaryPath,
472
- previousBinaryPath: config.serverPreviousBinaryPath,
473
- versionedTargetPath: join(config.versionsDir, `${binaryName}-${version}`),
1518
+ const roots = await readdir(extractDir).catch(() => []);
1519
+ if (roots.length === 0) {
1520
+ throw new Error('[self-host] extracted ui web bundle is empty');
1521
+ }
1522
+ const artifactRootDir = join(extractDir, roots[0]);
1523
+ await assertUiWebBundleIsValid(artifactRootDir);
1524
+
1525
+ const version = resolved.version || String(release?.tag_name ?? '').replace(/^ui-web-v/, '') || `${Date.now()}`;
1526
+ const versionedTargetDir = join(config.uiWebVersionsDir, `${config.uiWebProduct}-${version}`);
1527
+ await rm(versionedTargetDir, { recursive: true, force: true });
1528
+ await mkdir(dirname(versionedTargetDir), { recursive: true });
1529
+ await cp(artifactRootDir, versionedTargetDir, { recursive: true });
1530
+
1531
+ await rm(config.uiWebCurrentDir, { recursive: true, force: true }).catch(() => {});
1532
+ await symlink(versionedTargetDir, config.uiWebCurrentDir, config.platform === 'win32' ? 'junction' : 'dir').catch(async () => {
1533
+ await cp(versionedTargetDir, config.uiWebCurrentDir, { recursive: true });
474
1534
  });
475
- return { version, source: asset.archiveUrl };
1535
+
1536
+ return { installed: true, version, source: downloaded.source.archiveUrl, tag: channelTag };
476
1537
  } finally {
477
1538
  await rm(tempDir, { recursive: true, force: true });
478
1539
  }
@@ -518,20 +1579,56 @@ async function maybeInstallCompanionCli({ channel, nonInteractive, withCli }) {
518
1579
  };
519
1580
  }
520
1581
 
521
- async function cmdInstall({ channel, argv, json }) {
522
- assertLinux();
523
- assertRoot();
524
- if (!commandExists('systemctl')) {
525
- throw new Error('[self-host] systemctl is required');
526
- }
527
- if (!commandExists('tar')) {
528
- throw new Error('[self-host] tar is required');
1582
+ function buildSelfHostServerServiceSpec({ config, envText }) {
1583
+ return {
1584
+ label: config.serviceName,
1585
+ description: `Happier Self-Host (${config.serviceName})`,
1586
+ programArgs: [config.serverBinaryPath],
1587
+ workingDirectory: config.installRoot,
1588
+ env: parseEnvText(envText),
1589
+ stdoutPath: config.serverStdoutLogPath,
1590
+ stderrPath: config.serverStderrLogPath,
1591
+ };
1592
+ }
1593
+
1594
+ async function cmdInstall({ channel, mode, argv, json }) {
1595
+ if (mode === 'system' && process.platform !== 'win32') {
1596
+ assertRoot();
529
1597
  }
530
- const config = resolveConfig({ channel });
531
- const withoutCli = argv.includes('--without-cli') || parseBoolean(process.env.HAPPIER_WITH_CLI, true) === false;
532
- const nonInteractive = argv.includes('--non-interactive') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
1598
+ const parsedEnvOverrides = parseEnvOverridesFromArgv(argv);
1599
+ const envOverrides = parsedEnvOverrides.overrides;
1600
+ const argvSansEnv = parsedEnvOverrides.rest;
1601
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1602
+ const autoUpdateEnabled = resolveAutoUpdateEnabled(argvSansEnv, config.autoUpdate);
1603
+ const autoUpdateIntervalMinutes = resolveAutoUpdateIntervalMinutes(argvSansEnv, config.autoUpdateIntervalMinutes);
1604
+ const autoUpdateAt = resolveAutoUpdateAt(argvSansEnv, config.autoUpdateAt);
1605
+ const withoutCli = argvSansEnv.includes('--without-cli') || parseBoolean(process.env.HAPPIER_WITH_CLI, true) === false;
1606
+ const withUi =
1607
+ !(argvSansEnv.includes('--without-ui')
1608
+ || parseBoolean(process.env.HAPPIER_WITH_UI, true) === false
1609
+ || parseBoolean(process.env.HAPPIER_SELF_HOST_WITH_UI, true) === false);
1610
+ const nonInteractive = argvSansEnv.includes('--non-interactive') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
533
1611
  const serverBinaryOverride = String(process.env.HAPPIER_SELF_HOST_SERVER_BINARY ?? '').trim();
534
1612
 
1613
+ if (normalizeOs(config.platform) !== 'windows' && !commandExists('tar')) {
1614
+ throw new Error('[self-host] tar is required to extract release artifacts');
1615
+ }
1616
+ if (normalizeOs(config.platform) === 'windows' && !commandExists('powershell')) {
1617
+ throw new Error('[self-host] powershell is required on Windows');
1618
+ }
1619
+ if (withUi && !commandExists('tar')) {
1620
+ throw new Error('[self-host] tar is required to extract ui web bundle artifacts');
1621
+ }
1622
+ if (normalizeOs(config.platform) === 'linux' && !commandExists('systemctl')) {
1623
+ throw new Error('[self-host] systemctl is required on Linux');
1624
+ }
1625
+ if (autoUpdateEnabled && normalizeOs(config.platform) === 'darwin' && !commandExists('launchctl')) {
1626
+ throw new Error('[self-host] launchctl is required on macOS for auto-update scheduling');
1627
+ }
1628
+ if (autoUpdateEnabled && normalizeOs(config.platform) === 'windows' && !commandExists('schtasks')) {
1629
+ throw new Error('[self-host] schtasks is required on Windows for auto-update scheduling');
1630
+ }
1631
+
535
1632
  await mkdir(config.installRoot, { recursive: true });
536
1633
  await mkdir(config.installBinDir, { recursive: true });
537
1634
  await mkdir(config.versionsDir, { recursive: true });
@@ -540,80 +1637,73 @@ async function cmdInstall({ channel, argv, json }) {
540
1637
  await mkdir(config.filesDir, { recursive: true });
541
1638
  await mkdir(config.dbDir, { recursive: true });
542
1639
  await mkdir(config.logDir, { recursive: true });
1640
+ await mkdir(config.uiWebRootDir, { recursive: true });
1641
+ await mkdir(config.uiWebVersionsDir, { recursive: true });
543
1642
 
544
1643
  const installResult = await installFromRelease({
545
1644
  product: 'happier-server',
546
- binaryName: 'happier-server',
1645
+ binaryName: config.serverBinaryName,
547
1646
  config,
548
1647
  explicitBinaryPath: serverBinaryOverride,
549
1648
  });
550
1649
 
551
- await writeFile(
552
- config.configEnvPath,
553
- renderServerEnvFile({
554
- port: config.serverPort,
555
- host: config.serverHost,
556
- dataDir: config.dataDir,
557
- filesDir: config.filesDir,
558
- dbDir: config.dbDir,
559
- }),
560
- 'utf-8'
561
- );
562
- await writeFile(
563
- config.serviceUnitPath,
564
- renderServerServiceUnit({
565
- serviceName: config.serviceName,
566
- binaryPath: config.serverBinaryPath,
567
- envFilePath: config.configEnvPath,
568
- workingDirectory: config.installRoot,
569
- logPath: config.serverLogPath,
570
- }),
571
- 'utf-8'
572
- );
573
-
574
- const hstackPath = existsSync(join(config.binDir, 'hstack'))
575
- ? join(config.binDir, 'hstack')
576
- : join(config.installRoot, 'bin', 'hstack');
577
- await writeFile(
578
- config.updaterServiceUnitPath,
579
- renderUpdaterServiceUnit({
580
- updaterServiceName: config.updaterServiceName,
581
- hstackPath,
582
- channel,
583
- }),
584
- 'utf-8'
585
- );
586
- await writeFile(
587
- config.updaterTimerUnitPath,
588
- renderUpdaterTimerUnit({
589
- updaterServiceName: config.updaterServiceName,
590
- updaterTimerName: config.updaterTimerUnitName,
591
- }),
592
- 'utf-8'
593
- );
1650
+ const uiResult = withUi
1651
+ ? await installUiWebFromRelease({ config })
1652
+ : { installed: false, version: null, source: null, reason: 'disabled' };
1653
+ const uiInstalled = Boolean(uiResult?.installed);
1654
+
1655
+ const envText = renderServerEnvFile({
1656
+ port: config.serverPort,
1657
+ host: config.serverHost,
1658
+ dataDir: config.dataDir,
1659
+ filesDir: config.filesDir,
1660
+ dbDir: config.dbDir,
1661
+ uiDir: uiInstalled ? config.uiWebCurrentDir : '',
1662
+ serverBinDir: dirname(config.serverBinaryPath),
1663
+ arch: process.arch,
1664
+ platform: config.platform,
1665
+ });
1666
+ const envTextWithOverrides = envOverrides.length ? applyEnvOverridesToEnvText(envText, envOverrides) : envText;
1667
+ await writeFile(config.configEnvPath, envTextWithOverrides, 'utf-8');
1668
+ const installEnv = parseEnvText(envTextWithOverrides);
1669
+ if (!parseBoolean(installEnv.HAPPIER_SQLITE_AUTO_MIGRATE ?? installEnv.HAPPY_SQLITE_AUTO_MIGRATE, true)) {
1670
+ await applySelfHostSqliteMigrationsAtInstallTime({ env: installEnv }).catch((e) => {
1671
+ throw new Error(`[self-host] failed to apply sqlite migrations at install time: ${String(e?.message ?? e)}`);
1672
+ });
1673
+ }
594
1674
 
595
- const serverShimPath = join(config.binDir, 'happier-server');
1675
+ const serverShimPath = join(config.binDir, config.serverBinaryName);
596
1676
  await mkdir(config.binDir, { recursive: true });
597
1677
  await rm(serverShimPath, { force: true });
598
1678
  await symlink(config.serverBinaryPath, serverShimPath).catch(async () => {
599
1679
  await copyFile(config.serverBinaryPath, serverShimPath);
600
- await chmod(serverShimPath, 0o755);
1680
+ await chmod(serverShimPath, 0o755).catch(() => {});
601
1681
  });
602
1682
 
603
- runCommand('systemctl', ['daemon-reload'], { stdio: 'inherit' });
604
- runCommand('systemctl', ['enable', '--now', config.serviceUnitName], { stdio: 'inherit' });
605
-
606
- if (config.autoUpdate) {
607
- runCommand('systemctl', ['enable', '--now', config.updaterTimerUnitName], { allowFail: true, stdio: 'inherit' });
608
- } else {
609
- runCommand('systemctl', ['disable', '--now', config.updaterTimerUnitName], { allowFail: true, stdio: 'ignore' });
610
- }
1683
+ const serviceSpec = buildSelfHostServerServiceSpec({ config, envText: envTextWithOverrides });
1684
+ await installManagedService({
1685
+ platform: config.platform,
1686
+ mode: config.mode,
1687
+ homeDir: homedir(),
1688
+ spec: serviceSpec,
1689
+ persistent: true,
1690
+ });
611
1691
 
612
- const healthy = await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1692
+ const healthy = await restartAndCheckHealth({ config, serviceSpec });
613
1693
  if (!healthy) {
614
1694
  throw new Error('[self-host] service failed health checks after install');
615
1695
  }
616
1696
 
1697
+ const autoUpdateResult = await installAutoUpdateJob({
1698
+ config: { ...config, autoUpdateAt },
1699
+ enabled: autoUpdateEnabled,
1700
+ intervalMinutes: autoUpdateIntervalMinutes,
1701
+ at: autoUpdateAt,
1702
+ }).catch((e) => ({
1703
+ installed: false,
1704
+ reason: String(e?.message ?? e),
1705
+ }));
1706
+
617
1707
  const cliResult = await maybeInstallCompanionCli({
618
1708
  channel,
619
1709
  nonInteractive,
@@ -621,10 +1711,14 @@ async function cmdInstall({ channel, argv, json }) {
621
1711
  });
622
1712
  await writeSelfHostState(config, {
623
1713
  channel,
1714
+ mode,
624
1715
  version: installResult.version,
625
1716
  source: installResult.source,
626
- autoUpdate: config.autoUpdate,
627
1717
  withCli: !withoutCli,
1718
+ uiWeb: uiInstalled
1719
+ ? { installed: true, version: uiResult.version, source: uiResult.source, tag: uiResult.tag }
1720
+ : { installed: false, reason: String(uiResult?.reason ?? (withUi ? 'missing' : 'disabled')) },
1721
+ autoUpdate: { enabled: autoUpdateEnabled, intervalMinutes: autoUpdateIntervalMinutes, at: autoUpdateAt },
628
1722
  });
629
1723
 
630
1724
  printResult({
@@ -632,84 +1726,257 @@ async function cmdInstall({ channel, argv, json }) {
632
1726
  data: {
633
1727
  ok: true,
634
1728
  channel,
1729
+ mode,
635
1730
  version: installResult.version,
636
- service: config.serviceUnitName,
1731
+ service: config.serviceName,
637
1732
  serverPort: config.serverPort,
1733
+ autoUpdate: {
1734
+ enabled: autoUpdateEnabled,
1735
+ intervalMinutes: autoUpdateIntervalMinutes,
1736
+ at: autoUpdateAt || null,
1737
+ ...autoUpdateResult,
1738
+ },
638
1739
  cli: cliResult,
639
1740
  },
640
1741
  text: [
641
1742
  `${green('✓')} Happier Self-Host installed`,
642
- `- service: ${cyan(config.serviceUnitName)}`,
1743
+ `- mode: ${cyan(mode)}`,
1744
+ `- service: ${cyan(config.serviceName)}`,
643
1745
  `- version: ${cyan(installResult.version || 'unknown')}`,
644
1746
  `- server: ${cyan(`http://127.0.0.1:${config.serverPort}`)}`,
1747
+ `- auto-update: ${autoUpdateEnabled ? (autoUpdateResult.installed ? green(`installed (${autoUpdateAt ? `daily at ${autoUpdateAt}` : `every ${autoUpdateIntervalMinutes}m`})`) : yellow('failed')) : dim('disabled')}`,
645
1748
  `- cli: ${cliResult.installed ? green('installed') : dim(cliResult.reason)}`,
1749
+ `- ui: ${uiInstalled ? green('installed') : dim(String(uiResult?.reason ?? 'disabled'))}`,
646
1750
  ].join('\n'),
647
1751
  });
648
1752
  }
649
1753
 
650
- async function cmdStatus({ channel, json }) {
651
- assertLinux();
652
- const config = resolveConfig({ channel });
653
- const serviceState = runCommand('systemctl', ['is-active', config.serviceUnitName], { allowFail: true, stdio: 'pipe' });
654
- const active = (serviceState.status ?? 1) === 0;
655
- const enabledState = runCommand('systemctl', ['is-enabled', config.serviceUnitName], { allowFail: true, stdio: 'pipe' });
656
- const enabled = (enabledState.status ?? 1) === 0;
657
- const healthy = active ? await checkHealth({ port: config.serverPort }) : false;
1754
+ async function cmdStatus({ channel, mode, json }) {
1755
+ const config = resolveConfig({ channel, mode, platform: process.platform });
658
1756
  const state = existsSync(config.statePath)
659
1757
  ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
660
1758
  : {};
661
1759
 
1760
+ let active = null;
1761
+ let enabled = null;
1762
+ let updaterActive = null;
1763
+ let updaterEnabled = null;
1764
+ const updaterLabel = resolveUpdaterLabel(config);
1765
+ try {
1766
+ if (config.platform === 'linux' && commandExists('systemctl')) {
1767
+ const prefix = config.mode === 'user' ? ['--user'] : [];
1768
+ const isActive = runCommand('systemctl', [...prefix, 'is-active', '--quiet', `${config.serviceName}.service`], {
1769
+ allowFail: true,
1770
+ stdio: 'ignore',
1771
+ });
1772
+ active = (isActive.status ?? 1) === 0;
1773
+ const isEnabled = runCommand('systemctl', [...prefix, 'is-enabled', '--quiet', `${config.serviceName}.service`], {
1774
+ allowFail: true,
1775
+ stdio: 'ignore',
1776
+ });
1777
+ enabled = (isEnabled.status ?? 1) === 0;
1778
+
1779
+ const updaterTimerIsActive = runCommand('systemctl', [...prefix, 'is-active', '--quiet', `${updaterLabel}.timer`], {
1780
+ allowFail: true,
1781
+ stdio: 'ignore',
1782
+ });
1783
+ updaterActive = (updaterTimerIsActive.status ?? 1) === 0;
1784
+ const updaterIsEnabled = runCommand('systemctl', [...prefix, 'is-enabled', '--quiet', `${updaterLabel}.timer`], {
1785
+ allowFail: true,
1786
+ stdio: 'ignore',
1787
+ });
1788
+ updaterEnabled = (updaterIsEnabled.status ?? 1) === 0;
1789
+ } else if (config.platform === 'darwin' && commandExists('launchctl')) {
1790
+ const list = runCommand('launchctl', ['list'], { allowFail: true, stdio: 'pipe' });
1791
+ const out = String(list.stdout ?? '');
1792
+ active = out.includes(`\t${config.serviceName}`) || out.includes(` ${config.serviceName}`);
1793
+ updaterActive = out.includes(`\t${updaterLabel}`) || out.includes(` ${updaterLabel}`);
1794
+ enabled = null;
1795
+ updaterEnabled = null;
1796
+ } else if (config.platform === 'win32' && commandExists('schtasks')) {
1797
+ const query = runCommand('schtasks', ['/Query', '/TN', `Happier\\${config.serviceName}`, '/FO', 'LIST', '/V'], {
1798
+ allowFail: true,
1799
+ stdio: 'pipe',
1800
+ });
1801
+ const out = String(query.stdout ?? '');
1802
+ active = /Status:\s*Running/i.test(out) ? true : /Status:/i.test(out) ? false : null;
1803
+ enabled = /Scheduled Task State:\s*Enabled/i.test(out) ? true : /Scheduled Task State:/i.test(out) ? false : null;
1804
+
1805
+ const updaterQuery = runCommand('schtasks', ['/Query', '/TN', `Happier\\${updaterLabel}`, '/FO', 'LIST', '/V'], {
1806
+ allowFail: true,
1807
+ stdio: 'pipe',
1808
+ });
1809
+ const updaterOut = String(updaterQuery.stdout ?? '');
1810
+ updaterActive = /Status:\s*Running/i.test(updaterOut) ? true : /Status:/i.test(updaterOut) ? false : null;
1811
+ updaterEnabled = /Scheduled Task State:\s*Enabled/i.test(updaterOut)
1812
+ ? true
1813
+ : /Scheduled Task State:/i.test(updaterOut)
1814
+ ? false
1815
+ : null;
1816
+ }
1817
+ } catch {
1818
+ active = null;
1819
+ enabled = null;
1820
+ updaterActive = null;
1821
+ updaterEnabled = null;
1822
+ }
1823
+
1824
+ const healthy = await checkHealth({ port: config.serverPort });
1825
+ const serverVersion = state?.version ? String(state.version) : '';
1826
+ const uiWebVersion =
1827
+ state?.uiWeb?.installed === true && state?.uiWeb?.version
1828
+ ? String(state.uiWeb.version)
1829
+ : '';
1830
+ const autoUpdateState = normalizeSelfHostAutoUpdateState(state, {
1831
+ fallbackIntervalMinutes: config.autoUpdateIntervalMinutes,
1832
+ });
1833
+
1834
+ const serverUrl = `http://${config.serverHost}:${config.serverPort}`;
662
1835
  printResult({
663
1836
  json,
664
1837
  data: {
665
1838
  ok: true,
666
1839
  channel,
1840
+ mode,
1841
+ serverUrl,
1842
+ versions: {
1843
+ server: serverVersion || null,
1844
+ uiWeb: uiWebVersion || null,
1845
+ },
667
1846
  service: {
668
- name: config.serviceUnitName,
1847
+ name: config.serviceName,
669
1848
  active,
670
1849
  enabled,
671
1850
  },
1851
+ autoUpdate: {
1852
+ label: updaterLabel,
1853
+ active: updaterActive,
1854
+ enabled: updaterEnabled,
1855
+ configured: {
1856
+ enabled: Boolean(autoUpdateState?.enabled),
1857
+ intervalMinutes: autoUpdateState?.intervalMinutes ?? null,
1858
+ at: autoUpdateState?.at ? String(autoUpdateState.at) : null,
1859
+ },
1860
+ },
672
1861
  healthy,
673
1862
  state,
674
1863
  },
675
- text: [
676
- `${cyan('service')}: ${config.serviceUnitName}`,
677
- `${cyan('active')}: ${active ? green('yes') : yellow('no')}`,
678
- `${cyan('enabled')}: ${enabled ? green('yes') : yellow('no')}`,
679
- `${cyan('health')}: ${healthy ? green('ok') : yellow('failed')}`,
680
- state?.version ? `${cyan('version')}: ${state.version}` : null,
681
- ].filter(Boolean).join('\n'),
1864
+ text: renderSelfHostStatusText({
1865
+ channel,
1866
+ mode,
1867
+ serviceName: config.serviceName,
1868
+ serverUrl,
1869
+ healthy,
1870
+ service: { active, enabled },
1871
+ versions: { server: serverVersion || null, uiWeb: uiWebVersion || null },
1872
+ autoUpdate: {
1873
+ label: updaterLabel,
1874
+ job: { active: updaterActive, enabled: updaterEnabled },
1875
+ configured: {
1876
+ enabled: Boolean(autoUpdateState?.enabled),
1877
+ intervalMinutes: autoUpdateState?.intervalMinutes ?? null,
1878
+ at: autoUpdateState?.at ? String(autoUpdateState.at) : null,
1879
+ },
1880
+ },
1881
+ updatedAt: state?.updatedAt ?? null,
1882
+ }),
682
1883
  });
683
1884
  }
684
1885
 
685
- async function cmdUpdate({ channel, json }) {
686
- assertLinux();
687
- assertRoot();
688
- const config = resolveConfig({ channel });
1886
+ async function cmdUpdate({ channel, mode, json }) {
1887
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1888
+ if (config.mode === 'system' && config.platform !== 'win32') {
1889
+ assertRoot();
1890
+ }
1891
+ const existingState = existsSync(config.statePath)
1892
+ ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
1893
+ : {};
1894
+ const autoUpdateReconcile = decideSelfHostAutoUpdateReconcile(existingState, {
1895
+ fallbackIntervalMinutes: config.autoUpdateIntervalMinutes,
1896
+ });
1897
+ const withUi =
1898
+ parseBoolean(process.env.HAPPIER_WITH_UI, true) !== false
1899
+ && parseBoolean(process.env.HAPPIER_SELF_HOST_WITH_UI, true) !== false;
689
1900
  const installResult = await installFromRelease({
690
1901
  product: 'happier-server',
691
- binaryName: 'happier-server',
1902
+ binaryName: config.serverBinaryName,
692
1903
  config,
693
1904
  });
694
- const healthy = await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1905
+ const uiResult = withUi
1906
+ ? await installUiWebFromRelease({ config })
1907
+ : { installed: false, version: null, source: null, reason: 'disabled' };
1908
+ const uiInstalled = Boolean(uiResult?.installed);
1909
+
1910
+ const envText = existsSync(config.configEnvPath)
1911
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
1912
+ : '';
1913
+ const parsedEnv = parseEnvText(envText);
1914
+ const effectivePort = parsePort(parsedEnv.PORT, config.serverPort);
1915
+ const configWithPort = effectivePort === config.serverPort ? config : { ...config, serverPort: effectivePort };
1916
+ const defaultsEnvText = renderServerEnvFile({
1917
+ port: configWithPort.serverPort,
1918
+ host: configWithPort.serverHost,
1919
+ dataDir: configWithPort.dataDir,
1920
+ filesDir: configWithPort.filesDir,
1921
+ dbDir: configWithPort.dbDir,
1922
+ uiDir: uiInstalled ? configWithPort.uiWebCurrentDir : '',
1923
+ serverBinDir: dirname(configWithPort.serverBinaryPath),
1924
+ arch: process.arch,
1925
+ platform: configWithPort.platform,
1926
+ });
1927
+ const nextEnvText = envText ? mergeEnvTextWithDefaults(envText, defaultsEnvText) : defaultsEnvText;
1928
+ await mkdir(configWithPort.configDir, { recursive: true });
1929
+ await writeFile(configWithPort.configEnvPath, nextEnvText, 'utf-8');
1930
+ const nextEnv = parseEnvText(nextEnvText);
1931
+ if (!parseBoolean(nextEnv.HAPPIER_SQLITE_AUTO_MIGRATE ?? nextEnv.HAPPY_SQLITE_AUTO_MIGRATE, true)) {
1932
+ await applySelfHostSqliteMigrationsAtInstallTime({ env: nextEnv }).catch((e) => {
1933
+ throw new Error(`[self-host] failed to apply sqlite migrations at update time: ${String(e?.message ?? e)}`);
1934
+ });
1935
+ }
1936
+
1937
+ const serviceSpec = buildSelfHostServerServiceSpec({ config: configWithPort, envText: nextEnvText });
1938
+ await installManagedService({
1939
+ platform: configWithPort.platform,
1940
+ mode: configWithPort.mode,
1941
+ homeDir: homedir(),
1942
+ spec: serviceSpec,
1943
+ persistent: true,
1944
+ }).catch(() => {});
1945
+ const healthy = await restartAndCheckHealth({ config: configWithPort, serviceSpec });
695
1946
  if (!healthy) {
696
1947
  if (existsSync(config.serverPreviousBinaryPath)) {
697
1948
  await copyFile(config.serverPreviousBinaryPath, config.serverBinaryPath);
698
- await chmod(config.serverBinaryPath, 0o755);
699
- await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1949
+ await chmod(config.serverBinaryPath, 0o755).catch(() => {});
1950
+ await restartAndCheckHealth({ config: configWithPort, serviceSpec });
700
1951
  }
701
1952
  throw new Error('[self-host] update failed health checks and was rolled back to previous binary');
702
1953
  }
703
1954
 
1955
+ if (autoUpdateReconcile.action === 'install') {
1956
+ await installAutoUpdateJob({
1957
+ config: { ...configWithPort, autoUpdateAt: autoUpdateReconcile.at },
1958
+ enabled: true,
1959
+ intervalMinutes: autoUpdateReconcile.intervalMinutes,
1960
+ at: autoUpdateReconcile.at,
1961
+ }).catch(() => {});
1962
+ } else {
1963
+ await uninstallAutoUpdateJob({ config: configWithPort }).catch(() => {});
1964
+ }
1965
+
704
1966
  await writeSelfHostState(config, {
705
1967
  channel,
1968
+ mode,
706
1969
  version: installResult.version,
707
1970
  source: installResult.source,
1971
+ autoUpdate: { enabled: autoUpdateReconcile.enabled, intervalMinutes: autoUpdateReconcile.intervalMinutes, at: autoUpdateReconcile.at },
1972
+ uiWeb: uiInstalled
1973
+ ? { installed: true, version: uiResult.version, source: uiResult.source, tag: uiResult.tag }
1974
+ : { installed: false, reason: String(uiResult?.reason ?? (withUi ? 'missing' : 'disabled')) },
708
1975
  });
709
1976
 
710
1977
  printResult({
711
1978
  json,
712
- data: { ok: true, version: installResult.version, service: config.serviceUnitName },
1979
+ data: { ok: true, version: installResult.version, service: config.serviceName },
713
1980
  text: `${green('✓')} updated self-host runtime to ${cyan(installResult.version || 'latest')}`,
714
1981
  });
715
1982
  }
@@ -723,13 +1990,14 @@ function parseRollbackVersion(argv) {
723
1990
  return '';
724
1991
  }
725
1992
 
726
- async function cmdRollback({ channel, argv, json }) {
727
- assertLinux();
728
- assertRoot();
729
- const config = resolveConfig({ channel });
1993
+ async function cmdRollback({ channel, mode, argv, json }) {
1994
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1995
+ if (config.mode === 'system' && config.platform !== 'win32') {
1996
+ assertRoot();
1997
+ }
730
1998
  const to = parseRollbackVersion(argv);
731
1999
  const target = to
732
- ? join(config.versionsDir, `happier-server-${to}`)
2000
+ ? join(config.versionsDir, `${config.serverBinaryName}-${to}`)
733
2001
  : config.serverPreviousBinaryPath;
734
2002
  if (!existsSync(target)) {
735
2003
  throw new Error(
@@ -739,13 +2007,42 @@ async function cmdRollback({ channel, argv, json }) {
739
2007
  );
740
2008
  }
741
2009
  await copyFile(target, config.serverBinaryPath);
742
- await chmod(config.serverBinaryPath, 0o755);
743
- const healthy = await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
2010
+ await chmod(config.serverBinaryPath, 0o755).catch(() => {});
2011
+ const envText = existsSync(config.configEnvPath)
2012
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
2013
+ : '';
2014
+ const parsedEnv = parseEnvText(envText);
2015
+ const effectivePort = parsePort(parsedEnv.PORT, config.serverPort);
2016
+ const configWithPort = effectivePort === config.serverPort ? config : { ...config, serverPort: effectivePort };
2017
+ const defaultsEnvText = renderServerEnvFile({
2018
+ port: configWithPort.serverPort,
2019
+ host: configWithPort.serverHost,
2020
+ dataDir: configWithPort.dataDir,
2021
+ filesDir: configWithPort.filesDir,
2022
+ dbDir: configWithPort.dbDir,
2023
+ serverBinDir: dirname(configWithPort.serverBinaryPath),
2024
+ arch: process.arch,
2025
+ platform: configWithPort.platform,
2026
+ });
2027
+ const nextEnvText = envText ? mergeEnvTextWithDefaults(envText, defaultsEnvText) : defaultsEnvText;
2028
+ await mkdir(configWithPort.configDir, { recursive: true });
2029
+ await writeFile(configWithPort.configEnvPath, nextEnvText, 'utf-8');
2030
+
2031
+ const serviceSpec = buildSelfHostServerServiceSpec({ config: configWithPort, envText: nextEnvText });
2032
+ await installManagedService({
2033
+ platform: configWithPort.platform,
2034
+ mode: configWithPort.mode,
2035
+ homeDir: homedir(),
2036
+ spec: serviceSpec,
2037
+ persistent: true,
2038
+ }).catch(() => {});
2039
+ const healthy = await restartAndCheckHealth({ config: configWithPort, serviceSpec });
744
2040
  if (!healthy) {
745
2041
  throw new Error('[self-host] rollback completed binary swap but health checks failed');
746
2042
  }
747
2043
  await writeSelfHostState(config, {
748
2044
  channel,
2045
+ mode,
749
2046
  version: to || 'previous',
750
2047
  rolledBackAt: new Date().toISOString(),
751
2048
  });
@@ -756,27 +2053,43 @@ async function cmdRollback({ channel, argv, json }) {
756
2053
  });
757
2054
  }
758
2055
 
759
- async function cmdUninstall({ channel, argv, json }) {
760
- assertLinux();
761
- assertRoot();
762
- const config = resolveConfig({ channel });
2056
+ async function cmdUninstall({ channel, mode, argv, json }) {
2057
+ const config = resolveConfig({ channel, mode, platform: process.platform });
2058
+ if (config.mode === 'system' && config.platform !== 'win32') {
2059
+ assertRoot();
2060
+ }
763
2061
  const purgeData = argv.includes('--purge-data');
764
2062
  const yes = argv.includes('--yes') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
765
2063
  if (!yes) {
766
2064
  throw new Error('[self-host] uninstall requires --yes (or HAPPIER_NONINTERACTIVE=1)');
767
2065
  }
768
2066
 
769
- runCommand('systemctl', ['disable', '--now', config.serviceUnitName], { allowFail: true, stdio: 'ignore' });
770
- runCommand('systemctl', ['disable', '--now', config.updaterTimerUnitName], { allowFail: true, stdio: 'ignore' });
771
-
772
- await rm(config.serviceUnitPath, { force: true });
773
- await rm(config.updaterServiceUnitPath, { force: true });
774
- await rm(config.updaterTimerUnitPath, { force: true });
775
- runCommand('systemctl', ['daemon-reload'], { allowFail: true, stdio: 'ignore' });
2067
+ const envText = existsSync(config.configEnvPath)
2068
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
2069
+ : '';
2070
+ const fallbackEnvText = envText || renderServerEnvFile({
2071
+ port: config.serverPort,
2072
+ host: config.serverHost,
2073
+ dataDir: config.dataDir,
2074
+ filesDir: config.filesDir,
2075
+ dbDir: config.dbDir,
2076
+ serverBinDir: dirname(config.serverBinaryPath),
2077
+ arch: process.arch,
2078
+ platform: config.platform,
2079
+ });
2080
+ const serviceSpec = buildSelfHostServerServiceSpec({ config, envText: fallbackEnvText });
2081
+ await uninstallAutoUpdateJob({ config }).catch(() => {});
2082
+ await uninstallManagedService({
2083
+ platform: config.platform,
2084
+ mode: config.mode,
2085
+ homeDir: homedir(),
2086
+ spec: serviceSpec,
2087
+ persistent: true,
2088
+ }).catch(() => {});
776
2089
 
777
2090
  await rm(config.serverBinaryPath, { force: true });
778
2091
  await rm(config.serverPreviousBinaryPath, { force: true });
779
- await rm(join(config.binDir, 'happier-server'), { force: true });
2092
+ await rm(join(config.binDir, config.serverBinaryName), { force: true });
780
2093
  await rm(config.statePath, { force: true });
781
2094
 
782
2095
  if (purgeData) {
@@ -793,16 +2106,12 @@ async function cmdUninstall({ channel, argv, json }) {
793
2106
  });
794
2107
  }
795
2108
 
796
- async function cmdDoctor({ channel, json }) {
797
- const config = resolveConfig({ channel });
798
- const checks = [
799
- { name: 'linux', ok: process.platform === 'linux' },
800
- { name: 'systemctl', ok: commandExists('systemctl') },
801
- { name: 'curl', ok: commandExists('curl') },
802
- { name: 'tar', ok: commandExists('tar') },
803
- { name: 'server-binary', ok: existsSync(config.serverBinaryPath) },
804
- { name: 'service-unit', ok: existsSync(config.serviceUnitPath) },
805
- ];
2109
+ async function cmdDoctor({ channel, mode, json }) {
2110
+ const config = resolveConfig({ channel, mode, platform: process.platform });
2111
+ const state = existsSync(config.statePath)
2112
+ ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
2113
+ : {};
2114
+ const checks = buildSelfHostDoctorChecks(config, { state });
806
2115
  const ok = checks.every((check) => check.ok);
807
2116
  printResult({
808
2117
  json,
@@ -818,17 +2127,200 @@ async function cmdDoctor({ channel, json }) {
818
2127
  }
819
2128
  }
820
2129
 
2130
+ function pickFirstPositional(argv) {
2131
+ const args = Array.isArray(argv) ? argv.map(String) : [];
2132
+ return args.find((a) => a && !a.startsWith('-')) ?? '';
2133
+ }
2134
+
2135
+ function safeParseJson(text) {
2136
+ try {
2137
+ return JSON.parse(String(text ?? ''));
2138
+ } catch {
2139
+ return null;
2140
+ }
2141
+ }
2142
+
2143
+ function redactEnvForDisplay(env) {
2144
+ const input = env ?? {};
2145
+ const out = {};
2146
+ const allowed = new Set([
2147
+ 'PORT',
2148
+ 'HAPPIER_SERVER_HOST',
2149
+ 'HAPPIER_DB_PROVIDER',
2150
+ 'HAPPIER_FILES_BACKEND',
2151
+ 'HAPPIER_SERVER_UI_DIR',
2152
+ ]);
2153
+ for (const [k, v] of Object.entries(input)) {
2154
+ if (!allowed.has(k)) continue;
2155
+ out[k] = String(v ?? '');
2156
+ }
2157
+ return out;
2158
+ }
2159
+
2160
+ async function cmdConfig({ channel, mode, argv, json }) {
2161
+ const args = Array.isArray(argv) ? argv.map(String) : [];
2162
+ const sub = pickFirstPositional(args) || 'view';
2163
+ const subIndex = args.indexOf(sub);
2164
+ const rest = subIndex >= 0 ? args.slice(subIndex + 1) : [];
2165
+
2166
+ const config = resolveConfig({ channel, mode, platform: process.platform });
2167
+ const existingState = existsSync(config.statePath)
2168
+ ? safeParseJson(await readFile(config.statePath, 'utf-8').catch(() => '')) ?? {}
2169
+ : {};
2170
+ const normalizedAutoUpdate = normalizeSelfHostAutoUpdateState(existingState, {
2171
+ fallbackIntervalMinutes: config.autoUpdateIntervalMinutes,
2172
+ });
2173
+ const envText = existsSync(config.configEnvPath)
2174
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
2175
+ : '';
2176
+ const envObj = envText ? parseEnvText(envText) : {};
2177
+
2178
+ if (sub === 'view') {
2179
+ printResult({
2180
+ json,
2181
+ data: {
2182
+ ok: true,
2183
+ channel,
2184
+ mode,
2185
+ paths: {
2186
+ installRoot: config.installRoot,
2187
+ binDir: config.binDir,
2188
+ configDir: config.configDir,
2189
+ configEnvPath: config.configEnvPath,
2190
+ statePath: config.statePath,
2191
+ logDir: config.logDir,
2192
+ },
2193
+ autoUpdate: {
2194
+ enabled: Boolean(normalizedAutoUpdate.enabled),
2195
+ intervalMinutes: normalizedAutoUpdate.intervalMinutes,
2196
+ at: normalizedAutoUpdate.at || null,
2197
+ },
2198
+ env: redactEnvForDisplay(envObj),
2199
+ state: existingState,
2200
+ },
2201
+ text: json
2202
+ ? null
2203
+ : [
2204
+ banner('self-host config', { subtitle: 'Self-host configuration (paths + auto-update).' }),
2205
+ '',
2206
+ sectionTitle('paths:'),
2207
+ `- installRoot: ${cyan(config.installRoot)}`,
2208
+ `- configEnvPath: ${cyan(config.configEnvPath)}`,
2209
+ `- statePath: ${cyan(config.statePath)}`,
2210
+ '',
2211
+ sectionTitle('auto-update:'),
2212
+ `- enabled: ${normalizedAutoUpdate.enabled ? green('yes') : dim('no')}`,
2213
+ `- schedule: ${normalizedAutoUpdate.enabled ? cyan(normalizedAutoUpdate.at ? `daily at ${normalizedAutoUpdate.at}` : `every ${normalizedAutoUpdate.intervalMinutes}m`) : dim('disabled')}`,
2214
+ ].join('\n'),
2215
+ });
2216
+ return;
2217
+ }
2218
+
2219
+ if (sub === 'set') {
2220
+ const wantsApply = !rest.includes('--no-apply');
2221
+ const enabled =
2222
+ rest.includes('--auto-update') ? true : rest.includes('--no-auto-update') ? false : Boolean(normalizedAutoUpdate.enabled);
2223
+
2224
+ const wantsInterval = rest.some((a) => a === '--auto-update-interval' || a.startsWith('--auto-update-interval='));
2225
+ const nextIntervalMinutes = wantsInterval
2226
+ ? resolveAutoUpdateIntervalMinutes(rest, normalizedAutoUpdate.intervalMinutes)
2227
+ : normalizedAutoUpdate.intervalMinutes;
2228
+
2229
+ const wantsAt = rest.some((a) => a === '--auto-update-at' || a.startsWith('--auto-update-at='));
2230
+ const clearAt = rest.includes('--clear-auto-update-at');
2231
+ const nextAt = clearAt
2232
+ ? ''
2233
+ : wantsAt
2234
+ ? resolveAutoUpdateAt(rest, normalizedAutoUpdate.at || config.autoUpdateAt || '')
2235
+ : normalizedAutoUpdate.at || '';
2236
+
2237
+ const parsedEnvOverrides = parseEnvOverridesFromArgv(rest);
2238
+ const envOverrides = parsedEnvOverrides.overrides;
2239
+
2240
+ await mkdir(config.installRoot, { recursive: true });
2241
+
2242
+ if (envOverrides.length) {
2243
+ const baseEnvText = envText || renderServerEnvFile({
2244
+ port: config.serverPort,
2245
+ host: config.serverHost,
2246
+ dataDir: config.dataDir,
2247
+ filesDir: config.filesDir,
2248
+ dbDir: config.dbDir,
2249
+ uiDir: existingState?.uiWeb?.installed === true ? config.uiWebCurrentDir : '',
2250
+ serverBinDir: dirname(config.serverBinaryPath),
2251
+ arch: process.arch,
2252
+ platform: config.platform,
2253
+ });
2254
+ const nextEnvText = applyEnvOverridesToEnvText(baseEnvText, envOverrides);
2255
+ await mkdir(config.configDir, { recursive: true });
2256
+ await writeFile(config.configEnvPath, nextEnvText, 'utf-8');
2257
+ }
2258
+
2259
+ await writeSelfHostState(config, {
2260
+ channel,
2261
+ mode,
2262
+ autoUpdate: { enabled, intervalMinutes: nextIntervalMinutes, at: nextAt },
2263
+ });
2264
+
2265
+ let applyResult = null;
2266
+ if (wantsApply) {
2267
+ if (config.mode === 'system' && config.platform !== 'win32') {
2268
+ assertRoot();
2269
+ }
2270
+ if (enabled) {
2271
+ applyResult = await installAutoUpdateJob({
2272
+ config: { ...config, autoUpdateAt: nextAt },
2273
+ enabled: true,
2274
+ intervalMinutes: nextIntervalMinutes,
2275
+ at: nextAt,
2276
+ }).catch((e) => ({ installed: false, reason: String(e?.message ?? e) }));
2277
+ } else {
2278
+ applyResult = await uninstallAutoUpdateJob({ config }).catch((e) => ({ uninstalled: false, reason: String(e?.message ?? e) }));
2279
+ }
2280
+ }
2281
+
2282
+ const nextEnvText = existsSync(config.configEnvPath)
2283
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
2284
+ : '';
2285
+ const nextEnvObj = nextEnvText ? parseEnvText(nextEnvText) : {};
2286
+
2287
+ printResult({
2288
+ json,
2289
+ data: {
2290
+ ok: true,
2291
+ channel,
2292
+ mode,
2293
+ autoUpdate: { enabled, intervalMinutes: nextIntervalMinutes, at: nextAt || null },
2294
+ env: redactEnvForDisplay(nextEnvObj),
2295
+ applied: wantsApply,
2296
+ applyResult,
2297
+ },
2298
+ text: json
2299
+ ? null
2300
+ : [
2301
+ `${green('✓')} self-host config updated`,
2302
+ `- auto-update: ${enabled ? (nextAt ? `daily at ${nextAt}` : `every ${nextIntervalMinutes}m`) : 'disabled'}`,
2303
+ `- apply: ${wantsApply ? green('yes') : dim('no')}`,
2304
+ ].join('\n'),
2305
+ });
2306
+ return;
2307
+ }
2308
+
2309
+ throw new Error(`[self-host] unknown config command: ${sub}`);
2310
+ }
2311
+
821
2312
  export function usageText() {
822
2313
  return [
823
2314
  banner('self-host', { subtitle: 'Happier Self-Host guided installation flow.' }),
824
2315
  '',
825
2316
  sectionTitle('usage:'),
826
- ` ${cyan('hstack self-host')} install [--without-cli] [--channel=stable|preview] [--non-interactive] [--json]`,
827
- ` ${cyan('hstack self-host')} status [--channel=stable|preview] [--json]`,
828
- ` ${cyan('hstack self-host')} update [--channel=stable|preview] [--json]`,
829
- ` ${cyan('hstack self-host')} rollback [--to=<version>] [--channel=stable|preview] [--json]`,
830
- ` ${cyan('hstack self-host')} uninstall [--purge-data] [--yes] [--json]`,
2317
+ ` ${cyan('hstack self-host')} install [--mode=user|system] [--without-cli] [--without-ui] [--channel=stable|preview] [--auto-update|--no-auto-update] [--auto-update-interval=<minutes>] [--auto-update-at=<HH:MM>] [--env KEY=VALUE]... [--non-interactive] [--json]`,
2318
+ ` ${cyan('hstack self-host')} status [--mode=user|system] [--channel=stable|preview] [--json]`,
2319
+ ` ${cyan('hstack self-host')} update [--mode=user|system] [--channel=stable|preview] [--json]`,
2320
+ ` ${cyan('hstack self-host')} rollback [--mode=user|system] [--to=<version>] [--channel=stable|preview] [--json]`,
2321
+ ` ${cyan('hstack self-host')} uninstall [--mode=user|system] [--purge-data] [--yes] [--json]`,
831
2322
  ` ${cyan('hstack self-host')} doctor [--json]`,
2323
+ ` ${cyan('hstack self-host')} config view|set [--mode=user|system] [--channel=stable|preview] [--json]`,
832
2324
  '',
833
2325
  sectionTitle('notes:'),
834
2326
  '- works without a repository checkout (binary-safe flow).',
@@ -841,13 +2333,19 @@ export async function runSelfHostCli(argv = process.argv.slice(2)) {
841
2333
  const { flags, kv } = parseArgs(argv);
842
2334
  const json = wantsJson(argv, { flags });
843
2335
  const channel = normalizeChannel(String(kv.get('--channel') ?? process.env.HAPPIER_CHANNEL ?? 'stable'));
2336
+ const mode = normalizeMode(
2337
+ String(
2338
+ kv.get('--mode') ??
2339
+ (argv.includes('--system') ? 'system' : argv.includes('--user') ? 'user' : process.env.HAPPIER_SELF_HOST_MODE ?? 'user')
2340
+ )
2341
+ );
844
2342
 
845
2343
  if (wantsHelp(argv, { flags }) || parsed.subcommand === 'help') {
846
2344
  printResult({
847
2345
  json,
848
2346
  data: {
849
2347
  ok: true,
850
- commands: ['install', 'status', 'update', 'rollback', 'uninstall', 'doctor'],
2348
+ commands: ['install', 'status', 'update', 'rollback', 'uninstall', 'doctor', 'config'],
851
2349
  },
852
2350
  text: usageText(),
853
2351
  });
@@ -855,27 +2353,31 @@ export async function runSelfHostCli(argv = process.argv.slice(2)) {
855
2353
  }
856
2354
 
857
2355
  if (parsed.subcommand === 'install') {
858
- await cmdInstall({ channel, argv: parsed.rest, json });
2356
+ await cmdInstall({ channel, mode, argv: parsed.rest, json });
859
2357
  return;
860
2358
  }
861
2359
  if (parsed.subcommand === 'status') {
862
- await cmdStatus({ channel, json });
2360
+ await cmdStatus({ channel, mode, json });
863
2361
  return;
864
2362
  }
865
2363
  if (parsed.subcommand === 'update') {
866
- await cmdUpdate({ channel, json });
2364
+ await cmdUpdate({ channel, mode, json });
867
2365
  return;
868
2366
  }
869
2367
  if (parsed.subcommand === 'rollback') {
870
- await cmdRollback({ channel, argv: parsed.rest, json });
2368
+ await cmdRollback({ channel, mode, argv: parsed.rest, json });
871
2369
  return;
872
2370
  }
873
2371
  if (parsed.subcommand === 'uninstall') {
874
- await cmdUninstall({ channel, argv: parsed.rest, json });
2372
+ await cmdUninstall({ channel, mode, argv: parsed.rest, json });
875
2373
  return;
876
2374
  }
877
2375
  if (parsed.subcommand === 'doctor' || parsed.subcommand === 'migrate-from-npm') {
878
- await cmdDoctor({ channel, json });
2376
+ await cmdDoctor({ channel, mode, json });
2377
+ return;
2378
+ }
2379
+ if (parsed.subcommand === 'config') {
2380
+ await cmdConfig({ channel, mode, argv: parsed.rest, json });
879
2381
  return;
880
2382
  }
881
2383