@happier-dev/stack 0.1.0-preview.10.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.
- package/docs/server-flavors.md +6 -6
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts +2 -0
- package/node_modules/@happier-dev/cli-common/dist/index.d.ts.map +1 -1
- package/node_modules/@happier-dev/cli-common/dist/index.js +2 -0
- package/node_modules/@happier-dev/cli-common/dist/index.js.map +1 -1
- package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts +51 -0
- package/node_modules/@happier-dev/cli-common/dist/providers/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/providers/index.js +129 -0
- package/node_modules/@happier-dev/cli-common/dist/providers/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts +5 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.js +5 -0
- package/node_modules/@happier-dev/cli-common/dist/service/index.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +15 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +97 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts +55 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.js +302 -0
- package/node_modules/@happier-dev/cli-common/dist/service/manager.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts +12 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.js +75 -0
- package/node_modules/@happier-dev/cli-common/dist/service/systemd.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts +8 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.d.ts.map +1 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.js +29 -0
- package/node_modules/@happier-dev/cli-common/dist/service/windows.js.map +1 -0
- package/node_modules/@happier-dev/cli-common/package.json +11 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts +22 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.js +44 -0
- package/node_modules/@happier-dev/release-runtime/dist/assets.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts +5 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.js +21 -0
- package/node_modules/@happier-dev/release-runtime/dist/checksums.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts +14 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js +39 -0
- package/node_modules/@happier-dev/release-runtime/dist/extractPlan.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.d.ts +20 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.js +60 -0
- package/node_modules/@happier-dev/release-runtime/dist/github.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.d.ts +7 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.js +7 -0
- package/node_modules/@happier-dev/release-runtime/dist/index.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts +7 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.js +92 -0
- package/node_modules/@happier-dev/release-runtime/dist/minisign.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts +26 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.d.ts.map +1 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js +53 -0
- package/node_modules/@happier-dev/release-runtime/dist/verifiedDownload.js.map +1 -0
- package/node_modules/@happier-dev/release-runtime/package.json +38 -0
- package/package.json +4 -2
- package/scripts/auth.mjs +3 -2
- package/scripts/auth_copy_from_pglite_lock_in_use.integration.test.mjs +1 -0
- package/scripts/auth_copy_from_runCapture.integration.test.mjs +8 -1
- package/scripts/build.mjs +3 -18
- package/scripts/bundleWorkspaceDeps.mjs +5 -1
- package/scripts/bundleWorkspaceDeps.test.mjs +16 -0
- package/scripts/mobile.mjs +32 -1
- package/scripts/mobile_prebuild_happyDir_defined.test.mjs +47 -0
- package/scripts/mobile_prebuild_sets_rct_metro_port.test.mjs +81 -0
- package/scripts/mobile_run_ios_passes_port.integration.test.mjs +101 -0
- package/scripts/providers_cmd.mjs +262 -0
- package/scripts/release_binary_smoke.integration.test.mjs +25 -6
- package/scripts/remote_cmd.mjs +240 -0
- package/scripts/self_host_daemon.real.integration.test.mjs +296 -0
- package/scripts/self_host_launchd.real.integration.test.mjs +211 -0
- package/scripts/self_host_runtime.mjs +1403 -312
- package/scripts/self_host_runtime.test.mjs +361 -1
- package/scripts/self_host_schtasks.real.integration.test.mjs +217 -0
- package/scripts/self_host_service_e2e_harness.mjs +93 -0
- package/scripts/self_host_systemd.real.integration.test.mjs +8 -86
- package/scripts/service.mjs +156 -26
- package/scripts/stack/command_arguments.mjs +1 -0
- package/scripts/stack/help_text.mjs +2 -0
- package/scripts/stack_daemon_cmd.integration.test.mjs +37 -0
- package/scripts/stack_happy_cmd.integration.test.mjs +36 -0
- package/scripts/utils/auth/credentials_paths.mjs +9 -9
- package/scripts/utils/auth/credentials_paths.test.mjs +8 -0
- package/scripts/utils/auth/stable_scope_id.mjs +1 -1
- package/scripts/utils/cli/cli_registry.mjs +18 -0
- package/scripts/utils/cli/progress.mjs +8 -1
- package/scripts/utils/cli/progress.test.mjs +43 -0
- package/scripts/utils/dev/expo_dev.buildEnv.test.mjs +17 -0
- package/scripts/utils/dev/expo_dev.mjs +35 -5
- package/scripts/utils/dev/expo_dev_restart_port_reservation.test.mjs +180 -1
- package/scripts/utils/dev/expo_dev_runtime_metadata.test.mjs +126 -0
- package/scripts/utils/dev/expo_dev_verbose_logs.test.mjs +9 -2
- package/scripts/utils/server/port.mjs +20 -2
- package/scripts/utils/service/service_manager.definition.test.mjs +66 -0
- package/scripts/utils/service/service_manager.mjs +96 -0
- package/scripts/utils/service/service_manager.plan.test.mjs +37 -0
- package/scripts/utils/service/service_manager.test.mjs +20 -0
- package/scripts/utils/service/systemd_service_unit.mjs +1 -0
- package/scripts/utils/service/systemd_service_unit.test.mjs +42 -0
- package/scripts/utils/service/windows_schtasks_wrapper.mjs +1 -0
- package/scripts/utils/service/windows_schtasks_wrapper.test.mjs +25 -0
- package/scripts/utils/ui/ui_export_env.mjs +29 -0
- package/scripts/utils/ui/ui_export_env.test.mjs +25 -0
- package/scripts/worktrees.mjs +3 -0
- 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
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
141
|
-
archiveName,
|
|
142
|
-
checksumsUrl:
|
|
143
|
-
signatureUrl:
|
|
144
|
-
version
|
|
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
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
const
|
|
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 =
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
-
'[
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
'',
|
|
295
|
-
'
|
|
296
|
-
'
|
|
297
|
-
|
|
298
|
-
'',
|
|
299
|
-
|
|
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
|
|
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=${
|
|
773
|
+
`Description=${label} (auto-update timer)`,
|
|
306
774
|
'',
|
|
307
775
|
'[Timer]',
|
|
308
|
-
'
|
|
309
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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 <
|
|
323
|
-
const
|
|
324
|
-
if (
|
|
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
|
-
|
|
347
|
-
const inline = String(
|
|
348
|
-
|
|
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
|
|
430
|
-
|
|
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
|
|
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
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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:
|
|
1431
|
+
binaryName: config.serverBinaryName,
|
|
547
1432
|
config,
|
|
548
1433
|
explicitBinaryPath: serverBinaryOverride,
|
|
549
1434
|
});
|
|
550
1435
|
|
|
551
|
-
|
|
552
|
-
config
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
config.
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
})
|
|
571
|
-
|
|
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,
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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({
|
|
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.
|
|
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
|
-
`-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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:
|
|
1679
|
+
binaryName: config.serverBinaryName,
|
|
692
1680
|
config,
|
|
693
1681
|
});
|
|
694
|
-
const
|
|
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({
|
|
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.
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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,
|
|
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
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|