@happier-dev/stack 0.1.0-preview.100.1 → 0.1.0-preview.134.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/docs/server-flavors.md +6 -6
  2. package/node_modules/@happier-dev/cli-common/dist/index.d.ts +2 -0
  3. package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -1
  4. package/node_modules/@happier-dev/cli-common/dist/index.js +2 -0
  5. package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -1
  6. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts +51 -0
  7. package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts.map +1 -0
  8. package/node_modules/@happier-dev/cli-common/dist/providers/index.js +129 -0
  9. package/node_modules/@happier-dev/cli-common/dist/providers/index.js.map +1 -0
  10. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts +5 -0
  11. package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts.map +1 -0
  12. package/node_modules/@happier-dev/cli-common/dist/service/index.js +5 -0
  13. package/node_modules/@happier-dev/cli-common/dist/service/index.js.map +1 -0
  14. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +15 -0
  15. package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -0
  16. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +97 -0
  17. package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -0
  18. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts +55 -0
  19. package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts.map +1 -0
  20. package/node_modules/@happier-dev/cli-common/dist/service/manager.js +302 -0
  21. package/node_modules/@happier-dev/cli-common/dist/service/manager.js.map +1 -0
  22. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts +12 -0
  23. package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts.map +1 -0
  24. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js +75 -0
  25. package/node_modules/@happier-dev/cli-common/dist/service/systemd.js.map +1 -0
  26. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts +8 -0
  27. package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts.map +1 -0
  28. package/node_modules/@happier-dev/cli-common/dist/service/windows.js +29 -0
  29. package/node_modules/@happier-dev/cli-common/dist/service/windows.js.map +1 -0
  30. package/node_modules/@happier-dev/cli-common/package.json +11 -0
  31. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts +22 -0
  32. package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts.map +1 -0
  33. package/node_modules/@happier-dev/release-runtime/dist/assets.js +44 -0
  34. package/node_modules/@happier-dev/release-runtime/dist/assets.js.map +1 -0
  35. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts +5 -0
  36. package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts.map +1 -0
  37. package/node_modules/@happier-dev/release-runtime/dist/checksums.js +21 -0
  38. package/node_modules/@happier-dev/release-runtime/dist/checksums.js.map +1 -0
  39. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts +14 -0
  40. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts.map +1 -0
  41. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js +39 -0
  42. package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js.map +1 -0
  43. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts +20 -0
  44. package/node_modules/@happier-dev/release-runtime/dist/github.d.ts.map +1 -0
  45. package/node_modules/@happier-dev/release-runtime/dist/github.js +60 -0
  46. package/node_modules/@happier-dev/release-runtime/dist/github.js.map +1 -0
  47. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts +7 -0
  48. package/node_modules/@happier-dev/release-runtime/dist/index.d.ts.map +1 -0
  49. package/node_modules/@happier-dev/release-runtime/dist/index.js +7 -0
  50. package/node_modules/@happier-dev/release-runtime/dist/index.js.map +1 -0
  51. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts +7 -0
  52. package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts.map +1 -0
  53. package/node_modules/@happier-dev/release-runtime/dist/minisign.js +92 -0
  54. package/node_modules/@happier-dev/release-runtime/dist/minisign.js.map +1 -0
  55. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts +26 -0
  56. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts.map +1 -0
  57. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js +53 -0
  58. package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js.map +1 -0
  59. package/node_modules/@happier-dev/release-runtime/package.json +38 -0
  60. package/package.json +4 -2
  61. package/scripts/auth.mjs +3 -2
  62. package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +1 -0
  63. package/scripts/auth_copy_from_runCapture.integration.test.mjs +8 -1
  64. package/scripts/build.mjs +3 -18
  65. package/scripts/bundleWorkspaceDeps.mjs +5 -1
  66. package/scripts/bundleWorkspaceDeps.test.mjs +16 -0
  67. package/scripts/mobile.mjs +32 -1
  68. package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
  69. package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
  70. package/scripts/mobile_run_ios_passes_port.integration.test.mjs +101 -0
  71. package/scripts/providers_cmd.mjs +262 -0
  72. package/scripts/release_binary_smoke.integration.test.mjs +25 -6
  73. package/scripts/remote_cmd.mjs +240 -0
  74. package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
  75. package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
  76. package/scripts/self_host_runtime.mjs +1403 -312
  77. package/scripts/self_host_runtime.test.mjs +361 -1
  78. package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
  79. package/scripts/self_host_service_e2e_harness.mjs +93 -0
  80. package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
  81. package/scripts/service.mjs +156 -26
  82. package/scripts/stack/command_arguments.mjs +1 -0
  83. package/scripts/stack/help_text.mjs +2 -0
  84. package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
  85. package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
  86. package/scripts/utils/auth/credentials_paths.mjs +9 -9
  87. package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
  88. package/scripts/utils/auth/stable_scope_id.mjs +1 -1
  89. package/scripts/utils/cli/cli_registry.mjs +18 -0
  90. package/scripts/utils/cli/progress.mjs +8 -1
  91. package/scripts/utils/cli/progress.test.mjs +43 -0
  92. package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
  93. package/scripts/utils/dev/expo_dev.mjs +35 -5
  94. package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
  95. package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
  96. package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
  97. package/scripts/utils/server/port.mjs +20 -2
  98. package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
  99. package/scripts/utils/service/service_manager.mjs +96 -0
  100. package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
  101. package/scripts/utils/service/service_manager.test.mjs +20 -0
  102. package/scripts/utils/service/systemd_service_unit.mjs +1 -0
  103. package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
  104. package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
  105. package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
  106. package/scripts/utils/ui/ui_export_env.mjs +29 -0
  107. package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
  108. package/scripts/worktrees.mjs +3 -0
  109. package/scripts/worktrees_status_default_target.test.mjs +56 -0
@@ -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,58 @@ function parsePort(raw, fallback = DEFAULTS.serverPort) {
50
97
  return port > 0 && port <= 65535 ? port : fallback;
51
98
  }
52
99
 
