@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.
@@ -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
- return { enabled, intervalMinutes };
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
- ? `configured enabled${autoConfiguredInterval ? ` (every ${autoConfiguredInterval}m)` : ''}`
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
- '[Timer]',
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
- startIntervalSec: startIntervalSec || undefined,
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 ps = `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${definitionPath}"`;
988
- const args = [
989
- '/Create',
990
- '/F',
991
- '/SC',
992
- 'MINUTE',
993
- '/MO',
994
- String(interval),
995
- '/ST',
996
- '00:00',
997
- '/TN',
998
- name,
999
- '/TR',
1000
- ps,
1001
- ...(backend === 'schtasks-system' ? ['/RU', 'SYSTEM', '/RL', 'HIGHEST'] : []),
1002
- ];
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 installFromRelease({ product, binaryName, config, explicitBinaryPath = '' }) {
1150
- if (explicitBinaryPath) {
1151
- const srcPath = explicitBinaryPath;
1152
- if (!existsSync(srcPath)) {
1153
- throw new Error(`[self-host] missing --server-binary path: ${srcPath}`);
1154
- }
1155
- const version = `local-${Date.now()}`;
1156
- await installBinaryAtomically({
1157
- sourceBinaryPath: srcPath,
1158
- targetBinaryPath: config.serverBinaryPath,
1159
- previousBinaryPath: config.serverPreviousBinaryPath,
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
- const channelTag = config.channel === 'preview' ? 'server-preview' : 'server-stable';
1177
- const release = await fetchGitHubReleaseByTag({
1178
- githubRepo: config.githubRepo,
1179
- tag: channelTag,
1180
- userAgent: 'happier-self-host-installer',
1181
- githubToken: String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''),
1182
- });
1183
- const os = normalizeOs(config.platform);
1184
- const resolved = resolveReleaseAssetBundle({
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: resolved,
1354
+ bundle: resolvedBundle,
1196
1355
  destDir: tempDir,
1197
1356
  pubkeyFile,
1198
- userAgent: 'happier-self-host-installer',
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, binaryName);
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 = resolved.version || String(release?.tag_name ?? '').replace(/^server-v/, '') || `${Date.now()}`;
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, `${binaryName}-${version}`),
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: asset.archiveUrl };
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(argv, config.autoUpdate);
1390
- const autoUpdateIntervalMinutes = resolveAutoUpdateIntervalMinutes(argv, config.autoUpdateIntervalMinutes);
1391
- const withoutCli = argv.includes('--without-cli') || parseBoolean(process.env.HAPPIER_WITH_CLI, true) === false;
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
- !(argv.includes('--without-ui')
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 = argv.includes('--non-interactive') || parseBoolean(process.env.HAPPIER_NONINTERACTIVE, false);
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
- await writeFile(config.configEnvPath, envText, 'utf-8');
1453
- const installEnv = parseEnvText(envText);
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({ config, enabled: autoUpdateEnabled, intervalMinutes: autoUpdateIntervalMinutes }).catch((e) => ({
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
  }