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