100
+ export function resolveSelfHostHealthTimeoutMs(env = process.env) {
101
+ const raw = String(env?.HAPPIER_SELF_HOST_HEALTH_TIMEOUT_MS ?? '').trim();
102
+ if (!raw) return DEFAULTS.healthCheckTimeoutMs;
103
+ const parsed = Number(raw);
104
+ return Number.isFinite(parsed) && parsed >= 10_000
105
+ ? Math.floor(parsed)
106
+ : DEFAULTS.healthCheckTimeoutMs;
107
+ }
108
+
109
+ export function resolveSelfHostAutoUpdateDefault(env = process.env) {
110
+ return parseBoolean(env?.HAPPIER_SELF_HOST_AUTO_UPDATE, false);
111
+ }
112
+
113
+ export function resolveSelfHostAutoUpdateIntervalMinutes(env = process.env) {
114
+ const raw = String(env?.HAPPIER_SELF_HOST_AUTO_UPDATE_INTERVAL_MINUTES ?? '').trim();
115
+ if (!raw) return DEFAULTS.autoUpdateIntervalMinutes;
116
+ const parsed = Number(raw);
117
+ const minutes = Number.isFinite(parsed) ? Math.floor(parsed) : NaN;
118
+ return Number.isFinite(minutes) && minutes >= 15
119
+ ? minutes
120
+ : DEFAULTS.autoUpdateIntervalMinutes;
121
+ }
122
+
123
+ export function normalizeSelfHostAutoUpdateState(state, { fallbackIntervalMinutes = DEFAULTS.autoUpdateIntervalMinutes } = {}) {
124
+ const fallbackRaw = Number(fallbackIntervalMinutes);
125
+ const fallback =
126
+ Number.isFinite(fallbackRaw) && Math.floor(fallbackRaw) >= 15
127
+ ? Math.floor(fallbackRaw)
128
+ : DEFAULTS.autoUpdateIntervalMinutes;
129
+
130
+ const raw = state?.autoUpdate;
131
+ if (raw != null && typeof raw === 'object') {
132
+ const enabled = Boolean(raw.enabled);
133
+ const parsed = Number(raw.intervalMinutes);
134
+ const intervalMinutes = Number.isFinite(parsed) && Math.floor(parsed) >= 15 ? Math.floor(parsed) : fallback;
135
+ return { enabled, intervalMinutes };
136
+ }
137
+ if (raw === true || raw === false) {
138
+ return { enabled: raw, intervalMinutes: fallback };
139
+ }
140
+ return { enabled: false, intervalMinutes: fallback };
141
+ }
142
+
143
+ export function decideSelfHostAutoUpdateReconcile(state, { fallbackIntervalMinutes = DEFAULTS.autoUpdateIntervalMinutes } = {}) {
144
+ const normalized = normalizeSelfHostAutoUpdateState(state, { fallbackIntervalMinutes });
145
+ return {
146
+ action: normalized.enabled ? 'install' : 'uninstall',
147
+ enabled: normalized.enabled,
148
+ intervalMinutes: normalized.intervalMinutes,
149
+ };
150
+ }
151
+
53
152
  function assertLinux() {
54
153
  if (process.platform !== 'linux') {
55
154
  throw new Error('[self-host] Happier Self-Host currently supports Linux only.');
@@ -82,7 +181,13 @@ function runCommand(cmd, args, { cwd, env, allowFail = false, stdio = 'pipe' } =
82
181
  }
83
182
 
84
183
  function commandExists(cmd) {
85
- const result = runCommand('bash', ['-lc', `command -v ${cmd} >/dev/null 2>&1`], { allowFail: true, stdio: 'ignore' });
184
+ const name = String(cmd ?? '').trim();
185
+ if (!name) return false;
186
+ if (process.platform === 'win32') {
187
+ const result = runCommand('where', [name], { allowFail: true, stdio: 'ignore' });
188
+ return (result.status ?? 1) === 0;
189
+ }
190
+ const result = runCommand('sh', ['-lc', `command -v ${name} >/dev/null 2>&1`], { allowFail: true, stdio: 'ignore' });
86
191
  return (result.status ?? 1) === 0;
87
192
  }
88
193
 
@@ -94,6 +199,14 @@ function normalizeArch() {
94
199
  return arch;
95
200
  }
96
201
 
202
+ function normalizeOs(platform = process.platform) {
203
+ const p = String(platform ?? '').trim() || process.platform;
204
+ if (p === 'linux') return 'linux';
205
+ if (p === 'darwin') return 'darwin';
206
+ if (p === 'win32') return 'windows';
207
+ throw new Error(`[self-host] unsupported platform: ${p}`);
208
+ }
209
+
97
210
  function normalizeChannel(raw) {
98
211
  const channel = String(raw ?? '').trim() || 'stable';
99
212
  if (!SUPPORTED_CHANNELS.has(channel)) {
@@ -102,6 +215,13 @@ function normalizeChannel(raw) {
102
215
  return channel;
103
216
  }
104
217
 
218
+ function normalizeMode(raw) {
219
+ const mode = String(raw ?? '').trim().toLowerCase();
220
+ if (!mode) return 'user';
221
+ if (mode === 'user' || mode === 'system') return mode;
222
+ throw new Error(`[self-host] invalid mode: ${mode} (expected user|system)`);
223
+ }
224
+
105
225
  export function parseSelfHostInvocation(argv) {
106
226
  const args = Array.isArray(argv) ? [...argv] : [];
107
227
  if (args[0] === 'self-host' || args[0] === 'selfhost') {
@@ -116,78 +236,154 @@ export function parseSelfHostInvocation(argv) {
116
236
  };
117
237
  }
118
238
 
119
- function escapeRegex(s) {
120
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
121
- }
122
-
123
239
  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([^-/]+)-/);
240
+ const { version, archive, checksums, checksumsSig } = resolveReleaseAssetBundle({
241
+ assets,
242
+ product,
243
+ os,
244
+ arch,
245
+ });
139
246
  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] : '',
247
+ archiveUrl: archive.url,
248
+ archiveName: archive.name,
249
+ checksumsUrl: checksums.url,
250
+ signatureUrl: checksumsSig.url,
251
+ version,
145
252
  };
146
253
  }
147
254
 
148
- async function sha256(path) {
149
- const bytes = await readFile(path);
150
- return createHash('sha256').update(bytes).digest('hex');
255
+ function resolveSqliteDatabaseFilePath(databaseUrl) {
256
+ const raw = String(databaseUrl ?? '').trim();
257
+ if (!raw) return '';
258
+ if (!raw.startsWith('file:')) return '';
259
+ try {
260
+ const url = new URL(raw);
261
+ if (url.protocol !== 'file:') return '';
262
+ const pathname = url.pathname || '';
263
+ // On Windows, URL.pathname can start with /C:/...
264
+ return pathname.startsWith('/') && /^[A-Za-z]:\//.test(pathname.slice(1))
265
+ ? pathname.slice(1)
266
+ : pathname;
267
+ } catch {
268
+ const value = raw.slice('file:'.length);
269
+ return value.startsWith('//') ? value.replace(/^\/+/, '/') : value;
270
+ }
151
271
  }
152
272
 
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());
273
+ async function applySelfHostSqliteMigrationsAtInstallTime({ env }) {
274
+ if (typeof globalThis.Bun === 'undefined') {
275
+ return { applied: [], skipped: true, reason: 'bun-unavailable' };
276
+ }
277
+ const databaseUrl = String(env?.DATABASE_URL ?? '').trim();
278
+ const migrationsDir = String(env?.HAPPIER_SQLITE_MIGRATIONS_DIR ?? env?.HAPPY_SQLITE_MIGRATIONS_DIR ?? '').trim();
279
+ if (!databaseUrl || !migrationsDir) {
280
+ return { applied: [], skipped: true, reason: 'missing-config' };
281
+ }
282
+ const dbPath = resolveSqliteDatabaseFilePath(databaseUrl);
283
+ if (!dbPath) {
284
+ return { applied: [], skipped: true, reason: 'unsupported-database-url' };
285
+ }
286
+ const migrationsInfo = await stat(migrationsDir).catch(() => null);
287
+ if (!migrationsInfo?.isDirectory()) {
288
+ return { applied: [], skipped: true, reason: 'migrations-dir-missing' };
161
289
  }
162
- return map;
163
- }
164
290
 
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})`);
291
+ const mod = await import('bun:sqlite');
292
+ const Database = mod?.Database;
293
+ if (!Database) {
294
+ return { applied: [], skipped: true, reason: 'bun-sqlite-unavailable' };
174
295
  }
175
- const bytes = Buffer.from(await response.arrayBuffer());
176
- await writeFile(targetPath, bytes);
177
- }
296
+ const db = new Database(dbPath);
297
+ db.exec(
298
+ [
299
+ 'CREATE TABLE IF NOT EXISTS _prisma_migrations (',
300
+ ' id TEXT PRIMARY KEY,',
301
+ ' checksum TEXT NOT NULL,',
302
+ ' finished_at DATETIME,',
303
+ ' migration_name TEXT NOT NULL,',
304
+ ' logs TEXT,',
305
+ ' rolled_back_at DATETIME,',
306
+ ' started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,',
307
+ ' applied_steps_count INTEGER NOT NULL DEFAULT 0',
308
+ ');',
309
+ ].join('\n'),
310
+ );
178
311
 
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})`);
312
+ const tableNamesQuery = db.query(`SELECT name FROM sqlite_master WHERE type='table'`);
313
+ const appliedQuery = db.query(
314
+ `SELECT migration_name FROM _prisma_migrations WHERE rolled_back_at IS NULL AND finished_at IS NOT NULL`,
315
+ );
316
+ const insertQuery = db.query(
317
+ `INSERT INTO _prisma_migrations (id, checksum, finished_at, migration_name, applied_steps_count) VALUES (?, ?, CURRENT_TIMESTAMP, ?, 1)`,
318
+ );
319
+
320
+ const applied = new Set(
321
+ appliedQuery.all().map((row) => String(row?.migration_name ?? '').trim()).filter(Boolean),
322
+ );
323
+
324
+ const existingTables = new Set(
325
+ tableNamesQuery.all().map((row) => String(row?.name ?? '').trim()).filter(Boolean),
326
+ );
327
+ const hasCoreTables =
328
+ existingTables.has('Account')
329
+ || existingTables.has('account')
330
+ || existingTables.has('accounts');
331
+ const legacyMode = applied.size === 0 && hasCoreTables;
332
+
333
+ const isLikelyAlreadyAppliedError = (err) => {
334
+ const msg = String(err?.message ?? err ?? '').toLowerCase();
335
+ return msg.includes('already exists') || msg.includes('duplicate column') || msg.includes('duplicate');
336
+ };
337
+
338
+ const entries = await readdir(migrationsDir, { withFileTypes: true }).catch(() => []);
339
+ const dirs = entries
340
+ .filter((e) => e.isDirectory())
341
+ .map((e) => e.name)
342
+ .sort((a, b) => a.localeCompare(b));
343
+
344
+ const sha256Hex = (input) => createHash('sha256').update(String(input)).digest('hex');
345
+ const appliedNow = [];
346
+ for (const name of dirs) {
347
+ if (applied.has(name)) continue;
348
+ const sqlPath = join(migrationsDir, name, 'migration.sql');
349
+ const sql = await readFile(sqlPath, 'utf8').catch(() => '');
350
+ if (!sql.trim()) continue;
351
+ const checksum = sha256Hex(sql);
352
+ db.exec('BEGIN');
353
+ try {
354
+ db.exec(sql);
355
+ insertQuery.run(randomUUID(), checksum, name);
356
+ db.exec('COMMIT');
357
+ appliedNow.push(name);
358
+ applied.add(name);
359
+ } catch (e) {
360
+ try {
361
+ db.exec('ROLLBACK');
362
+ } catch {
363
+ // ignore
364
+ }
365
+ if (legacyMode && isLikelyAlreadyAppliedError(e)) {
366
+ db.exec('BEGIN');
367
+ try {
368
+ insertQuery.run(randomUUID(), checksum, name);
369
+ db.exec('COMMIT');
370
+ } catch (inner) {
371
+ try {
372
+ db.exec('ROLLBACK');
373
+ } catch {
374
+ // ignore
375
+ }
376
+ throw inner;
377
+ }
378
+ appliedNow.push(name);
379
+ applied.add(name);
380
+ continue;
381
+ }
382
+ throw e;
383
+ }
189
384
  }
190
- return await response.json();
385
+
386
+ return { applied: appliedNow, skipped: false, reason: 'ok' };
191
387
  }
192
388
 
193
389
  async function findExecutableByName(rootDir, binaryName) {
@@ -202,30 +398,38 @@ async function findExecutableByName(rootDir, binaryName) {
202
398
  if (!entry.isFile()) continue;
203
399
  if (entry.name !== binaryName) continue;
204
400
  const info = await stat(fullPath);
401
+ if (process.platform === 'win32') return fullPath;
205
402
  if ((info.mode & 0o111) !== 0) return fullPath;
206
403
  }
207
404
  return '';
208
405
  }
