@happier-dev/stack 0.1.0-preview.142.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/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts +4 -0
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.d.ts.map +1 -1
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js +21 -1
- package/node_modules/@happier-dev/cli-common/dist/service/launchd.js.map +1 -1
- package/package.json +1 -1
- package/scripts/auth_login_guided_server_no_expo.test.mjs +2 -0
- package/scripts/bundleWorkspaceDeps.test.mjs +26 -1
- package/scripts/mobile.mjs +4 -7
- package/scripts/mobile_dev_client.mjs +7 -32
- package/scripts/mobile_dev_client_help_smoke.test.mjs +24 -0
- package/scripts/mobile_run_ios_passes_port.integration.test.mjs +8 -6
- package/scripts/mobile_run_ios_uses_long_port_flag.test.mjs +106 -0
- package/scripts/remote_cmd.mjs +112 -0
- package/scripts/self_host_runtime.mjs +505 -94
- package/scripts/self_host_runtime.test.mjs +170 -8
- package/scripts/stack/help_text.mjs +1 -1
- package/scripts/stack.mjs +2 -1
- package/scripts/stack_pr_help_cmd.test.mjs +38 -0
- package/scripts/stop.mjs +2 -3
- package/scripts/utils/auth/orchestrated_stack_auth_flow.mjs +64 -3
- package/scripts/utils/cli/cli_registry.mjs +5 -2
- package/scripts/utils/mobile/dev_client_install_invocation.mjs +68 -0
- package/scripts/utils/mobile/dev_client_install_invocation.test.mjs +27 -0
|
@@ -97,6 +97,23 @@ function parsePort(raw, fallback = DEFAULTS.serverPort) {
|
|
|
97
97
|
return port > 0 && port <= 65535 ? port : fallback;
|
|
98
98
|
}
|
|
99
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
|
+
|
|
100
117
|
export function resolveSelfHostHealthTimeoutMs(env = process.env) {
|
|
101
118
|
const raw = String(env?.HAPPIER_SELF_HOST_HEALTH_TIMEOUT_MS ?? '').trim();
|
|
102
119
|
if (!raw) return DEFAULTS.healthCheckTimeoutMs;
|
|
@@ -120,6 +137,12 @@ export function resolveSelfHostAutoUpdateIntervalMinutes(env = process.env) {
|
|
|
120
137
|
: DEFAULTS.autoUpdateIntervalMinutes;
|
|
121
138
|
}
|
|
122
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
|
+
|
|
123
146
|
export function normalizeSelfHostAutoUpdateState(state, { fallbackIntervalMinutes = DEFAULTS.autoUpdateIntervalMinutes } = {}) {
|
|
124
147
|
const fallbackRaw = Number(fallbackIntervalMinutes);
|
|
125
148
|
const fallback =
|
|
@@ -132,12 +155,13 @@ export function normalizeSelfHostAutoUpdateState(state, { fallbackIntervalMinute
|
|
|
132
155
|
const enabled = Boolean(raw.enabled);
|
|
133
156
|
const parsed = Number(raw.intervalMinutes);
|
|
134
157
|
const intervalMinutes = Number.isFinite(parsed) && Math.floor(parsed) >= 15 ? Math.floor(parsed) : fallback;
|
|
135
|
-
|
|
158
|
+
const at = typeof raw.at === 'string' ? (parseDailyAtTime(raw.at)?.normalized || '') : '';
|
|
159
|
+
return { enabled, intervalMinutes, at };
|
|
136
160
|
}
|
|
137
161
|
if (raw === true || raw === false) {
|
|
138
|
-
return { enabled: raw, intervalMinutes: fallback };
|
|
162
|
+
return { enabled: raw, intervalMinutes: fallback, at: '' };
|
|
139
163
|
}
|
|
140
|
-
return { enabled: false, intervalMinutes: fallback };
|
|
164
|
+
return { enabled: false, intervalMinutes: fallback, at: '' };
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
export function decideSelfHostAutoUpdateReconcile(state, { fallbackIntervalMinutes = DEFAULTS.autoUpdateIntervalMinutes } = {}) {
|
|
@@ -146,6 +170,7 @@ export function decideSelfHostAutoUpdateReconcile(state, { fallbackIntervalMinut
|
|
|
146
170
|
action: normalized.enabled ? 'install' : 'uninstall',
|
|
147
171
|
enabled: normalized.enabled,
|
|
148
172
|
intervalMinutes: normalized.intervalMinutes,
|
|
173
|
+
at: normalized.at,
|
|
149
174
|
};
|
|
150
175
|
}
|
|
151
176
|
|
|
@@ -417,6 +442,7 @@ function resolveConfig({ channel, mode = 'user', platform = process.platform } =
|
|
|
417
442
|
const githubRepo = String(process.env.HAPPIER_GITHUB_REPO ?? DEFAULTS.githubRepo).trim();
|
|
418
443
|
const autoUpdate = resolveSelfHostAutoUpdateDefault(process.env);
|
|
419
444
|
const autoUpdateIntervalMinutes = resolveSelfHostAutoUpdateIntervalMinutes(process.env);
|
|
445
|
+
const autoUpdateAt = resolveSelfHostAutoUpdateAt(process.env);
|
|
420
446
|
const serverBinaryName = platform === 'win32' ? 'happier-server.exe' : 'happier-server';
|
|
421
447
|
const uiWebRootDir = join(installRoot, 'ui-web');
|
|
422
448
|
|
|
@@ -446,6 +472,7 @@ function resolveConfig({ channel, mode = 'user', platform = process.platform } =
|
|
|
446
472
|
githubRepo,
|
|
447
473
|
autoUpdate,
|
|
448
474
|
autoUpdateIntervalMinutes,
|
|
475
|
+
autoUpdateAt,
|
|
449
476
|
uiWebProduct: DEFAULTS.uiWebProduct,
|
|
450
477
|
uiWebOs: DEFAULTS.uiWebOs,
|
|
451
478
|
uiWebArch: DEFAULTS.uiWebArch,
|
|
@@ -555,6 +582,99 @@ function listEnvKeysInOrder(raw) {
|
|
|
555
582
|
return keys;
|
|
556
583
|
}
|
|
557
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
|
+
|
|
558
678
|
export function mergeEnvTextWithDefaults(existingText, defaultsText) {
|
|
559
679
|
const existingRaw = String(existingText ?? '');
|
|
560
680
|
const defaultsRaw = String(defaultsText ?? '');
|
|
@@ -640,11 +760,17 @@ export function renderSelfHostStatusText(report, { colors = true } = {}) {
|
|
|
640
760
|
|
|
641
761
|
const autoConfiguredEnabled = Boolean(report?.autoUpdate?.configured?.enabled);
|
|
642
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 || '';
|
|
643
765
|
const updaterEnabled = report?.autoUpdate?.job?.enabled ?? null;
|
|
644
766
|
const updaterActive = report?.autoUpdate?.job?.active ?? null;
|
|
645
767
|
|
|
646
768
|
const configuredLine = autoConfiguredEnabled
|
|
647
|
-
?
|
|
769
|
+
? (
|
|
770
|
+
autoConfiguredAt
|
|
771
|
+
? `configured enabled (daily at ${autoConfiguredAt})`
|
|
772
|
+
: `configured enabled${autoConfiguredInterval ? ` (every ${autoConfiguredInterval}m)` : ''}`
|
|
773
|
+
)
|
|
648
774
|
: 'configured disabled';
|
|
649
775
|
|
|
650
776
|
const jobLine =
|
|
@@ -764,19 +890,30 @@ export function renderUpdaterSystemdUnit({
|
|
|
764
890
|
});
|
|
765
891
|
}
|
|
766
892
|
|
|
767
|
-
export function renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes = 1440 } = {}) {
|
|
893
|
+
export function renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes = 1440, at } = {}) {
|
|
768
894
|
const label = String(updaterLabel ?? '').trim() || 'happier-self-host-updater';
|
|
895
|
+
const parsedAt = parseDailyAtTime(at);
|
|
769
896
|
const minutesRaw = Number(intervalMinutes);
|
|
770
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
|
+
];
|
|
771
912
|
return [
|
|
772
913
|
'[Unit]',
|
|
773
914
|
`Description=${label} (auto-update timer)`,
|
|
774
915
|
'',
|
|
775
|
-
|
|
776
|
-
'OnBootSec=5m',
|
|
777
|
-
`OnUnitActiveSec=${minutes}m`,
|
|
778
|
-
`Unit=${label}.service`,
|
|
779
|
-
'Persistent=true',
|
|
916
|
+
...timerLines,
|
|
780
917
|
'',
|
|
781
918
|
'[Install]',
|
|
782
919
|
'WantedBy=timers.target',
|
|
@@ -790,6 +927,7 @@ export function renderUpdaterLaunchdPlistXml({
|
|
|
790
927
|
channel,
|
|
791
928
|
mode,
|
|
792
929
|
intervalMinutes,
|
|
930
|
+
at,
|
|
793
931
|
workingDirectory,
|
|
794
932
|
stdoutPath,
|
|
795
933
|
stderrPath,
|
|
@@ -802,6 +940,7 @@ export function renderUpdaterLaunchdPlistXml({
|
|
|
802
940
|
const wd = String(workingDirectory ?? '').trim();
|
|
803
941
|
const out = String(stdoutPath ?? '').trim();
|
|
804
942
|
const err = String(stderrPath ?? '').trim();
|
|
943
|
+
const parsedAt = parseDailyAtTime(at);
|
|
805
944
|
const intervalRaw = Number(intervalMinutes);
|
|
806
945
|
const startIntervalSec = Number.isFinite(intervalRaw) && intervalRaw > 0 ? Math.max(15, Math.floor(intervalRaw)) * 60 : 0;
|
|
807
946
|
|
|
@@ -822,7 +961,9 @@ export function renderUpdaterLaunchdPlistXml({
|
|
|
822
961
|
stderrPath: err,
|
|
823
962
|
workingDirectory: wd,
|
|
824
963
|
keepAliveOnFailure: false,
|
|
825
|
-
|
|
964
|
+
...(parsedAt
|
|
965
|
+
? { startCalendarInterval: { hour: parsedAt.hour, minute: parsedAt.minute } }
|
|
966
|
+
: { startIntervalSec: startIntervalSec || undefined }),
|
|
826
967
|
});
|
|
827
968
|
}
|
|
828
969
|
|
|
@@ -860,6 +1001,34 @@ export function renderUpdaterScheduledTaskWrapperPs1({
|
|
|
860
1001
|
});
|
|
861
1002
|
}
|
|
862
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
|
+
|
|
863
1032
|
function resolveAutoUpdateEnabled(argv, fallback) {
|
|
864
1033
|
const args = Array.isArray(argv) ? argv.map(String) : [];
|
|
865
1034
|
if (args.includes('--no-auto-update')) return false;
|
|
@@ -885,6 +1054,25 @@ function resolveAutoUpdateIntervalMinutes(argv, fallback) {
|
|
|
885
1054
|
return Math.min(minutes, 60 * 24 * 7);
|
|
886
1055
|
}
|
|
887
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
|
+
|
|
888
1076
|
function resolveUpdaterLabel(config) {
|
|
889
1077
|
const override = String(process.env.HAPPIER_SELF_HOST_UPDATER_LABEL ?? '').trim();
|
|
890
1078
|
if (override) return override;
|
|
@@ -900,7 +1088,7 @@ function resolveHstackPathForUpdater(config) {
|
|
|
900
1088
|
return join(String(config?.binDir ?? '').trim() || '', exe);
|
|
901
1089
|
}
|
|
902
1090
|
|
|
903
|
-
async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
1091
|
+
async function installAutoUpdateJob({ config, enabled, intervalMinutes, at }) {
|
|
904
1092
|
if (!enabled) return { installed: false, reason: 'disabled' };
|
|
905
1093
|
const updaterLabel = resolveUpdaterLabel(config);
|
|
906
1094
|
const hstackPath = resolveHstackPathForUpdater(config);
|
|
@@ -908,6 +1096,7 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
908
1096
|
const stderrPath = join(config.logDir, 'updater.err.log');
|
|
909
1097
|
const backend = resolveServiceBackend({ platform: config.platform, mode: config.mode });
|
|
910
1098
|
const interval = resolveAutoUpdateIntervalMinutes([], intervalMinutes ?? config.autoUpdateIntervalMinutes);
|
|
1099
|
+
const effectiveAt = resolveAutoUpdateAt([], at ?? config.autoUpdateAt ?? '');
|
|
911
1100
|
|
|
912
1101
|
const baseSpec = {
|
|
913
1102
|
label: updaterLabel,
|
|
@@ -934,7 +1123,7 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
934
1123
|
stderrPath,
|
|
935
1124
|
wantedBy,
|
|
936
1125
|
});
|
|
937
|
-
const timerContents = renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes: interval });
|
|
1126
|
+
const timerContents = renderUpdaterSystemdTimerUnit({ updaterLabel, intervalMinutes: interval, at: effectiveAt });
|
|
938
1127
|
const prefix = backend === 'systemd-user' ? ['--user'] : [];
|
|
939
1128
|
const plan = {
|
|
940
1129
|
writes: [
|
|
@@ -948,7 +1137,7 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
948
1137
|
],
|
|
949
1138
|
};
|
|
950
1139
|
await applyServicePlan(plan);
|
|
951
|
-
return { installed: true, backend, label: updaterLabel, definitionPath, timerPath, intervalMinutes: interval };
|
|
1140
|
+
return { installed: true, backend, label: updaterLabel, definitionPath, timerPath, intervalMinutes: interval, at: effectiveAt };
|
|
952
1141
|
}
|
|
953
1142
|
|
|
954
1143
|
if (backend === 'launchd-system' || backend === 'launchd-user') {
|
|
@@ -958,6 +1147,7 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
958
1147
|
channel: config.channel,
|
|
959
1148
|
mode: config.mode,
|
|
960
1149
|
intervalMinutes: interval,
|
|
1150
|
+
at: effectiveAt,
|
|
961
1151
|
workingDirectory: config.installRoot,
|
|
962
1152
|
stdoutPath,
|
|
963
1153
|
stderrPath,
|
|
@@ -971,7 +1161,7 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
971
1161
|
persistent: true,
|
|
972
1162
|
});
|
|
973
1163
|
await applyServicePlan(plan);
|
|
974
|
-
return { installed: true, backend, label: updaterLabel, definitionPath, intervalMinutes: interval };
|
|
1164
|
+
return { installed: true, backend, label: updaterLabel, definitionPath, intervalMinutes: interval, at: effectiveAt };
|
|
975
1165
|
}
|
|
976
1166
|
|
|
977
1167
|
const definitionContents = renderUpdaterScheduledTaskWrapperPs1({
|
|
@@ -984,22 +1174,13 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
984
1174
|
stderrPath,
|
|
985
1175
|
});
|
|
986
1176
|
const name = `Happier\\${updaterLabel}`;
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
String(interval),
|
|
995
|
-
'/ST',
|
|
996
|
-
'00:00',
|
|
997
|
-
'/TN',
|
|
998
|
-
name,
|
|
999
|
-
'/TR',
|
|
1000
|
-
ps,
|
|
1001
|
-
...(backend === 'schtasks-system' ? ['/RU', 'SYSTEM', '/RL', 'HIGHEST'] : []),
|
|
1002
|
-
];
|
|
1177
|
+
const args = buildUpdaterScheduledTaskCreateArgs({
|
|
1178
|
+
backend,
|
|
1179
|
+
taskName: name,
|
|
1180
|
+
definitionPath,
|
|
1181
|
+
intervalMinutes: interval,
|
|
1182
|
+
at: effectiveAt,
|
|
1183
|
+
});
|
|
1003
1184
|
const plan = {
|
|
1004
1185
|
writes: [{ path: definitionPath, contents: definitionContents, mode: 0o644 }],
|
|
1005
1186
|
commands: [
|
|
@@ -1008,7 +1189,7 @@ async function installAutoUpdateJob({ config, enabled, intervalMinutes }) {
|
|
|
1008
1189
|
],
|
|
1009
1190
|
};
|
|
1010
1191
|
await applyServicePlan(plan);
|
|
1011
|
-
return { installed: true, backend, label: updaterLabel, definitionPath, taskName: name, intervalMinutes: interval };
|
|
1192
|
+
return { installed: true, backend, label: updaterLabel, definitionPath, taskName: name, intervalMinutes: interval, at: effectiveAt };
|
|
1012
1193
|
}
|
|
1013
1194
|
|
|
1014
1195
|
async function uninstallAutoUpdateJob({ config }) {
|
|
@@ -1146,56 +1327,34 @@ async function syncSelfHostGeneratedClients({ artifactRootDir, targetDir }) {
|
|
|
1146
1327
|
return { copied: true, reason: 'ok' };
|
|
1147
1328
|
}
|
|
1148
1329
|
|
|
1149
|
-
async function
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
versionedTargetPath: join(config.versionsDir, `${binaryName}-${version}`),
|
|
1161
|
-
});
|
|
1162
|
-
await syncSelfHostSqliteMigrations({
|
|
1163
|
-
artifactRootDir: dirname(srcPath),
|
|
1164
|
-
targetDir: join(config.dataDir, 'migrations', 'sqlite'),
|
|
1165
|
-
}).catch(() => {});
|
|
1166
|
-
const generated = await syncSelfHostGeneratedClients({
|
|
1167
|
-
artifactRootDir: dirname(srcPath),
|
|
1168
|
-
targetDir: join(dirname(config.serverBinaryPath), 'generated'),
|
|
1169
|
-
});
|
|
1170
|
-
if (!generated.copied) {
|
|
1171
|
-
throw new Error('[self-host] server runtime is missing packaged generated clients');
|
|
1172
|
-
}
|
|
1173
|
-
return { version, source: 'local' };
|
|
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)');
|
|
1174
1341
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const os = normalizeOs(config.platform);
|
|
1184
|
-
const resolved = resolveReleaseAssetBundle({
|
|
1185
|
-
assets: release?.assets,
|
|
1186
|
-
product,
|
|
1187
|
-
os,
|
|
1188
|
-
arch: normalizeArch(),
|
|
1189
|
-
});
|
|
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);
|
|
1190
1350
|
|
|
1191
1351
|
const tempDir = await mkdtemp(join(tmpdir(), 'happier-self-host-release-'));
|
|
1192
1352
|
try {
|
|
1193
|
-
const pubkeyFile = resolveMinisignPublicKeyText(process.env);
|
|
1194
1353
|
const downloaded = await downloadVerifiedReleaseAssetBundle({
|
|
1195
|
-
bundle:
|
|
1354
|
+
bundle: resolvedBundle,
|
|
1196
1355
|
destDir: tempDir,
|
|
1197
1356
|
pubkeyFile,
|
|
1198
|
-
userAgent
|
|
1357
|
+
userAgent,
|
|
1199
1358
|
});
|
|
1200
1359
|
|
|
1201
1360
|
const extractDir = join(tempDir, 'extract');
|
|
@@ -1210,17 +1369,17 @@ async function installFromRelease({ product, binaryName, config, explicitBinaryP
|
|
|
1210
1369
|
throw new Error(`[self-host] ${plan.requiredCommand} is required to extract release artifacts`);
|
|
1211
1370
|
}
|
|
1212
1371
|
runCommand(plan.command.cmd, plan.command.args, { stdio: 'ignore' });
|
|
1213
|
-
const extractedBinary = await findExecutableByName(extractDir,
|
|
1372
|
+
const extractedBinary = await findExecutableByName(extractDir, name);
|
|
1214
1373
|
if (!extractedBinary) {
|
|
1215
1374
|
throw new Error('[self-host] failed to locate extracted server binary');
|
|
1216
1375
|
}
|
|
1217
1376
|
|
|
1218
|
-
const version =
|
|
1377
|
+
const version = downloaded.version || String(resolvedBundle?.version ?? '').trim() || `${Date.now()}`;
|
|
1219
1378
|
await installBinaryAtomically({
|
|
1220
1379
|
sourceBinaryPath: extractedBinary,
|
|
1221
1380
|
targetBinaryPath: config.serverBinaryPath,
|
|
1222
1381
|
previousBinaryPath: config.serverPreviousBinaryPath,
|
|
1223
|
-
versionedTargetPath: join(config.versionsDir, `${
|
|
1382
|
+
versionedTargetPath: join(config.versionsDir, `${name}-${version}`),
|
|
1224
1383
|
});
|
|
1225
1384
|
const roots = await readdir(extractDir).catch(() => []);
|
|
1226
1385
|
const artifactRootDir = roots.length > 0 ? join(extractDir, roots[0]) : extractDir;
|
|
@@ -1235,12 +1394,63 @@ async function installFromRelease({ product, binaryName, config, explicitBinaryP
|
|
|
1235
1394
|
if (!generated.copied) {
|
|
1236
1395
|
throw new Error('[self-host] server runtime is missing packaged generated clients');
|
|
1237
1396
|
}
|
|
1238
|
-
return { version, source:
|
|
1397
|
+
return { version, source: resolvedBundle.archive.url };
|
|
1239
1398
|
} finally {
|
|
1240
1399
|
await rm(tempDir, { recursive: true, force: true });
|
|
1241
1400
|
}
|
|
1242
1401
|
}
|
|
1243
1402
|
|
|
1403
|
+
async function installFromRelease({ product, binaryName, config, explicitBinaryPath = '' }) {
|
|
1404
|
+
if (explicitBinaryPath) {
|
|
1405
|
+
const srcPath = explicitBinaryPath;
|
|
1406
|
+
if (!existsSync(srcPath)) {
|
|
1407
|
+
throw new Error(`[self-host] missing --server-binary path: ${srcPath}`);
|
|
1408
|
+
}
|
|
1409
|
+
const version = `local-${Date.now()}`;
|
|
1410
|
+
await installBinaryAtomically({
|
|
1411
|
+
sourceBinaryPath: srcPath,
|
|
1412
|
+
targetBinaryPath: config.serverBinaryPath,
|
|
1413
|
+
previousBinaryPath: config.serverPreviousBinaryPath,
|
|
1414
|
+
versionedTargetPath: join(config.versionsDir, `${binaryName}-${version}`),
|
|
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
|
+
}
|
|
1427
|
+
return { version, source: 'local' };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const channelTag = config.channel === 'preview' ? 'server-preview' : 'server-stable';
|
|
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({
|
|
1439
|
+
assets: release?.assets,
|
|
1440
|
+
product,
|
|
1441
|
+
os,
|
|
1442
|
+
arch: normalizeArch(),
|
|
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
|
+
}
|
|
1453
|
+
|
|
1244
1454
|
async function assertUiWebBundleIsValid(rootDir) {
|
|
1245
1455
|
const indexPath = join(rootDir, 'index.html');
|
|
1246
1456
|
const info = await stat(indexPath).catch(() => null);
|
|
@@ -1385,15 +1595,19 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1385
1595
|
if (mode === 'system' && process.platform !== 'win32') {
|
|
1386
1596
|
assertRoot();
|
|
1387
1597
|
}
|
|
1598
|
+
const parsedEnvOverrides = parseEnvOverridesFromArgv(argv);
|
|
1599
|
+
const envOverrides = parsedEnvOverrides.overrides;
|
|
1600
|
+
const argvSansEnv = parsedEnvOverrides.rest;
|
|
1388
1601
|
const config = resolveConfig({ channel, mode, platform: process.platform });
|
|
1389
|
-
const autoUpdateEnabled = resolveAutoUpdateEnabled(
|
|
1390
|
-
const autoUpdateIntervalMinutes = resolveAutoUpdateIntervalMinutes(
|
|
1391
|
-
const
|
|
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;
|
|
1392
1606
|
const withUi =
|
|
1393
|
-
!(
|
|
1607
|
+
!(argvSansEnv.includes('--without-ui')
|
|
1394
1608
|
|| parseBoolean(process.env.HAPPIER_WITH_UI, true) === false
|
|
1395
1609
|
|| parseBoolean(process.env.HAPPIER_SELF_HOST_WITH_UI, true) === false);
|
|
1396
|
-
const nonInteractive =
|
|
1610
|
+
const nonInteractive = argvSansEnv.includes('--non-interactive') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
|
|
1397
1611
|
const serverBinaryOverride = String(process.env.HAPPIER_SELF_HOST_SERVER_BINARY ?? '').trim();
|
|
1398
1612
|
|
|
1399
1613
|
if (normalizeOs(config.platform) !== 'windows' && !commandExists('tar')) {
|
|
@@ -1449,8 +1663,9 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1449
1663
|
arch: process.arch,
|
|
1450
1664
|
platform: config.platform,
|
|
1451
1665
|
});
|
|
1452
|
-
|
|
1453
|
-
|
|
1666
|
+
const envTextWithOverrides = envOverrides.length ? applyEnvOverridesToEnvText(envText, envOverrides) : envText;
|
|
1667
|
+
await writeFile(config.configEnvPath, envTextWithOverrides, 'utf-8');
|
|
1668
|
+
const installEnv = parseEnvText(envTextWithOverrides);
|
|
1454
1669
|
if (!parseBoolean(installEnv.HAPPIER_SQLITE_AUTO_MIGRATE ?? installEnv.HAPPY_SQLITE_AUTO_MIGRATE, true)) {
|
|
1455
1670
|
await applySelfHostSqliteMigrationsAtInstallTime({ env: installEnv }).catch((e) => {
|
|
1456
1671
|
throw new Error(`[self-host] failed to apply sqlite migrations at install time: ${String(e?.message ?? e)}`);
|
|
@@ -1465,7 +1680,7 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1465
1680
|
await chmod(serverShimPath, 0o755).catch(() => {});
|
|
1466
1681
|
});
|
|
1467
1682
|
|
|
1468
|
-
const serviceSpec = buildSelfHostServerServiceSpec({ config, envText });
|
|
1683
|
+
const serviceSpec = buildSelfHostServerServiceSpec({ config, envText: envTextWithOverrides });
|
|
1469
1684
|
await installManagedService({
|
|
1470
1685
|
platform: config.platform,
|
|
1471
1686
|
mode: config.mode,
|
|
@@ -1479,7 +1694,12 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1479
1694
|
throw new Error('[self-host] service failed health checks after install');
|
|
1480
1695
|
}
|
|
1481
1696
|
|
|
1482
|
-
const autoUpdateResult = await installAutoUpdateJob({
|
|
1697
|
+
const autoUpdateResult = await installAutoUpdateJob({
|
|
1698
|
+
config: { ...config, autoUpdateAt },
|
|
1699
|
+
enabled: autoUpdateEnabled,
|
|
1700
|
+
intervalMinutes: autoUpdateIntervalMinutes,
|
|
1701
|
+
at: autoUpdateAt,
|
|
1702
|
+
}).catch((e) => ({
|
|
1483
1703
|
installed: false,
|
|
1484
1704
|
reason: String(e?.message ?? e),
|
|
1485
1705
|
}));
|
|
@@ -1498,7 +1718,7 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1498
1718
|
uiWeb: uiInstalled
|
|
1499
1719
|
? { installed: true, version: uiResult.version, source: uiResult.source, tag: uiResult.tag }
|
|
1500
1720
|
: { installed: false, reason: String(uiResult?.reason ?? (withUi ? 'missing' : 'disabled')) },
|
|
1501
|
-
autoUpdate: { enabled: autoUpdateEnabled, intervalMinutes: autoUpdateIntervalMinutes },
|
|
1721
|
+
autoUpdate: { enabled: autoUpdateEnabled, intervalMinutes: autoUpdateIntervalMinutes, at: autoUpdateAt },
|
|
1502
1722
|
});
|
|
1503
1723
|
|
|
1504
1724
|
printResult({
|
|
@@ -1513,6 +1733,7 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1513
1733
|
autoUpdate: {
|
|
1514
1734
|
enabled: autoUpdateEnabled,
|
|
1515
1735
|
intervalMinutes: autoUpdateIntervalMinutes,
|
|
1736
|
+
at: autoUpdateAt || null,
|
|
1516
1737
|
...autoUpdateResult,
|
|
1517
1738
|
},
|
|
1518
1739
|
cli: cliResult,
|
|
@@ -1523,7 +1744,7 @@ async function cmdInstall({ channel, mode, argv, json }) {
|
|
|
1523
1744
|
`- service: ${cyan(config.serviceName)}`,
|
|
1524
1745
|
`- version: ${cyan(installResult.version || 'unknown')}`,
|
|
1525
1746
|
`- server: ${cyan(`http://127.0.0.1:${config.serverPort}`)}`,
|
|
1526
|
-
`- auto-update: ${autoUpdateEnabled ? (autoUpdateResult.installed ? green(`installed (every ${autoUpdateIntervalMinutes}m)`) : yellow('failed')) : dim('disabled')}`,
|
|
1747
|
+
`- auto-update: ${autoUpdateEnabled ? (autoUpdateResult.installed ? green(`installed (${autoUpdateAt ? `daily at ${autoUpdateAt}` : `every ${autoUpdateIntervalMinutes}m`})`) : yellow('failed')) : dim('disabled')}`,
|
|
1527
1748
|
`- cli: ${cliResult.installed ? green('installed') : dim(cliResult.reason)}`,
|
|
1528
1749
|
`- ui: ${uiInstalled ? green('installed') : dim(String(uiResult?.reason ?? 'disabled'))}`,
|
|
1529
1750
|
].join('\n'),
|
|
@@ -1634,6 +1855,7 @@ async function cmdStatus({ channel, mode, json }) {
|
|
|
1634
1855
|
configured: {
|
|
1635
1856
|
enabled: Boolean(autoUpdateState?.enabled),
|
|
1636
1857
|
intervalMinutes: autoUpdateState?.intervalMinutes ?? null,
|
|
1858
|
+
at: autoUpdateState?.at ? String(autoUpdateState.at) : null,
|
|
1637
1859
|
},
|
|
1638
1860
|
},
|
|
1639
1861
|
healthy,
|
|
@@ -1653,6 +1875,7 @@ async function cmdStatus({ channel, mode, json }) {
|
|
|
1653
1875
|
configured: {
|
|
1654
1876
|
enabled: Boolean(autoUpdateState?.enabled),
|
|
1655
1877
|
intervalMinutes: autoUpdateState?.intervalMinutes ?? null,
|
|
1878
|
+
at: autoUpdateState?.at ? String(autoUpdateState.at) : null,
|
|
1656
1879
|
},
|
|
1657
1880
|
},
|
|
1658
1881
|
updatedAt: state?.updatedAt ?? null,
|
|
@@ -1731,9 +1954,10 @@ async function cmdUpdate({ channel, mode, json }) {
|
|
|
1731
1954
|
|
|
1732
1955
|
if (autoUpdateReconcile.action === 'install') {
|
|
1733
1956
|
await installAutoUpdateJob({
|
|
1734
|
-
config: configWithPort,
|
|
1957
|
+
config: { ...configWithPort, autoUpdateAt: autoUpdateReconcile.at },
|
|
1735
1958
|
enabled: true,
|
|
1736
1959
|
intervalMinutes: autoUpdateReconcile.intervalMinutes,
|
|
1960
|
+
at: autoUpdateReconcile.at,
|
|
1737
1961
|
}).catch(() => {});
|
|
1738
1962
|
} else {
|
|
1739
1963
|
await uninstallAutoUpdateJob({ config: configWithPort }).catch(() => {});
|
|
@@ -1744,7 +1968,7 @@ async function cmdUpdate({ channel, mode, json }) {
|
|
|
1744
1968
|
mode,
|
|
1745
1969
|
version: installResult.version,
|
|
1746
1970
|
source: installResult.source,
|
|
1747
|
-
autoUpdate: { enabled: autoUpdateReconcile.enabled, intervalMinutes: autoUpdateReconcile.intervalMinutes },
|
|
1971
|
+
autoUpdate: { enabled: autoUpdateReconcile.enabled, intervalMinutes: autoUpdateReconcile.intervalMinutes, at: autoUpdateReconcile.at },
|
|
1748
1972
|
uiWeb: uiInstalled
|
|
1749
1973
|
? { installed: true, version: uiResult.version, source: uiResult.source, tag: uiResult.tag }
|
|
1750
1974
|
: { installed: false, reason: String(uiResult?.reason ?? (withUi ? 'missing' : 'disabled')) },
|
|
@@ -1903,17 +2127,200 @@ async function cmdDoctor({ channel, mode, json }) {
|
|
|
1903
2127
|
}
|
|
1904
2128
|
}
|
|
1905
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
|
+
|
|
1906
2312
|
export function usageText() {
|
|
1907
2313
|
return [
|
|
1908
2314
|
banner('self-host', { subtitle: 'Happier Self-Host guided installation flow.' }),
|
|
1909
2315
|
'',
|
|
1910
2316
|
sectionTitle('usage:'),
|
|
1911
|
-
` ${cyan('hstack self-host')} install [--mode=user|system] [--without-cli] [--without-ui] [--channel=stable|preview] [--auto-update|--no-auto-update] [--auto-update-interval=<minutes>] [--non-interactive] [--json]`,
|
|
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]`,
|
|
1912
2318
|
` ${cyan('hstack self-host')} status [--mode=user|system] [--channel=stable|preview] [--json]`,
|
|
1913
2319
|
` ${cyan('hstack self-host')} update [--mode=user|system] [--channel=stable|preview] [--json]`,
|
|
1914
2320
|
` ${cyan('hstack self-host')} rollback [--mode=user|system] [--to=<version>] [--channel=stable|preview] [--json]`,
|
|
1915
2321
|
` ${cyan('hstack self-host')} uninstall [--mode=user|system] [--purge-data] [--yes] [--json]`,
|
|
1916
2322
|
` ${cyan('hstack self-host')} doctor [--json]`,
|
|
2323
|
+
` ${cyan('hstack self-host')} config view|set [--mode=user|system] [--channel=stable|preview] [--json]`,
|
|
1917
2324
|
'',
|
|
1918
2325
|
sectionTitle('notes:'),
|
|
1919
2326
|
'- works without a repository checkout (binary-safe flow).',
|
|
@@ -1938,7 +2345,7 @@ export async function runSelfHostCli(argv = process.argv.slice(2)) {
|
|
|
1938
2345
|
json,
|
|
1939
2346
|
data: {
|
|
1940
2347
|
ok: true,
|
|
1941
|
-
commands: ['install', 'status', 'update', 'rollback', 'uninstall', 'doctor'],
|
|
2348
|
+
commands: ['install', 'status', 'update', 'rollback', 'uninstall', 'doctor', 'config'],
|
|
1942
2349
|
},
|
|
1943
2350
|
text: usageText(),
|
|
1944
2351
|
});
|
|
@@ -1969,6 +2376,10 @@ export async function runSelfHostCli(argv = process.argv.slice(2)) {
|
|
|
1969
2376
|
await cmdDoctor({ channel, mode, json });
|
|
1970
2377
|
return;
|
|
1971
2378
|
}
|
|
2379
|
+
if (parsed.subcommand === 'config') {
|
|
2380
|
+
await cmdConfig({ channel, mode, argv: parsed.rest, json });
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
1972
2383
|
|
|
1973
2384
|
throw new Error(`[self-host] unknown command: ${parsed.subcommand}`);
|
|
1974
2385
|
}
|