209
406
 
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();
407
+ function resolveConfig({ channel, mode = 'user', platform = process.platform } = {}) {
408
+ const defaults = resolveSelfHostDefaults({ platform, mode, homeDir: homedir() });
409
+ const installRoot = String(process.env.HAPPIER_SELF_HOST_INSTALL_ROOT ?? defaults.installRoot).trim();
410
+ const binDir = String(process.env.HAPPIER_SELF_HOST_BIN_DIR ?? defaults.binDir).trim();
411
+ const configDir = String(process.env.HAPPIER_SELF_HOST_CONFIG_DIR ?? defaults.configDir).trim();
412
+ const dataDir = String(process.env.HAPPIER_SELF_HOST_DATA_DIR ?? defaults.dataDir).trim();
413
+ const logDir = String(process.env.HAPPIER_SELF_HOST_LOG_DIR ?? defaults.logDir).trim();
216
414
  const serviceName = String(process.env.HAPPIER_SELF_HOST_SERVICE_NAME ?? DEFAULTS.serviceName).trim();
217
415
  const serverHost = String(process.env.HAPPIER_SERVER_HOST ?? DEFAULTS.serverHost).trim();
218
416
  const serverPort = parsePort(process.env.HAPPIER_SERVER_PORT, DEFAULTS.serverPort);
219
417
  const githubRepo = String(process.env.HAPPIER_GITHUB_REPO ?? DEFAULTS.githubRepo).trim();
220
- const autoUpdate = parseBoolean(process.env.HAPPIER_SELF_HOST_AUTO_UPDATE, true);
418
+ const autoUpdate = resolveSelfHostAutoUpdateDefault(process.env);
419
+ const autoUpdateIntervalMinutes = resolveSelfHostAutoUpdateIntervalMinutes(process.env);
420
+ const serverBinaryName = platform === 'win32' ? 'happier-server.exe' : 'happier-server';
421
+ const uiWebRootDir = join(installRoot, 'ui-web');
221
422
 
222
423
  return {
223
424
  channel,
425
+ mode,
426
+ platform,
224
427
  installRoot,
225
428
  versionsDir: join(installRoot, 'versions'),
226
429
  installBinDir: join(installRoot, 'bin'),
227
- serverBinaryPath: join(installRoot, 'bin', 'happier-server'),
228
- serverPreviousBinaryPath: join(installRoot, 'bin', 'happier-server.previous'),
430
+ serverBinaryName,
431
+ serverBinaryPath: join(installRoot, 'bin', serverBinaryName),
432
+ serverPreviousBinaryPath: join(installRoot, 'bin', `${serverBinaryName}.previous`),
229
433
  statePath: join(installRoot, 'self-host-state.json'),
230
434
  binDir,
231
435
  configDir,
@@ -234,27 +438,83 @@ function resolveConfig({ channel }) {
234
438
  filesDir: join(dataDir, 'files'),
235
439
  dbDir: join(dataDir, 'pglite'),
236
440
  logDir,
237
- serverLogPath: join(logDir, 'server.log'),
441
+ serverStdoutLogPath: join(logDir, 'server.out.log'),
442
+ serverStderrLogPath: join(logDir, 'server.err.log'),
238
443
  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
444
  serverHost,
246
445
  serverPort,
247
446
  githubRepo,
248
447
  autoUpdate,
448
+ autoUpdateIntervalMinutes,
449
+ uiWebProduct: DEFAULTS.uiWebProduct,
450
+ uiWebOs: DEFAULTS.uiWebOs,
451
+ uiWebArch: DEFAULTS.uiWebArch,
452
+ uiWebRootDir,
453
+ uiWebVersionsDir: join(uiWebRootDir, 'versions'),
454
+ uiWebCurrentDir: join(uiWebRootDir, 'current'),
249
455
  };
250
456
  }
251
457
 
252
- export function renderServerEnvFile({ port, host, dataDir, filesDir, dbDir }) {
458
+ export function renderServerEnvFile({
459
+ port,
460
+ host,
461
+ dataDir,
462
+ filesDir,
463
+ dbDir,
464
+ uiDir,
465
+ serverBinDir = '',
466
+ arch = process.arch,
467
+ platform = process.platform,
468
+ }) {
469
+ const normalizedDataDir = String(dataDir ?? '').replace(/\/+$/, '') || String(dataDir ?? '');
470
+ const p = String(platform ?? '').trim() || process.platform;
471
+ const a = String(arch ?? '').trim() || process.arch;
472
+ const hasBunRuntime = typeof globalThis.Bun !== 'undefined';
473
+ // NOTE: Bun's native sqlite module (`bun:sqlite`) can hang when used inside launchd-managed binaries on macOS.
474
+ // We pre-apply migrations at install time in the self-host installer on that path instead.
475
+ const autoMigrateSqlite = p === 'darwin' && hasBunRuntime ? '0' : '1';
476
+ const migrationsDir =
477
+ p === 'win32'
478
+ ? win32Path.join(String(dataDir ?? ''), 'migrations', 'sqlite')
479
+ : `${normalizedDataDir}/migrations/sqlite`;
480
+ const dbPath =
481
+ p === 'win32'
482
+ ? win32Path.join(String(dataDir ?? ''), 'happier-server-light.sqlite')
483
+ : `${normalizedDataDir}/happier-server-light.sqlite`;
484
+ const databaseUrl =
485
+ p === 'win32'
486
+ ? (() => {
487
+ const normalized = String(dbPath).replaceAll('\\', '/');
488
+ if (/^[a-zA-Z]:\//.test(normalized)) return `file:///${normalized}`;
489
+ if (normalized.startsWith('//')) return `file:${normalized}`;
490
+ return `file:///${normalized}`;
491
+ })()
492
+ : `file:${dbPath}`;
493
+ const uiDirRaw = typeof uiDir === 'string' && uiDir.trim() ? uiDir.trim() : '';
494
+ const serverBinDirRaw = typeof serverBinDir === 'string' && serverBinDir.trim() ? serverBinDir.trim() : '';
495
+ const prismaEngineCandidate = serverBinDirRaw && p === 'darwin' && a === 'arm64'
496
+ ? join(serverBinDirRaw, 'generated', 'sqlite-client', 'libquery_engine-darwin-arm64.dylib.node')
497
+ : serverBinDirRaw && p === 'linux' && a === 'arm64'
498
+ ? join(serverBinDirRaw, 'generated', 'sqlite-client', 'libquery_engine-linux-arm64-openssl-1.1.x.so.node')
499
+ : '';
500
+ const prismaEnginePath = prismaEngineCandidate && existsSync(prismaEngineCandidate) ? prismaEngineCandidate : '';
253
501
  return [
254
502
  `PORT=${port}`,
255
503
  `HAPPIER_SERVER_HOST=${host}`,
504
+ ...(uiDirRaw ? [`HAPPIER_SERVER_UI_DIR=${uiDirRaw}`] : []),
505
+ 'METRICS_ENABLED=false',
506
+ // Bun-compiled server binaries currently exhibit unstable pglite path resolution in systemd environments.
256
507
  'HAPPIER_DB_PROVIDER=sqlite',
508
+ `DATABASE_URL=${databaseUrl}`,
257
509
  'HAPPIER_FILES_BACKEND=local',
510
+ ...(prismaEnginePath
511
+ ? [
512
+ 'PRISMA_CLIENT_ENGINE_TYPE=library',
513
+ `PRISMA_QUERY_ENGINE_LIBRARY=${prismaEnginePath}`,
514
+ ]
515
+ : []),
516
+ `HAPPIER_SQLITE_AUTO_MIGRATE=${autoMigrateSqlite}`,
517
+ `HAPPIER_SQLITE_MIGRATIONS_DIR=${migrationsDir}`,
258
518
  `HAPPIER_SERVER_LIGHT_DATA_DIR=${dataDir}`,
259
519
  `HAPPIER_SERVER_LIGHT_FILES_DIR=${filesDir}`,
260
520
  `HAPPIER_SERVER_LIGHT_DB_DIR=${dbDir}`,
@@ -262,6 +522,153 @@ export function renderServerEnvFile({ port, host, dataDir, filesDir, dbDir }) {
262
522
  ].join('\n');
263
523
  }
264
524
 
525
+ function parseEnvText(raw) {
526
+ const env = {};
527
+ for (const line of String(raw ?? '').split('\n')) {
528
+ const trimmed = line.trim();
529
+ if (!trimmed) continue;
530
+ if (trimmed.startsWith('#')) continue;
531
+ const idx = trimmed.indexOf('=');
532
+ if (idx <= 0) continue;
533
+ const k = trimmed.slice(0, idx).trim();
534
+ const v = trimmed.slice(idx + 1);
535
+ if (!k) continue;
536
+ env[k] = v;
537
+ }
538
+ return env;
539
+ }
540
+
541
+ function listEnvKeysInOrder(raw) {
542
+ const keys = [];
543
+ const seen = new Set();
544
+ for (const line of String(raw ?? '').split('\n')) {
545
+ const trimmed = line.trim();
546
+ if (!trimmed) continue;
547
+ if (trimmed.startsWith('#')) continue;
548
+ const idx = trimmed.indexOf('=');
549
+ if (idx <= 0) continue;
550
+ const k = trimmed.slice(0, idx).trim();
551
+ if (!k || seen.has(k)) continue;
552
+ seen.add(k);
553
+ keys.push(k);
554
+ }
555
+ return keys;
556
+ }
557
+
558
+ export function mergeEnvTextWithDefaults(existingText, defaultsText) {
559
+ const existingRaw = String(existingText ?? '');
560
+ const defaultsRaw = String(defaultsText ?? '');
561
+ if (!existingRaw.trim()) return defaultsRaw.endsWith('\n') ? defaultsRaw : `${defaultsRaw}\n`;
562
+
563
+ const existingEnv = parseEnvText(existingRaw);
564
+ const defaultsEnv = parseEnvText(defaultsRaw);
565
+ const defaultKeys = listEnvKeysInOrder(defaultsRaw);
566
+ const existingKeys = listEnvKeysInOrder(existingRaw);
567
+
568
+ const lines = [];
569
+ for (const key of defaultKeys) {
570
+ const fromExisting = Object.prototype.hasOwnProperty.call(existingEnv, key) ? existingEnv[key] : null;
571
+ const v = fromExisting != null ? fromExisting : defaultsEnv[key];
572
+ if (v == null) continue;
573
+ lines.push(`${key}=${v}`);
574
+ }
575
+ for (const key of existingKeys) {
576
+ if (Object.prototype.hasOwnProperty.call(defaultsEnv, key)) continue;
577
+ if (!Object.prototype.hasOwnProperty.call(existingEnv, key)) continue;
578
+ lines.push(`${key}=${existingEnv[key]}`);
579
+ }
580
+ lines.push('');
581
+ return `${lines.join('\n')}\n`;
582
+ }
583
+
584
+ function identity(s) {
585
+ return String(s ?? '');
586
+ }
587
+
588
+ function formatTriState(value, { yes, no, unknown }) {
589
+ if (value == null) return unknown('unknown');
590
+ return value ? yes('yes') : no('no');
591
+ }
592
+
593
+ function formatHealth(value, { ok, warn }) {
594
+ return value ? ok('ok') : warn('failed');
595
+ }
596
+
597
+ function formatJobState(value, { yes, no, unknown }) {
598
+ if (value == null) return unknown('unknown');
599
+ return value ? yes('enabled') : no('disabled');
600
+ }
601
+
602
+ function formatJobActive(value, { yes, no, unknown }) {
603
+ if (value == null) return unknown('unknown');
604
+ return value ? yes('active') : no('inactive');
605
+ }
606
+
607
+ export function renderSelfHostStatusText(report, { colors = true } = {}) {
608
+ const fmt = colors
609
+ ? {
610
+ label: cyan,
611
+ yes: green,
612
+ no: yellow,
613
+ ok: green,
614
+ warn: yellow,
615
+ unknown: dim,
616
+ dim,
617
+ }
618
+ : {
619
+ label: identity,
620
+ yes: identity,
621
+ no: identity,
622
+ ok: identity,
623
+ warn: identity,
624
+ unknown: identity,
625
+ dim: identity,
626
+ };
627
+
628
+ const channel = String(report?.channel ?? '').trim();
629
+ const mode = String(report?.mode ?? '').trim();
630
+ const serviceName = String(report?.serviceName ?? '').trim();
631
+ const serverUrl = String(report?.serverUrl ?? '').trim();
632
+ const healthy = Boolean(report?.healthy);
633
+ const updatedAt = report?.updatedAt ? String(report.updatedAt) : '';
634
+
635
+ const serviceActive = report?.service?.active ?? null;
636
+ const serviceEnabled = report?.service?.enabled ?? null;
637
+
638
+ const serverVersion = report?.versions?.server ? String(report.versions.server) : '';
639
+ const uiWebVersion = report?.versions?.uiWeb ? String(report.versions.uiWeb) : '';
640
+
641
+ const autoConfiguredEnabled = Boolean(report?.autoUpdate?.configured?.enabled);
642
+ const autoConfiguredInterval = report?.autoUpdate?.configured?.intervalMinutes ?? null;
643
+ const updaterEnabled = report?.autoUpdate?.job?.enabled ?? null;
644
+ const updaterActive = report?.autoUpdate?.job?.active ?? null;
645
+
646
+ const configuredLine = autoConfiguredEnabled
647
+ ? `configured enabled${autoConfiguredInterval ? ` (every ${autoConfiguredInterval}m)` : ''}`
648
+ : 'configured disabled';
649
+
650
+ const jobLine =
651
+ updaterEnabled == null && updaterActive == null
652
+ ? 'job unknown'
653
+ : `job ${formatJobState(updaterEnabled, fmt)}, ${formatJobActive(updaterActive, fmt)}`;
654
+
655
+ return [
656
+ channel ? `${fmt.label('channel')}: ${channel}` : null,
657
+ mode ? `${fmt.label('mode')}: ${mode}` : null,
658
+ serviceName ? `${fmt.label('service')}: ${serviceName}` : null,
659
+ serverUrl ? `${fmt.label('url')}: ${serverUrl}` : null,
660
+ `${fmt.label('health')}: ${formatHealth(healthy, fmt)}`,
661
+ `${fmt.label('active')}: ${formatTriState(serviceActive, fmt)}`,
662
+ `${fmt.label('enabled')}: ${formatTriState(serviceEnabled, fmt)}`,
663
+ `${fmt.label('auto-update')}: ${configuredLine}; ${jobLine}`,
664
+ `${fmt.label('server')}: ${serverVersion || fmt.unknown('unknown')}`,
665
+ `${fmt.label('ui-web')}: ${uiWebVersion || fmt.unknown('unknown')}`,
666
+ updatedAt ? `${fmt.label('updated')}: ${updatedAt}` : null,
667
+ ]
668
+ .filter(Boolean)
669
+ .join('\n');
670
+ }
671
+
265
672
  export function renderServerServiceUnit({ serviceName, binaryPath, envFilePath, workingDirectory, logPath }) {
266
673
  return [
267
674
  '[Unit]',
@@ -286,29 +693,90 @@ export function renderServerServiceUnit({ serviceName, binaryPath, envFilePath,
286
693
  ].join('\n');
287
694
  }
288
695
 
289
- function renderUpdaterServiceUnit({ updaterServiceName, hstackPath, channel }) {
696
+ export function buildSelfHostDoctorChecks(config, { state, commandExists: commandExistsOverride, pathExists } = {}) {
697
+ const cfg = config ?? {};
698
+ const platform = String(cfg.platform ?? process.platform).trim() || process.platform;
699
+ const mode = String(cfg.mode ?? 'user').trim() || 'user';
700
+ const os = normalizeOs(platform);
701
+
702
+ const commandExistsFn = typeof commandExistsOverride === 'function' ? commandExistsOverride : commandExists;
703
+ const pathExistsFn = typeof pathExists === 'function'
704
+ ? pathExists
705
+ : (p) => existsSync(String(p ?? ''));
706
+ const stateObj = state ?? {};
707
+
708
+ const uiExpected = Boolean(stateObj?.uiWeb?.installed);
709
+ const uiIndexPath = cfg.uiWebCurrentDir ? join(String(cfg.uiWebCurrentDir), 'index.html') : '';
710
+
290
711
  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');
712
+ { name: 'platform', ok: ['linux', 'darwin', 'windows'].includes(os) },
713
+ { name: 'mode', ok: mode === 'user' || (mode === 'system' && platform !== 'win32') },
714
+ // We verify minisign signatures using the bundled public key + node:crypto, so no external `minisign` dependency.
715
+ { name: 'tar', ok: commandExistsFn('tar') },
716
+ { name: 'powershell', ok: os === 'windows' ? commandExistsFn('powershell') : true },
717
+ { name: 'systemctl', ok: os === 'linux' ? commandExistsFn('systemctl') : true },
718
+ { name: 'launchctl', ok: os === 'darwin' ? commandExistsFn('launchctl') : true },
719
+ { name: 'schtasks', ok: os === 'windows' ? commandExistsFn('schtasks') : true },
720
+ { name: 'server-binary', ok: cfg.serverBinaryPath ? pathExistsFn(cfg.serverBinaryPath) : false },
721
+ { name: 'server-env', ok: cfg.configEnvPath ? pathExistsFn(cfg.configEnvPath) : false },
722
+ ...(uiExpected
723
+ ? [{ name: 'ui-web', ok: uiIndexPath ? pathExistsFn(uiIndexPath) : false }]
724
+ : []),
725
+ ];
726
+ }
727
+
728
+ export function renderUpdaterSystemdUnit({
729
+ updaterLabel,
730
+ hstackPath,
731
+ channel,
732
+ mode,
733
+ workingDirectory,
734
+ stdoutPath,
735
+ stderrPath,
736
+ wantedBy,
737
+ } = {}) {
738
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
739
+ const hstack = String(hstackPath ?? '').trim();
740
+ if (!hstack) throw new Error('[self-host] missing hstackPath for updater unit');
741
+ const ch = String(channel ?? '').trim() || 'stable';
742
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
743
+ const wd = String(workingDirectory ?? '').trim();
744
+ const out = String(stdoutPath ?? '').trim();
745
+ const err = String(stderrPath ?? '').trim();
746
+ const wb = String(wantedBy ?? '').trim() || 'default.target';
747
+
748
+ return renderSystemdServiceUnit({
749
+ description: `${label} (auto-update)`,
750
+ execStart: [
751
+ hstack,
752
+ 'self-host',
753
+ 'update',
754
+ `--channel=${ch}`,
755
+ `--mode=${m}`,
756
+ '--non-interactive',
757
+ ],
758
+ workingDirectory: wd,
759
+ env: {},
760
+ restart: 'no',
761
+ stdoutPath: out,
762
+ stderrPath: err,
763
+ wantedBy: wb,
764
+ });
300
765
  }
301
766
 
302
- function renderUpdaterTimerUnit({ updaterServiceName, updaterTimerName }) {
767
+ export function renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes = 1440 } = {}) {
768
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
769
+ const minutesRaw = Number(intervalMinutes);
770
+ const minutes = Number.isFinite(minutesRaw) ? Math.max(15, Math.floor(minutesRaw)) : 1440;
303
771
  return [
304
772
  '[Unit]',
305
- `Description=${updaterTimerName}`,
773
+ `Description=${label} (auto-update timer)`,
306
774
  '',
307
775
  '[Timer]',
308
- 'OnCalendar=weekly',
309
- 'RandomizedDelaySec=30m',
776
+ 'OnBootSec=5m',
777
+ `OnUnitActiveSec=${minutes}m`,
778
+ `Unit=${label}.service`,
310
779
  'Persistent=true',
311
- `Unit=${updaterServiceName}.service`,
312
780
  '',
313
781
  '[Install]',
314
782
  'WantedBy=timers.target',
@@ -316,15 +784,300 @@ function renderUpdaterTimerUnit({ updaterServiceName, updaterTimerName }) {
316
784
  ].join('\n');
317
785
  }
318
786
 
319
- async function restartAndCheckHealth({ serviceUnitName, serverPort }) {
320
- runCommand('systemctl', ['restart', serviceUnitName], { stdio: 'inherit' });
787
+ export function renderUpdaterLaunchdPlistXml({
788
+ updaterLabel,
789
+ hstackPath,
790
+ channel,
791
+ mode,
792
+ intervalMinutes,
793
+ workingDirectory,
794
+ stdoutPath,
795
+ stderrPath,
796
+ } = {}) {
797
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
798
+ const hstack = String(hstackPath ?? '').trim();
799
+ if (!hstack) throw new Error('[self-host] missing hstackPath for updater launchd plist');
800
+ const ch = String(channel ?? '').trim() || 'stable';
801
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
802
+ const wd = String(workingDirectory ?? '').trim();
803
+ const out = String(stdoutPath ?? '').trim();
804
+ const err = String(stderrPath ?? '').trim();
805
+ const intervalRaw = Number(intervalMinutes);
806
+ const startIntervalSec = Number.isFinite(intervalRaw) && intervalRaw > 0 ? Math.max(15, Math.floor(intervalRaw)) * 60 : 0;
807
+
808
+ return buildLaunchdPlistXml({
809
+ label,
810
+ programArgs: [
811
+ hstack,
812
+ 'self-host',
813
+ 'update',
814
+ `--channel=${ch}`,
815
+ `--mode=${m}`,
816
+ '--non-interactive',
817
+ ],
818
+ env: {
819
+ PATH: buildLaunchdPath({ execPath: process.execPath, basePath: process.env.PATH }),
820
+ },
821
+ stdoutPath: out,
822
+ stderrPath: err,
823
+ workingDirectory: wd,
824
+ keepAliveOnFailure: false,
825
+ startIntervalSec: startIntervalSec || undefined,
826
+ });
827
+ }
828
+
829
+ export function renderUpdaterScheduledTaskWrapperPs1({
830
+ updaterLabel,
831
+ hstackPath,
832
+ channel,
833
+ mode,
834
+ workingDirectory,
835
+ stdoutPath,
836
+ stderrPath,
837
+ } = {}) {
838
+ const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
839
+ const hstack = String(hstackPath ?? '').trim();
840
+ if (!hstack) throw new Error('[self-host] missing hstackPath for updater scheduled task wrapper');
841
+ const ch = String(channel ?? '').trim() || 'stable';
842
+ const m = String(mode ?? '').trim().toLowerCase() === 'system' ? 'system' : 'user';
843
+ const wd = String(workingDirectory ?? '').trim();
844
+ const out = String(stdoutPath ?? '').trim();
845
+ const err = String(stderrPath ?? '').trim();
846
+
847
+ return renderWindowsScheduledTaskWrapperPs1({
848
+ workingDirectory: wd,
849
+ programArgs: [
850
+ hstack,
851
+ 'self-host',
852
+ 'update',
853
+ `--channel=${ch}`,
854
+ `--mode=${m}`,
855
+ '--non-interactive',
856
+ ],
857
+ env: {},
858
+ stdoutPath: out,
859
+ stderrPath: err,
860
+ });
861
+ }
862
+
863
+ function resolveAutoUpdateEnabled(argv, fallback) {
864
+ const args = Array.isArray(argv) ? argv.map(String) : [];
865
+ if (args.includes('--no-auto-update')) return false;
866
+ if (args.includes('--auto-update')) return true;
867
+ return Boolean(fallback);
868
+ }
869
+
870
+ function resolveAutoUpdateIntervalMinutes(argv, fallback) {
871
+ const args = Array.isArray(argv) ? argv.map(String) : [];
872
+ const findEq = args.find((a) => a.startsWith('--auto-update-interval='));
873
+ const value = findEq
874
+ ? findEq.slice('--auto-update-interval='.length)
875
+ : (() => {
876
+ const idx = args.indexOf('--auto-update-interval');
877
+ if (idx >= 0 && args[idx + 1] && !String(args[idx + 1]).startsWith('-')) return String(args[idx + 1]);
878
+ return '';
879
+ })();
880
+ const raw = String(value ?? '').trim();
881
+ if (!raw) return Number(fallback) || DEFAULTS.autoUpdateIntervalMinutes;
882
+ const parsed = Number(raw);
883
+ const minutes = Number.isFinite(parsed) ? Math.floor(parsed) : NaN;
884
+ if (!Number.isFinite(minutes) || minutes < 15) return Number(fallback) || DEFAULTS.autoUpdateIntervalMinutes;
885
+ return Math.min(minutes, 60 * 24 * 7);
886
+ }
887
+
888
+ function resolveUpdaterLabel(config) {
889
+ const override = String(process.env.HAPPIER_SELF_HOST_UPDATER_LABEL ?? '').trim();
890
+ if (override) return override;
891
+ const base = String(config?.serviceName ?? '').trim() || 'happier-server';
892
+ return `${base}-updater`;
893
+ }
894
+
895
+ function resolveHstackPathForUpdater(config) {
896
+ const override = String(process.env.HAPPIER_SELF_HOST_HSTACK_PATH ?? '').trim();
897
+ if (override) return override;
898
+ const platform = String(config?.platform ?? '').trim() || process.platform;
899
+ const exe = platform === 'win32' ? 'hstack.exe' : 'hstack';
900
+ return join(String(config?.binDir ?? '').trim() || '', exe);
901
+ }
902
+
903
+ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
904
+ if (!enabled) return { installed: false, reason: 'disabled' };
905
+ const updaterLabel = resolveUpdaterLabel(config);
906
+ const hstackPath = resolveHstackPathForUpdater(config);
907
+ const stdoutPath = join(config.logDir, 'updater.out.log');
908
+ const stderrPath = join(config.logDir, 'updater.err.log');
909
+ const backend = resolveServiceBackend({ platform: config.platform, mode: config.mode });
910
+ const interval = resolveAutoUpdateIntervalMinutes([], intervalMinutes ?? config.autoUpdateIntervalMinutes);
911
+
912
+ const baseSpec = {
913
+ label: updaterLabel,
914
+ description: `Happier Self-Host (${updaterLabel})`,
915
+ programArgs: [hstackPath],
916
+ workingDirectory: config.installRoot,
917
+ env: {},
918
+ stdoutPath,
919
+ stderrPath,
920
+ };
921
+ const definitionPath = buildServiceDefinition({ backend, homeDir: homedir(), spec: baseSpec }).path;
922
+ const wantedBy =
923
+ backend === 'systemd-system' ? 'multi-user.target' : backend === 'systemd-user' ? 'default.target' : '';
924
+
925
+ if (backend === 'systemd-system' || backend === 'systemd-user') {
926
+ const timerPath = definitionPath.replace(/\.service$/, '.timer');
927
+ const serviceContents = renderUpdaterSystemdUnit({
928
+ updaterLabel,
929
+ hstackPath,
930
+ channel: config.channel,
931
+ mode: config.mode,
932
+ workingDirectory: config.installRoot,
933
+ stdoutPath,
934
+ stderrPath,
935
+ wantedBy,
936
+ });
937
+ const timerContents = renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes: interval });
938
+ const prefix = backend === 'systemd-user' ? ['--user'] : [];
939
+ const plan = {
940
+ writes: [
941
+ { path: definitionPath, contents: serviceContents, mode: 0o644 },
942
+ { path: timerPath, contents: timerContents, mode: 0o644 },
943
+ ],
944
+ commands: [
945
+ { cmd: 'systemctl', args: [...prefix, 'daemon-reload'] },
946
+ { cmd: 'systemctl', args: [...prefix, 'enable', '--now', `${updaterLabel}.timer`] },
947
+ { cmd: 'systemctl', args: [...prefix, 'start', `${updaterLabel}.service`], allowFail: true },
948
+ ],
949
+ };
950
+ await applyServicePlan(plan);
951
+ return { installed: true, backend, label: updaterLabel, definitionPath, timerPath, intervalMinutes: interval };
952
+ }
953
+
954
+ if (backend === 'launchd-system' || backend === 'launchd-user') {
955
+ const definitionContents = renderUpdaterLaunchdPlistXml({
956
+ updaterLabel,
957
+ hstackPath,
958
+ channel: config.channel,
959
+ mode: config.mode,
960
+ intervalMinutes: interval,
961
+ workingDirectory: config.installRoot,
962
+ stdoutPath,
963
+ stderrPath,
964
+ });
965
+ const plan = planServiceAction({
966
+ backend,
967
+ action: 'install',
968
+ label: updaterLabel,
969
+ definitionPath,
970
+ definitionContents,
971
+ persistent: true,
972
+ });
973
+ await applyServicePlan(plan);
974
+ return { installed: true, backend, label: updaterLabel, definitionPath, intervalMinutes: interval };
975
+ }
976
+
977
+ const definitionContents = renderUpdaterScheduledTaskWrapperPs1({
978
+ updaterLabel,
979
+ hstackPath,
980
+ channel: config.channel,
981
+ mode: config.mode,
982
+ workingDirectory: config.installRoot,
983
+ stdoutPath,
984
+ stderrPath,
985
+ });
986
+ const name = `Happier\\${updaterLabel}`;
987
+ const ps = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${definitionPath}"`;
988
+ const args = [
989
+ '/Create',
990
+ '/F',
991
+ '/SC',
992
+ 'MINUTE',
993
+ '/MO',
994
+ String(interval),
995
+ '/ST',
996
+ '00:00',
997
+ '/TN',
998
+ name,
999
+ '/TR',
1000
+ ps,
1001
+ ...(backend === 'schtasks-system' ? ['/RU', 'SYSTEM', '/RL', 'HIGHEST'] : []),
1002
+ ];
1003
+ const plan = {
1004
+ writes: [{ path: definitionPath, contents: definitionContents, mode: 0o644 }],
1005
+ commands: [
1006
+ { cmd: 'schtasks', args },
1007
+ { cmd: 'schtasks', args: ['/Run', '/TN', name] },
1008
+ ],
1009
+ };
1010
+ await applyServicePlan(plan);
1011
+ return { installed: true, backend, label: updaterLabel, definitionPath, taskName: name, intervalMinutes: interval };
1012
+ }
1013
+
1014
+ async function uninstallAutoUpdateJob({ config }) {
1015
+ const updaterLabel = resolveUpdaterLabel(config);
1016
+ const hstackPath = resolveHstackPathForUpdater(config);
1017
+ const stdoutPath = join(config.logDir, 'updater.out.log');
1018
+ const stderrPath = join(config.logDir, 'updater.err.log');
1019
+ const backend = resolveServiceBackend({ platform: config.platform, mode: config.mode });
1020
+ const baseSpec = {
1021
+ label: updaterLabel,
1022
+ description: `Happier Self-Host (${updaterLabel})`,
1023
+ programArgs: [hstackPath],
1024
+ workingDirectory: config.installRoot,
1025
+ env: {},
1026
+ stdoutPath,
1027
+ stderrPath,
1028
+ };
1029
+ const definitionPath = buildServiceDefinition({ backend, homeDir: homedir(), spec: baseSpec }).path;
1030
+
1031
+ if (backend === 'systemd-system' || backend === 'systemd-user') {
1032
+ const prefix = backend === 'systemd-user' ? ['--user'] : [];
1033
+ const timerPath = definitionPath.replace(/\.service$/, '.timer');
1034
+ const plan = {
1035
+ writes: [],
1036
+ commands: [
1037
+ { cmd: 'systemctl', args: [...prefix, 'disable', '--now', `${updaterLabel}.timer`], allowFail: true },
1038
+ { cmd: 'systemctl', args: [...prefix, 'disable', '--now', `${updaterLabel}.service`], allowFail: true },
1039
+ { cmd: 'systemctl', args: [...prefix, 'daemon-reload'] },
1040
+ ],
1041
+ };
1042
+ await applyServicePlan(plan);
1043
+ await rm(timerPath, { force: true }).catch(() => {});
1044
+ await rm(definitionPath, { force: true }).catch(() => {});
1045
+ return { uninstalled: true, backend, label: updaterLabel };
1046
+ }
1047
+
1048
+ if (backend === 'launchd-system' || backend === 'launchd-user') {
1049
+ const plan = planServiceAction({
1050
+ backend,
1051
+ action: 'uninstall',
1052
+ label: updaterLabel,
1053
+ definitionPath,
1054
+ persistent: true,
1055
+ });
1056
+ await applyServicePlan(plan);
1057
+ await rm(definitionPath, { force: true }).catch(() => {});
1058
+ return { uninstalled: true, backend, label: updaterLabel };
1059
+ }
1060
+
1061
+ const name = `Happier\\${updaterLabel}`;
1062
+ const plan = {
1063
+ writes: [],
1064
+ commands: [
1065
+ { cmd: 'schtasks', args: ['/End', '/TN', name], allowFail: true },
1066
+ { cmd: 'schtasks', args: ['/Delete', '/F', '/TN', name], allowFail: true },
1067
+ ],
1068
+ };
1069
+ await applyServicePlan(plan);
1070
+ await rm(definitionPath, { force: true }).catch(() => {});
1071
+ return { uninstalled: true, backend, label: updaterLabel };
1072
+ }
1073
+
1074
+ async function restartAndCheckHealth({ config, serviceSpec }) {
1075
+ await restartManagedService({ platform: config.platform, mode: config.mode, spec: serviceSpec }).catch(() => {});
1076
+ const timeoutMs = resolveSelfHostHealthTimeoutMs();
321
1077
  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
- }
1078
+ while (Date.now() - startedAt < timeoutMs) {
1079
+ const ok = await checkHealth({ port: config.serverPort });
1080
+ if (ok) return true;
328
1081
  await new Promise((resolve) => setTimeout(resolve, 1500));
329
1082
  }
330
1083
  return false;
@@ -343,53 +1096,9 @@ async function checkHealth({ port }) {
343
1096
  }
344
1097
  }
345
1098
 
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
- }
1099
+ export function resolveMinisignPublicKeyText(env = process.env) {
1100
+ const inline = String(env?.HAPPIER_MINISIGN_PUBKEY ?? '').trim();
1101
+ return inline || DEFAULT_MINISIGN_PUBLIC_KEY;
393
1102
  }
394
1103
 
395
1104
  async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, previousBinaryPath, versionedTargetPath }) {
@@ -397,16 +1106,44 @@ async function installBinaryAtomically({ sourceBinaryPath, targetBinaryPath, pre
397
1106
  await mkdir(dirname(versionedTargetPath), { recursive: true });
398
1107
  const stagedPath = `${targetBinaryPath}.new`;
399
1108
  await copyFile(sourceBinaryPath, stagedPath);
400
- await chmod(stagedPath, 0o755);
1109
+ await chmod(stagedPath, 0o755).catch(() => {});
401
1110
  if (existsSync(targetBinaryPath)) {
402
1111
  await copyFile(targetBinaryPath, previousBinaryPath);
403
- await chmod(previousBinaryPath, 0o755);
1112
+ await chmod(previousBinaryPath, 0o755).catch(() => {});
404
1113
  }
405
1114
  await copyFile(stagedPath, versionedTargetPath);
406
- await chmod(versionedTargetPath, 0o755);
1115
+ await chmod(versionedTargetPath, 0o755).catch(() => {});
407
1116
  await rm(stagedPath, { force: true });
408
1117
  await copyFile(versionedTargetPath, targetBinaryPath);
409
- await chmod(targetBinaryPath, 0o755);
1118
+ await chmod(targetBinaryPath, 0o755).catch(() => {});
1119
+ }
1120
+
1121
+ async function syncSelfHostSqliteMigrations({ artifactRootDir, targetDir }) {
1122
+ const root = String(artifactRootDir ?? '').trim();
1123
+ const dest = String(targetDir ?? '').trim();
1124
+ if (!root || !dest) return { copied: false, reason: 'missing-paths' };
1125
+
1126
+ const source = join(root, 'prisma', 'sqlite', 'migrations');
1127
+ if (!existsSync(source)) return { copied: false, reason: 'missing-source' };
1128
+
1129
+ await rm(dest, { recursive: true, force: true });
1130
+ await mkdir(dirname(dest), { recursive: true });
1131
+ await cp(source, dest, { recursive: true });
1132
+ return { copied: true, reason: 'ok' };
1133
+ }
1134
+
1135
+ async function syncSelfHostGeneratedClients({ artifactRootDir, targetDir }) {
1136
+ const root = String(artifactRootDir ?? '').trim();
1137
+ const dest = String(targetDir ?? '').trim();
1138
+ if (!root || !dest) return { copied: false, reason: 'missing-paths' };
1139
+
1140
+ const source = join(root, 'generated');
1141
+ if (!existsSync(source)) return { copied: false, reason: 'missing-source' };
1142
+
1143
+ await rm(dest, { recursive: true, force: true });
1144
+ await mkdir(dirname(dest), { recursive: true });
1145
+ await cp(source, dest, { recursive: true });
1146
+ return { copied: true, reason: 'ok' };
410
1147
  }
411
1148
 
412
1149
  async function installFromRelease({ product, binaryName, config, explicitBinaryPath = '' }) {
@@ -422,62 +1159,176 @@ async function installFromRelease({ product, binaryName, config, explicitBinaryP
422
1159
  previousBinaryPath: config.serverPreviousBinaryPath,
423
1160
  versionedTargetPath: join(config.versionsDir, `${binaryName}-${version}`),
424
1161
  });
1162
+ await syncSelfHostSqliteMigrations({
1163
+ artifactRootDir: dirname(srcPath),
1164
+ targetDir: join(config.dataDir, 'migrations', 'sqlite'),
1165
+ }).catch(() => {});
1166
+ const generated = await syncSelfHostGeneratedClients({
1167
+ artifactRootDir: dirname(srcPath),
1168
+ targetDir: join(dirname(config.serverBinaryPath), 'generated'),
1169
+ });
1170
+ if (!generated.copied) {
1171
+ throw new Error('[self-host] server runtime is missing packaged generated clients');
1172
+ }
425
1173
  return { version, source: 'local' };
426
1174
  }
427
1175
 
428
1176
  const channelTag = config.channel === 'preview' ? 'server-preview' : 'server-stable';
429
- const release = await readRelease(channelTag, config.githubRepo);
430
- const asset = pickReleaseAsset({
1177
+ const release = await fetchGitHubReleaseByTag({
1178
+ githubRepo: config.githubRepo,
1179
+ tag: channelTag,
1180
+ userAgent: 'happier-self-host-installer',
1181
+ githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
1182
+ });
1183
+ const os = normalizeOs(config.platform);
1184
+ const resolved = resolveReleaseAssetBundle({
431
1185
  assets: release?.assets,
432
1186
  product,
433
- os: 'linux',
1187
+ os,
434
1188
  arch: normalizeArch(),
435
1189
  });
436
1190
 
437
1191
  const tempDir = await mkdtemp(join(tmpdir(), 'happier-self-host-release-'));
438
1192
  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
- }
452
-
453
- const publicKey = await resolveMinisignPublicKey();
454
- await verifySignature({
455
- checksumsPath,
456
- signatureUrl: asset.signatureUrl,
457
- publicKey,
1193
+ const pubkeyFile = resolveMinisignPublicKeyText(process.env);
1194
+ const downloaded = await downloadVerifiedReleaseAssetBundle({
1195
+ bundle: resolved,
1196
+ destDir: tempDir,
1197
+ pubkeyFile,
1198
+ userAgent: 'happier-self-host-installer',
458
1199
  });
459
1200
 
460
1201
  const extractDir = join(tempDir, 'extract');
461
1202
  await mkdir(extractDir, { recursive: true });
462
- runCommand('tar', ['-xzf', archivePath, '-C', extractDir], { stdio: 'ignore' });
1203
+ const plan = planArchiveExtraction({
1204
+ archiveName: downloaded.archiveName,
1205
+ archivePath: downloaded.archivePath,
1206
+ destDir: extractDir,
1207
+ os,
1208
+ });
1209
+ if (!commandExists(plan.requiredCommand)) {
1210
+ throw new Error(`[self-host] ${plan.requiredCommand} is required to extract release artifacts`);
1211
+ }
1212
+ runCommand(plan.command.cmd, plan.command.args, { stdio: 'ignore' });
463
1213
  const extractedBinary = await findExecutableByName(extractDir, binaryName);
464
1214
  if (!extractedBinary) {
465
1215
  throw new Error('[self-host] failed to locate extracted server binary');
466
1216
  }
467
1217
 
468
- const version = asset.version || String(release?.tag_name ?? '').replace(/^server-v/, '') || `${Date.now()}`;
1218
+ const version = resolved.version || String(release?.tag_name ?? '').replace(/^server-v/, '') || `${Date.now()}`;
469
1219
  await installBinaryAtomically({
470
1220
  sourceBinaryPath: extractedBinary,
471
1221
  targetBinaryPath: config.serverBinaryPath,
472
1222
  previousBinaryPath: config.serverPreviousBinaryPath,
473
1223
  versionedTargetPath: join(config.versionsDir, `${binaryName}-${version}`),
474
1224
  });
1225
+ const roots = await readdir(extractDir).catch(() => []);
1226
+ const artifactRootDir = roots.length > 0 ? join(extractDir, roots[0]) : extractDir;
1227
+ await syncSelfHostSqliteMigrations({
1228
+ artifactRootDir,
1229
+ targetDir: join(config.dataDir, 'migrations', 'sqlite'),
1230
+ }).catch(() => {});
1231
+ const generated = await syncSelfHostGeneratedClients({
1232
+ artifactRootDir,
1233
+ targetDir: join(dirname(config.serverBinaryPath), 'generated'),
1234
+ });
1235
+ if (!generated.copied) {
1236
+ throw new Error('[self-host] server runtime is missing packaged generated clients');
1237
+ }
475
1238
  return { version, source: asset.archiveUrl };
476
1239
  } finally {
477
1240
  await rm(tempDir, { recursive: true, force: true });
478
1241
  }
479
1242
  }
480
1243
 
1244
+ async function assertUiWebBundleIsValid(rootDir) {
1245
+ const indexPath = join(rootDir, 'index.html');
1246
+ const info = await stat(indexPath).catch(() => null);
1247
+ if (!info?.isFile()) {
1248
+ throw new Error(`[self-host] UI web bundle is missing index.html: ${indexPath}`);
1249
+ }
1250
+ }
1251
+
1252
+ async function installUiWebFromRelease({ config }) {
1253
+ const tags = config.channel === 'preview'
1254
+ ? ['ui-web-preview', 'ui-web-stable']
1255
+ : ['ui-web-stable'];
1256
+
1257
+ const resolvedRelease = await fetchFirstGitHubReleaseByTags({
1258
+ githubRepo: config.githubRepo,
1259
+ tags,
1260
+ userAgent: 'happier-self-host-installer',
1261
+ githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
1262
+ }).catch((e) => {
1263
+ const status = Number(e?.status);
1264
+ if (status === 404) return null;
1265
+ throw e;
1266
+ });
1267
+
1268
+ if (!resolvedRelease) {
1269
+ return {
1270
+ installed: false,
1271
+ version: null,
1272
+ source: null,
1273
+ reason: `ui web release tag not found (${tags.join(', ')})`,
1274
+ };
1275
+ }
1276
+ const { release, tag: channelTag } = resolvedRelease;
1277
+
1278
+ const resolved = resolveReleaseAssetBundle({
1279
+ assets: release?.assets,
1280
+ product: config.uiWebProduct,
1281
+ os: config.uiWebOs,
1282
+ arch: config.uiWebArch,
1283
+ });
1284
+
1285
+ const tempDir = await mkdtemp(join(tmpdir(), 'happier-self-host-ui-web-'));
1286
+ try {
1287
+ const pubkeyFile = resolveMinisignPublicKeyText(process.env);
1288
+ const downloaded = await downloadVerifiedReleaseAssetBundle({
1289
+ bundle: resolved,
1290
+ destDir: tempDir,
1291
+ pubkeyFile,
1292
+ userAgent: 'happier-self-host-installer',
1293
+ });
1294
+
1295
+ const extractDir = join(tempDir, 'extract');
1296
+ await mkdir(extractDir, { recursive: true });
1297
+ const plan = planArchiveExtraction({
1298
+ archiveName: downloaded.archiveName,
1299
+ archivePath: downloaded.archivePath,
1300
+ destDir: extractDir,
1301
+ os: normalizeOs(config.platform),
1302
+ });
1303
+ if (!commandExists(plan.requiredCommand)) {
1304
+ throw new Error(`[self-host] ${plan.requiredCommand} is required to extract ui web bundle artifacts`);
1305
+ }
1306
+ runCommand(plan.command.cmd, plan.command.args, { stdio: 'ignore' });
1307
+
1308
+ const roots = await readdir(extractDir).catch(() => []);
1309
+ if (roots.length === 0) {
1310
+ throw new Error('[self-host] extracted ui web bundle is empty');
1311
+ }
1312
+ const artifactRootDir = join(extractDir, roots[0]);
1313
+ await assertUiWebBundleIsValid(artifactRootDir);
1314
+
1315
+ const version = resolved.version || String(release?.tag_name ?? '').replace(/^ui-web-v/, '') || `${Date.now()}`;
1316
+ const versionedTargetDir = join(config.uiWebVersionsDir, `${config.uiWebProduct}-${version}`);
1317
+ await rm(versionedTargetDir, { recursive: true, force: true });
1318
+ await mkdir(dirname(versionedTargetDir), { recursive: true });
1319
+ await cp(artifactRootDir, versionedTargetDir, { recursive: true });
1320
+
1321
+ await rm(config.uiWebCurrentDir, { recursive: true, force: true }).catch(() => {});
1322
+ await symlink(versionedTargetDir, config.uiWebCurrentDir, config.platform === 'win32' ? 'junction' : 'dir').catch(async () => {
1323
+ await cp(versionedTargetDir, config.uiWebCurrentDir, { recursive: true });
1324
+ });
1325
+
1326
+ return { installed: true, version, source: downloaded.source.archiveUrl, tag: channelTag };
1327
+ } finally {
1328
+ await rm(tempDir, { recursive: true, force: true });
1329
+ }
1330
+ }
1331
+
481
1332
  async function writeSelfHostState(config, statePatch) {
482
1333
  const existing = existsSync(config.statePath)
483
1334
  ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
@@ -518,20 +1369,52 @@ async function maybeInstallCompanionCli({ channel, nonInteractive, withCli }) {
518
1369
  };
519
1370
  }
520
1371
 
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');
1372
+ function buildSelfHostServerServiceSpec({ config, envText }) {
1373
+ return {
1374
+ label: config.serviceName,
1375
+ description: `Happier Self-Host (${config.serviceName})`,
1376
+ programArgs: [config.serverBinaryPath],
1377
+ workingDirectory: config.installRoot,
1378
+ env: parseEnvText(envText),
1379
+ stdoutPath: config.serverStdoutLogPath,
1380
+ stderrPath: config.serverStderrLogPath,
1381
+ };
1382
+ }
1383
+
1384
+ async function cmdInstall({ channel, mode, argv, json }) {
1385
+ if (mode === 'system' && process.platform !== 'win32') {
1386
+ assertRoot();
529
1387
  }
530
- const config = resolveConfig({ channel });
1388
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1389
+ const autoUpdateEnabled = resolveAutoUpdateEnabled(argv, config.autoUpdate);
1390
+ const autoUpdateIntervalMinutes = resolveAutoUpdateIntervalMinutes(argv, config.autoUpdateIntervalMinutes);
531
1391
  const withoutCli = argv.includes('--without-cli') || parseBoolean(process.env.HAPPIER_WITH_CLI, true) === false;
1392
+ const withUi =
1393
+ !(argv.includes('--without-ui')
1394
+ || parseBoolean(process.env.HAPPIER_WITH_UI, true) === false
1395
+ || parseBoolean(process.env.HAPPIER_SELF_HOST_WITH_UI, true) === false);
532
1396
  const nonInteractive = argv.includes('--non-interactive') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
533
1397
  const serverBinaryOverride = String(process.env.HAPPIER_SELF_HOST_SERVER_BINARY ?? '').trim();
534
1398
 
1399
+ if (normalizeOs(config.platform) !== 'windows' && !commandExists('tar')) {
1400
+ throw new Error('[self-host] tar is required to extract release artifacts');
1401
+ }
1402
+ if (normalizeOs(config.platform) === 'windows' && !commandExists('powershell')) {
1403
+ throw new Error('[self-host] powershell is required on Windows');
1404
+ }
1405
+ if (withUi && !commandExists('tar')) {
1406
+ throw new Error('[self-host] tar is required to extract ui web bundle artifacts');
1407
+ }
1408
+ if (normalizeOs(config.platform) === 'linux' && !commandExists('systemctl')) {
1409
+ throw new Error('[self-host] systemctl is required on Linux');
1410
+ }
1411
+ if (autoUpdateEnabled && normalizeOs(config.platform) === 'darwin' && !commandExists('launchctl')) {
1412
+ throw new Error('[self-host] launchctl is required on macOS for auto-update scheduling');
1413
+ }
1414
+ if (autoUpdateEnabled && normalizeOs(config.platform) === 'windows' && !commandExists('schtasks')) {
1415
+ throw new Error('[self-host] schtasks is required on Windows for auto-update scheduling');
1416
+ }
1417
+
535
1418
  await mkdir(config.installRoot, { recursive: true });
536
1419
  await mkdir(config.installBinDir, { recursive: true });
537
1420
  await mkdir(config.versionsDir, { recursive: true });
@@ -540,80 +1423,67 @@ async function cmdInstall({ channel, argv, json }) {
540
1423
  await mkdir(config.filesDir, { recursive: true });
541
1424
  await mkdir(config.dbDir, { recursive: true });
542
1425
  await mkdir(config.logDir, { recursive: true });
1426
+ await mkdir(config.uiWebRootDir, { recursive: true });
1427
+ await mkdir(config.uiWebVersionsDir, { recursive: true });
543
1428
 
544
1429
  const installResult = await installFromRelease({
545
1430
  product: 'happier-server',
546
- binaryName: 'happier-server',
1431
+ binaryName: config.serverBinaryName,
547
1432
  config,
548
1433
  explicitBinaryPath: serverBinaryOverride,
549
1434
  });
550
1435
 
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
- );
1436
+ const uiResult = withUi
1437
+ ? await installUiWebFromRelease({ config })
1438
+ : { installed: false, version: null, source: null, reason: 'disabled' };
1439
+ const uiInstalled = Boolean(uiResult?.installed);
1440
+
1441
+ const envText = renderServerEnvFile({
1442
+ port: config.serverPort,
1443
+ host: config.serverHost,
1444
+ dataDir: config.dataDir,
1445
+ filesDir: config.filesDir,
1446
+ dbDir: config.dbDir,
1447
+ uiDir: uiInstalled ? config.uiWebCurrentDir : '',
1448
+ serverBinDir: dirname(config.serverBinaryPath),
1449
+ arch: process.arch,
1450
+ platform: config.platform,
1451
+ });
1452
+ await writeFile(config.configEnvPath, envText, 'utf-8');
1453
+ const installEnv = parseEnvText(envText);
1454
+ if (!parseBoolean(installEnv.HAPPIER_SQLITE_AUTO_MIGRATE ?? installEnv.HAPPY_SQLITE_AUTO_MIGRATE, true)) {
1455
+ await applySelfHostSqliteMigrationsAtInstallTime({ env: installEnv }).catch((e) => {
1456
+ throw new Error(`[self-host] failed to apply sqlite migrations at install time: ${String(e?.message ?? e)}`);
1457
+ });
1458
+ }
594
1459
 
595
- const serverShimPath = join(config.binDir, 'happier-server');
1460
+ const serverShimPath = join(config.binDir, config.serverBinaryName);
596
1461
  await mkdir(config.binDir, { recursive: true });
597
1462
  await rm(serverShimPath, { force: true });
598
1463
  await symlink(config.serverBinaryPath, serverShimPath).catch(async () => {
599
1464
  await copyFile(config.serverBinaryPath, serverShimPath);
600
- await chmod(serverShimPath, 0o755);
1465
+ await chmod(serverShimPath, 0o755).catch(() => {});
601
1466
  });
602
1467
 
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
- }
1468
+ const serviceSpec = buildSelfHostServerServiceSpec({ config, envText });
1469
+ await installManagedService({
1470
+ platform: config.platform,
1471
+ mode: config.mode,
1472
+ homeDir: homedir(),
1473
+ spec: serviceSpec,
1474
+ persistent: true,
1475
+ });
611
1476
 
612
- const healthy = await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1477
+ const healthy = await restartAndCheckHealth({ config, serviceSpec });
613
1478
  if (!healthy) {
614
1479
  throw new Error('[self-host] service failed health checks after install');
615
1480
  }
616
1481
 
1482
+ const autoUpdateResult = await installAutoUpdateJob({ config, enabled: autoUpdateEnabled, intervalMinutes: autoUpdateIntervalMinutes }).catch((e) => ({
1483
+ installed: false,
1484
+ reason: String(e?.message ?? e),
1485
+ }));
1486
+
617
1487
  const cliResult = await maybeInstallCompanionCli({
618
1488
  channel,
619
1489
  nonInteractive,
@@ -621,10 +1491,14 @@ async function cmdInstall({ channel, argv, json }) {
621
1491
  });
622
1492
  await writeSelfHostState(config, {
623
1493
  channel,
1494
+ mode,
624
1495
  version: installResult.version,
625
1496
  source: installResult.source,
626
- autoUpdate: config.autoUpdate,
627
1497
  withCli: !withoutCli,
1498
+ uiWeb: uiInstalled
1499
+ ? { installed: true, version: uiResult.version, source: uiResult.source, tag: uiResult.tag }
1500
+ : { installed: false, reason: String(uiResult?.reason ?? (withUi ? 'missing' : 'disabled')) },
1501
+ autoUpdate: { enabled: autoUpdateEnabled, intervalMinutes: autoUpdateIntervalMinutes },
628
1502
  });
629
1503
 
630
1504
  printResult({
@@ -632,84 +1506,253 @@ async function cmdInstall({ channel, argv, json }) {
632
1506
  data: {
633
1507
  ok: true,
634
1508
  channel,
1509
+ mode,
635
1510
  version: installResult.version,
636
- service: config.serviceUnitName,
1511
+ service: config.serviceName,
637
1512
  serverPort: config.serverPort,
1513
+ autoUpdate: {
1514
+ enabled: autoUpdateEnabled,
1515
+ intervalMinutes: autoUpdateIntervalMinutes,
1516
+ ...autoUpdateResult,
1517
+ },
638
1518
  cli: cliResult,
639
1519
  },
640
1520
  text: [
641
1521
  `${green('✓')} Happier Self-Host installed`,
642
- `- service: ${cyan(config.serviceUnitName)}`,
1522
+ `- mode: ${cyan(mode)}`,
1523
+ `- service: ${cyan(config.serviceName)}`,
643
1524
  `- version: ${cyan(installResult.version || 'unknown')}`,
644
1525
  `- server: ${cyan(`http://127.0.0.1:${config.serverPort}`)}`,
1526
+ `- auto-update: ${autoUpdateEnabled ? (autoUpdateResult.installed ? green(`installed (every ${autoUpdateIntervalMinutes}m)`) : yellow('failed')) : dim('disabled')}`,
645
1527
  `- cli: ${cliResult.installed ? green('installed') : dim(cliResult.reason)}`,
1528
+ `- ui: ${uiInstalled ? green('installed') : dim(String(uiResult?.reason ?? 'disabled'))}`,
646
1529
  ].join('\n'),
647
1530
  });
648
1531
  }
649
1532
 
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;
1533
+ async function cmdStatus({ channel, mode, json }) {
1534
+ const config = resolveConfig({ channel, mode, platform: process.platform });
658
1535
  const state = existsSync(config.statePath)
659
1536
  ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
660
1537
  : {};
661
1538
 
1539
+ let active = null;
1540
+ let enabled = null;
1541
+ let updaterActive = null;
1542
+ let updaterEnabled = null;
1543
+ const updaterLabel = resolveUpdaterLabel(config);
1544
+ try {
1545
+ if (config.platform === 'linux' && commandExists('systemctl')) {
1546
+ const prefix = config.mode === 'user' ? ['--user'] : [];
1547
+ const isActive = runCommand('systemctl', [...prefix, 'is-active', '--quiet', `${config.serviceName}.service`], {
1548
+ allowFail: true,
1549
+ stdio: 'ignore',
1550
+ });
1551
+ active = (isActive.status ?? 1) === 0;
1552
+ const isEnabled = runCommand('systemctl', [...prefix, 'is-enabled', '--quiet', `${config.serviceName}.service`], {
1553
+ allowFail: true,
1554
+ stdio: 'ignore',
1555
+ });
1556
+ enabled = (isEnabled.status ?? 1) === 0;
1557
+
1558
+ const updaterTimerIsActive = runCommand('systemctl', [...prefix, 'is-active', '--quiet', `${updaterLabel}.timer`], {
1559
+ allowFail: true,
1560
+ stdio: 'ignore',
1561
+ });
1562
+ updaterActive = (updaterTimerIsActive.status ?? 1) === 0;
1563
+ const updaterIsEnabled = runCommand('systemctl', [...prefix, 'is-enabled', '--quiet', `${updaterLabel}.timer`], {
1564
+ allowFail: true,
1565
+ stdio: 'ignore',
1566
+ });
1567
+ updaterEnabled = (updaterIsEnabled.status ?? 1) === 0;
1568
+ } else if (config.platform === 'darwin' && commandExists('launchctl')) {
1569
+ const list = runCommand('launchctl', ['list'], { allowFail: true, stdio: 'pipe' });
1570
+ const out = String(list.stdout ?? '');
1571
+ active = out.includes(`\t${config.serviceName}`) || out.includes(` ${config.serviceName}`);
1572
+ updaterActive = out.includes(`\t${updaterLabel}`) || out.includes(` ${updaterLabel}`);
1573
+ enabled = null;
1574
+ updaterEnabled = null;
1575
+ } else if (config.platform === 'win32' && commandExists('schtasks')) {
1576
+ const query = runCommand('schtasks', ['/Query', '/TN', `Happier\\${config.serviceName}`, '/FO', 'LIST', '/V'], {
1577
+ allowFail: true,
1578
+ stdio: 'pipe',
1579
+ });
1580
+ const out = String(query.stdout ?? '');
1581
+ active = /Status:\s*Running/i.test(out) ? true : /Status:/i.test(out) ? false : null;
1582
+ enabled = /Scheduled Task State:\s*Enabled/i.test(out) ? true : /Scheduled Task State:/i.test(out) ? false : null;
1583
+
1584
+ const updaterQuery = runCommand('schtasks', ['/Query', '/TN', `Happier\\${updaterLabel}`, '/FO', 'LIST', '/V'], {
1585
+ allowFail: true,
1586
+ stdio: 'pipe',
1587
+ });
1588
+ const updaterOut = String(updaterQuery.stdout ?? '');
1589
+ updaterActive = /Status:\s*Running/i.test(updaterOut) ? true : /Status:/i.test(updaterOut) ? false : null;
1590
+ updaterEnabled = /Scheduled Task State:\s*Enabled/i.test(updaterOut)
1591
+ ? true
1592
+ : /Scheduled Task State:/i.test(updaterOut)
1593
+ ? false
1594
+ : null;
1595
+ }
1596
+ } catch {
1597
+ active = null;
1598
+ enabled = null;
1599
+ updaterActive = null;
1600
+ updaterEnabled = null;
1601
+ }
1602
+
1603
+ const healthy = await checkHealth({ port: config.serverPort });
1604
+ const serverVersion = state?.version ? String(state.version) : '';
1605
+ const uiWebVersion =
1606
+ state?.uiWeb?.installed === true && state?.uiWeb?.version
1607
+ ? String(state.uiWeb.version)
1608
+ : '';
1609
+ const autoUpdateState = normalizeSelfHostAutoUpdateState(state, {
1610
+ fallbackIntervalMinutes: config.autoUpdateIntervalMinutes,
1611
+ });
1612
+
1613
+ const serverUrl = `http://${config.serverHost}:${config.serverPort}`;
662
1614
  printResult({
663
1615
  json,
664
1616
  data: {
665
1617
  ok: true,
666
1618
  channel,
1619
+ mode,
1620
+ serverUrl,
1621
+ versions: {
1622
+ server: serverVersion || null,
1623
+ uiWeb: uiWebVersion || null,
1624
+ },
667
1625
  service: {
668
- name: config.serviceUnitName,
1626
+ name: config.serviceName,
669
1627
  active,
670
1628
  enabled,
671
1629
  },
1630
+ autoUpdate: {
1631
+ label: updaterLabel,
1632
+ active: updaterActive,
1633
+ enabled: updaterEnabled,
1634
+ configured: {
1635
+ enabled: Boolean(autoUpdateState?.enabled),
1636
+ intervalMinutes: autoUpdateState?.intervalMinutes ?? null,
1637
+ },
1638
+ },
672
1639
  healthy,
673
1640
  state,
674
1641
  },
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'),
1642
+ text: renderSelfHostStatusText({
1643
+ channel,
1644
+ mode,
1645
+ serviceName: config.serviceName,
1646
+ serverUrl,
1647
+ healthy,
1648
+ service: { active, enabled },
1649
+ versions: { server: serverVersion || null, uiWeb: uiWebVersion || null },
1650
+ autoUpdate: {
1651
+ label: updaterLabel,
1652
+ job: { active: updaterActive, enabled: updaterEnabled },
1653
+ configured: {
1654
+ enabled: Boolean(autoUpdateState?.enabled),
1655
+ intervalMinutes: autoUpdateState?.intervalMinutes ?? null,
1656
+ },
1657
+ },
1658
+ updatedAt: state?.updatedAt ?? null,
1659
+ }),
682
1660
  });
683
1661
  }
684
1662
 
685
- async function cmdUpdate({ channel, json }) {
686
- assertLinux();
687
- assertRoot();
688
- const config = resolveConfig({ channel });
1663
+ async function cmdUpdate({ channel, mode, json }) {
1664
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1665
+ if (config.mode === 'system' && config.platform !== 'win32') {
1666
+ assertRoot();
1667
+ }
1668
+ const existingState = existsSync(config.statePath)
1669
+ ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
1670
+ : {};
1671
+ const autoUpdateReconcile = decideSelfHostAutoUpdateReconcile(existingState, {
1672
+ fallbackIntervalMinutes: config.autoUpdateIntervalMinutes,
1673
+ });
1674
+ const withUi =
1675
+ parseBoolean(process.env.HAPPIER_WITH_UI, true) !== false
1676
+ && parseBoolean(process.env.HAPPIER_SELF_HOST_WITH_UI, true) !== false;
689
1677
  const installResult = await installFromRelease({
690
1678
  product: 'happier-server',
691
- binaryName: 'happier-server',
1679
+ binaryName: config.serverBinaryName,
692
1680
  config,
693
1681
  });
694
- const healthy = await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1682
+ const uiResult = withUi
1683
+ ? await installUiWebFromRelease({ config })
1684
+ : { installed: false, version: null, source: null, reason: 'disabled' };
1685
+ const uiInstalled = Boolean(uiResult?.installed);
1686
+
1687
+ const envText = existsSync(config.configEnvPath)
1688
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
1689
+ : '';
1690
+ const parsedEnv = parseEnvText(envText);
1691
+ const effectivePort = parsePort(parsedEnv.PORT, config.serverPort);
1692
+ const configWithPort = effectivePort === config.serverPort ? config : { ...config, serverPort: effectivePort };
1693
+ const defaultsEnvText = renderServerEnvFile({
1694
+ port: configWithPort.serverPort,
1695
+ host: configWithPort.serverHost,
1696
+ dataDir: configWithPort.dataDir,
1697
+ filesDir: configWithPort.filesDir,
1698
+ dbDir: configWithPort.dbDir,
1699
+ uiDir: uiInstalled ? configWithPort.uiWebCurrentDir : '',
1700
+ serverBinDir: dirname(configWithPort.serverBinaryPath),
1701
+ arch: process.arch,
1702
+ platform: configWithPort.platform,
1703
+ });
1704
+ const nextEnvText = envText ? mergeEnvTextWithDefaults(envText, defaultsEnvText) : defaultsEnvText;
1705
+ await mkdir(configWithPort.configDir, { recursive: true });
1706
+ await writeFile(configWithPort.configEnvPath, nextEnvText, 'utf-8');
1707
+ const nextEnv = parseEnvText(nextEnvText);
1708
+ if (!parseBoolean(nextEnv.HAPPIER_SQLITE_AUTO_MIGRATE ?? nextEnv.HAPPY_SQLITE_AUTO_MIGRATE, true)) {
1709
+ await applySelfHostSqliteMigrationsAtInstallTime({ env: nextEnv }).catch((e) => {
1710
+ throw new Error(`[self-host] failed to apply sqlite migrations at update time: ${String(e?.message ?? e)}`);
1711
+ });
1712
+ }
1713
+
1714
+ const serviceSpec = buildSelfHostServerServiceSpec({ config: configWithPort, envText: nextEnvText });
1715
+ await installManagedService({
1716
+ platform: configWithPort.platform,
1717
+ mode: configWithPort.mode,
1718
+ homeDir: homedir(),
1719
+ spec: serviceSpec,
1720
+ persistent: true,
1721
+ }).catch(() => {});
1722
+ const healthy = await restartAndCheckHealth({ config: configWithPort, serviceSpec });
695
1723
  if (!healthy) {
696
1724
  if (existsSync(config.serverPreviousBinaryPath)) {
697
1725
  await copyFile(config.serverPreviousBinaryPath, config.serverBinaryPath);
698
- await chmod(config.serverBinaryPath, 0o755);
699
- await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1726
+ await chmod(config.serverBinaryPath, 0o755).catch(() => {});
1727
+ await restartAndCheckHealth({ config: configWithPort, serviceSpec });
700
1728
  }
701
1729
  throw new Error('[self-host] update failed health checks and was rolled back to previous binary');
702
1730
  }
703
1731
 
1732
+ if (autoUpdateReconcile.action === 'install') {
1733
+ await installAutoUpdateJob({
1734
+ config: configWithPort,
1735
+ enabled: true,
1736
+ intervalMinutes: autoUpdateReconcile.intervalMinutes,
1737
+ }).catch(() => {});
1738
+ } else {
1739
+ await uninstallAutoUpdateJob({ config: configWithPort }).catch(() => {});
1740
+ }
1741
+
704
1742
  await writeSelfHostState(config, {
705
1743
  channel,
1744
+ mode,
706
1745
  version: installResult.version,
707
1746
  source: installResult.source,
1747
+ autoUpdate: { enabled: autoUpdateReconcile.enabled, intervalMinutes: autoUpdateReconcile.intervalMinutes },
1748
+ uiWeb: uiInstalled
1749
+ ? { installed: true, version: uiResult.version, source: uiResult.source, tag: uiResult.tag }
1750
+ : { installed: false, reason: String(uiResult?.reason ?? (withUi ? 'missing' : 'disabled')) },
708
1751
  });
709
1752
 
710
1753
  printResult({
711
1754
  json,
712
- data: { ok: true, version: installResult.version, service: config.serviceUnitName },
1755
+ data: { ok: true, version: installResult.version, service: config.serviceName },
713
1756
  text: `${green('✓')} updated self-host runtime to ${cyan(installResult.version || 'latest')}`,
714
1757
  });
715
1758
  }
@@ -723,13 +1766,14 @@ function parseRollbackVersion(argv) {
723
1766
  return '';
724
1767
  }
725
1768
 
726
- async function cmdRollback({ channel, argv, json }) {
727
- assertLinux();
728
- assertRoot();
729
- const config = resolveConfig({ channel });
1769
+ async function cmdRollback({ channel, mode, argv, json }) {
1770
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1771
+ if (config.mode === 'system' && config.platform !== 'win32') {
1772
+ assertRoot();
1773
+ }
730
1774
  const to = parseRollbackVersion(argv);
731
1775
  const target = to
732
- ? join(config.versionsDir, `happier-server-${to}`)
1776
+ ? join(config.versionsDir, `${config.serverBinaryName}-${to}`)
733
1777
  : config.serverPreviousBinaryPath;
734
1778
  if (!existsSync(target)) {
735
1779
  throw new Error(
@@ -739,13 +1783,42 @@ async function cmdRollback({ channel, argv, json }) {
739
1783
  );
740
1784
  }
741
1785
  await copyFile(target, config.serverBinaryPath);
742
- await chmod(config.serverBinaryPath, 0o755);
743
- const healthy = await restartAndCheckHealth({ serviceUnitName: config.serviceUnitName, serverPort: config.serverPort });
1786
+ await chmod(config.serverBinaryPath, 0o755).catch(() => {});
1787
+ const envText = existsSync(config.configEnvPath)
1788
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
1789
+ : '';
1790
+ const parsedEnv = parseEnvText(envText);
1791
+ const effectivePort = parsePort(parsedEnv.PORT, config.serverPort);
1792
+ const configWithPort = effectivePort === config.serverPort ? config : { ...config, serverPort: effectivePort };
1793
+ const defaultsEnvText = renderServerEnvFile({
1794
+ port: configWithPort.serverPort,
1795
+ host: configWithPort.serverHost,
1796
+ dataDir: configWithPort.dataDir,
1797
+ filesDir: configWithPort.filesDir,
1798
+ dbDir: configWithPort.dbDir,
1799
+ serverBinDir: dirname(configWithPort.serverBinaryPath),
1800
+ arch: process.arch,
1801
+ platform: configWithPort.platform,
1802
+ });
1803
+ const nextEnvText = envText ? mergeEnvTextWithDefaults(envText, defaultsEnvText) : defaultsEnvText;
1804
+ await mkdir(configWithPort.configDir, { recursive: true });
1805
+ await writeFile(configWithPort.configEnvPath, nextEnvText, 'utf-8');
1806
+
1807
+ const serviceSpec = buildSelfHostServerServiceSpec({ config: configWithPort, envText: nextEnvText });
1808
+ await installManagedService({
1809
+ platform: configWithPort.platform,
1810
+ mode: configWithPort.mode,
1811
+ homeDir: homedir(),
1812
+ spec: serviceSpec,
1813
+ persistent: true,
1814
+ }).catch(() => {});
1815
+ const healthy = await restartAndCheckHealth({ config: configWithPort, serviceSpec });
744
1816
  if (!healthy) {
745
1817
  throw new Error('[self-host] rollback completed binary swap but health checks failed');
746
1818
  }
747
1819
  await writeSelfHostState(config, {
748
1820
  channel,
1821
+ mode,
749
1822
  version: to || 'previous',
750
1823
  rolledBackAt: new Date().toISOString(),
751
1824
  });
@@ -756,27 +1829,43 @@ async function cmdRollback({ channel, argv, json }) {
756
1829
  });
757
1830
  }
758
1831
 
759
- async function cmdUninstall({ channel, argv, json }) {
760
- assertLinux();
761
- assertRoot();
762
- const config = resolveConfig({ channel });
1832
+ async function cmdUninstall({ channel, mode, argv, json }) {
1833
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1834
+ if (config.mode === 'system' && config.platform !== 'win32') {
1835
+ assertRoot();
1836
+ }
763
1837
  const purgeData = argv.includes('--purge-data');
764
1838
  const yes = argv.includes('--yes') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
765
1839
  if (!yes) {
766
1840
  throw new Error('[self-host] uninstall requires --yes (or HAPPIER_NONINTERACTIVE=1)');
767
1841
  }
768
1842
 
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' });
1843
+ const envText = existsSync(config.configEnvPath)
1844
+ ? await readFile(config.configEnvPath, 'utf-8').catch(() => '')
1845
+ : '';
1846
+ const fallbackEnvText = envText || renderServerEnvFile({
1847
+ port: config.serverPort,
1848
+ host: config.serverHost,
1849
+ dataDir: config.dataDir,
1850
+ filesDir: config.filesDir,
1851
+ dbDir: config.dbDir,
1852
+ serverBinDir: dirname(config.serverBinaryPath),
1853
+ arch: process.arch,
1854
+ platform: config.platform,
1855
+ });
1856
+ const serviceSpec = buildSelfHostServerServiceSpec({ config, envText: fallbackEnvText });
1857
+ await uninstallAutoUpdateJob({ config }).catch(() => {});
1858
+ await uninstallManagedService({
1859
+ platform: config.platform,
1860
+ mode: config.mode,
1861
+ homeDir: homedir(),
1862
+ spec: serviceSpec,
1863
+ persistent: true,
1864
+ }).catch(() => {});
776
1865
 
777
1866
  await rm(config.serverBinaryPath, { force: true });
778
1867
  await rm(config.serverPreviousBinaryPath, { force: true });
779
- await rm(join(config.binDir, 'happier-server'), { force: true });
1868
+ await rm(join(config.binDir, config.serverBinaryName), { force: true });
780
1869
  await rm(config.statePath, { force: true });
781
1870
 
782
1871
  if (purgeData) {
@@ -793,16 +1882,12 @@ async function cmdUninstall({ channel, argv, json }) {
793
1882
  });
794
1883
  }
795
1884
 
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
- ];
1885
+ async function cmdDoctor({ channel, mode, json }) {
1886
+ const config = resolveConfig({ channel, mode, platform: process.platform });
1887
+ const state = existsSync(config.statePath)
1888
+ ? JSON.parse(await readFile(config.statePath, 'utf-8').catch(() => '{}'))
1889
+ : {};
1890
+ const checks = buildSelfHostDoctorChecks(config, { state });
806
1891
  const ok = checks.every((check) => check.ok);
807
1892
  printResult({
808
1893
  json,
@@ -823,11 +1908,11 @@ export function usageText() {
823
1908
  banner('self-host', { subtitle: 'Happier Self-Host guided installation flow.' }),
824
1909
  '',
825
1910
  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]`,
1911
+ ` ${cyan('hstack self-host')} install [--mode=user|system] [--without-cli] [--without-ui] [--channel=stable|preview] [--auto-update|--no-auto-update] [--auto-update-interval=<minutes>] [--non-interactive] [--json]`,
1912
+ ` ${cyan('hstack self-host')} status [--mode=user|system] [--channel=stable|preview] [--json]`,
1913
+ ` ${cyan('hstack self-host')} update [--mode=user|system] [--channel=stable|preview] [--json]`,
1914
+ ` ${cyan('hstack self-host')} rollback [--mode=user|system] [--to=<version>] [--channel=stable|preview] [--json]`,
1915
+ ` ${cyan('hstack self-host')} uninstall [--mode=user|system] [--purge-data] [--yes] [--json]`,
831
1916
  ` ${cyan('hstack self-host')} doctor [--json]`,
832
1917
  '',
833
1918
  sectionTitle('notes:'),
@@ -841,6 +1926,12 @@ export async function runSelfHostCli(argv = process.argv.slice(2)) {
841
1926
  const { flags, kv } = parseArgs(argv);
842
1927
  const json = wantsJson(argv, { flags });
843
1928
  const channel = normalizeChannel(String(kv.get('--channel') ?? process.env.HAPPIER_CHANNEL ?? 'stable'));
1929
+ const mode = normalizeMode(
1930
+ String(
1931
+ kv.get('--mode') ??
1932
+ (argv.includes('--system') ? 'system' : argv.includes('--user') ? 'user' : process.env.HAPPIER_SELF_HOST_MODE ?? 'user')
1933
+ )
1934
+ );
844
1935
 
845
1936
  if (wantsHelp(argv, { flags }) || parsed.subcommand === 'help') {
846
1937
  printResult({
@@ -855,27 +1946,27 @@ export async function runSelfHostCli(argv = process.argv.slice(2)) {
855
1946
  }
856
1947
 
857
1948
  if (parsed.subcommand === 'install') {
858
- await cmdInstall({ channel, argv: parsed.rest, json });
1949
+ await cmdInstall({ channel, mode, argv: parsed.rest, json });
859
1950
  return;
860
1951
  }
861
1952
  if (parsed.subcommand === 'status') {
862
- await cmdStatus({ channel, json });
1953
+ await cmdStatus({ channel, mode, json });
863
1954
  return;
864
1955
  }
865
1956
  if (parsed.subcommand === 'update') {
866
- await cmdUpdate({ channel, json });
1957
+ await cmdUpdate({ channel, mode, json });
867
1958
  return;
868
1959
  }
869
1960
  if (parsed.subcommand === 'rollback') {
870
- await cmdRollback({ channel, argv: parsed.rest, json });
1961
+ await cmdRollback({ channel, mode, argv: parsed.rest, json });
871
1962
  return;
872
1963
  }
873
1964
  if (parsed.subcommand === 'uninstall') {
874
- await cmdUninstall({ channel, argv: parsed.rest, json });
1965
+ await cmdUninstall({ channel, mode, argv: parsed.rest, json });
875
1966
  return;
876
1967
  }
877
1968
  if (parsed.subcommand === 'doctor' || parsed.subcommand === 'migrate-from-npm') {
878
- await cmdDoctor({ channel, json });
1969
+ await cmdDoctor({ channel, mode, json });
879
1970
  return;
880
1971
  }
881
1972