happy-stacks 0.0.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +22 -4
  2. package/bin/happys.mjs +76 -5
  3. package/docs/server-flavors.md +61 -2
  4. package/docs/stacks.md +16 -4
  5. package/extras/swiftbar/auth-login.sh +5 -5
  6. package/extras/swiftbar/happy-stacks.5s.sh +83 -41
  7. package/extras/swiftbar/happys-term.sh +151 -0
  8. package/extras/swiftbar/happys.sh +52 -0
  9. package/extras/swiftbar/lib/render.sh +74 -56
  10. package/extras/swiftbar/lib/system.sh +37 -6
  11. package/extras/swiftbar/lib/utils.sh +180 -4
  12. package/extras/swiftbar/pnpm-term.sh +2 -122
  13. package/extras/swiftbar/pnpm.sh +2 -13
  14. package/extras/swiftbar/set-server-flavor.sh +8 -8
  15. package/extras/swiftbar/wt-pr.sh +1 -1
  16. package/package.json +1 -1
  17. package/scripts/auth.mjs +374 -3
  18. package/scripts/daemon.mjs +78 -11
  19. package/scripts/dev.mjs +122 -17
  20. package/scripts/init.mjs +238 -32
  21. package/scripts/migrate.mjs +292 -0
  22. package/scripts/mobile.mjs +51 -19
  23. package/scripts/run.mjs +118 -26
  24. package/scripts/service.mjs +176 -37
  25. package/scripts/stack.mjs +665 -22
  26. package/scripts/stop.mjs +157 -0
  27. package/scripts/tailscale.mjs +147 -21
  28. package/scripts/typecheck.mjs +145 -0
  29. package/scripts/ui_gateway.mjs +248 -0
  30. package/scripts/uninstall.mjs +3 -3
  31. package/scripts/utils/cli_registry.mjs +23 -0
  32. package/scripts/utils/config.mjs +9 -1
  33. package/scripts/utils/env.mjs +37 -15
  34. package/scripts/utils/expo.mjs +94 -0
  35. package/scripts/utils/happy_server_infra.mjs +430 -0
  36. package/scripts/utils/pm.mjs +11 -2
  37. package/scripts/utils/ports.mjs +51 -13
  38. package/scripts/utils/proc.mjs +46 -5
  39. package/scripts/utils/server.mjs +37 -0
  40. package/scripts/utils/stack_stop.mjs +206 -0
  41. package/scripts/utils/validate.mjs +42 -1
  42. package/scripts/worktrees.mjs +53 -7
@@ -12,7 +12,10 @@ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
12
12
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
13
 
14
14
  /**
15
- * Manage the macOS LaunchAgent installed by `happys bootstrap -- --autostart`.
15
+ * Manage the autostart service installed by `happys bootstrap -- --autostart`.
16
+ *
17
+ * - macOS: launchd LaunchAgents
18
+ * - Linux: systemd user services
16
19
  *
17
20
  * Commands:
18
21
  * - install | uninstall
@@ -60,8 +63,8 @@ function getAutostartEnv({ rootDir }) {
60
63
  }
61
64
 
62
65
  export async function installService() {
63
- if (process.platform !== 'darwin') {
64
- throw new Error('[local] service install is only supported on macOS (LaunchAgents).');
66
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
67
+ throw new Error('[local] service install is only supported on macOS (launchd) and Linux (systemd user).');
65
68
  }
66
69
  const rootDir = getRootDir(import.meta.url);
67
70
  const { primaryLabel: label } = getDefaultAutostartPaths();
@@ -76,12 +79,21 @@ export async function installService() {
76
79
  } catch {
77
80
  // ignore
78
81
  }
79
- await ensureMacAutostartEnabled({ rootDir, label, env });
80
- console.log('[local] service installed (macOS LaunchAgent)');
82
+ if (process.platform === 'darwin') {
83
+ await ensureMacAutostartEnabled({ rootDir, label, env });
84
+ console.log('[local] service installed (macOS launchd)');
85
+ return;
86
+ }
87
+ await ensureSystemdUserServiceEnabled({ rootDir, label, env });
88
+ console.log('[local] service installed (Linux systemd --user)');
81
89
  }
82
90
 
83
91
  export async function uninstallService() {
84
- if (process.platform !== 'darwin') {
92
+ if (process.platform !== 'darwin' && process.platform !== 'linux') return;
93
+
94
+ if (process.platform === 'linux') {
95
+ await ensureSystemdUserServiceDisabled({ remove: true });
96
+ console.log('[local] service uninstalled (systemd user unit removed)');
85
97
  return;
86
98
  }
87
99
  const { primaryPlistPath, legacyPlistPath, primaryLabel, legacyLabel } = getDefaultAutostartPaths();
@@ -102,6 +114,91 @@ export async function uninstallService() {
102
114
  console.log('[local] service uninstalled (plist removed)');
103
115
  }
104
116
 
117
+ function systemdUnitName() {
118
+ const { label } = getDefaultAutostartPaths();
119
+ return `${label}.service`;
120
+ }
121
+
122
+ function systemdUnitPath() {
123
+ return join(homedir(), '.config', 'systemd', 'user', systemdUnitName());
124
+ }
125
+
126
+ function systemdEnvLines(env) {
127
+ return Object.entries(env)
128
+ .map(([k, v]) => `Environment=${k}=${String(v)}`)
129
+ .join('\n');
130
+ }
131
+
132
+ async function ensureSystemdUserServiceEnabled({ rootDir, label, env }) {
133
+ const unitPath = systemdUnitPath();
134
+ await mkdir(dirname(unitPath), { recursive: true });
135
+ const happysShim = join(homedir(), '.happy-stacks', 'bin', 'happys');
136
+ const entry = existsSync(happysShim) ? happysShim : join(rootDir, 'bin', 'happys.mjs');
137
+ const exec = existsSync(happysShim) ? entry : `${process.execPath} ${entry}`;
138
+
139
+ const unit = `[Unit]
140
+ Description=Happy Stacks (${label})
141
+ After=network-online.target
142
+ Wants=network-online.target
143
+
144
+ [Service]
145
+ Type=simple
146
+ WorkingDirectory=%h
147
+ ${systemdEnvLines(env)}
148
+ ExecStart=${exec} start
149
+ Restart=always
150
+ RestartSec=2
151
+
152
+ [Install]
153
+ WantedBy=default.target
154
+ `;
155
+
156
+ await writeFile(unitPath, unit, 'utf-8');
157
+ await runCapture('systemctl', ['--user', 'daemon-reload']).catch(() => {});
158
+ await run('systemctl', ['--user', 'enable', '--now', systemdUnitName()]);
159
+ }
160
+
161
+ async function ensureSystemdUserServiceDisabled({ remove } = {}) {
162
+ await runCapture('systemctl', ['--user', 'disable', '--now', systemdUnitName()]).catch(() => {});
163
+ await runCapture('systemctl', ['--user', 'stop', systemdUnitName()]).catch(() => {});
164
+ if (remove) {
165
+ await rm(systemdUnitPath(), { force: true }).catch(() => {});
166
+ await runCapture('systemctl', ['--user', 'daemon-reload']).catch(() => {});
167
+ }
168
+ }
169
+
170
+ async function systemdStatus() {
171
+ await run('systemctl', ['--user', 'status', systemdUnitName(), '--no-pager']);
172
+ }
173
+
174
+ async function systemdStart({ persistent }) {
175
+ if (persistent) {
176
+ await run('systemctl', ['--user', 'enable', '--now', systemdUnitName()]);
177
+ } else {
178
+ await run('systemctl', ['--user', 'start', systemdUnitName()]);
179
+ }
180
+ }
181
+
182
+ async function systemdStop({ persistent }) {
183
+ if (persistent) {
184
+ await run('systemctl', ['--user', 'disable', '--now', systemdUnitName()]);
185
+ } else {
186
+ await run('systemctl', ['--user', 'stop', systemdUnitName()]);
187
+ }
188
+ }
189
+
190
+ async function systemdRestart() {
191
+ await run('systemctl', ['--user', 'restart', systemdUnitName()]);
192
+ }
193
+
194
+ async function systemdLogs({ lines = 120 } = {}) {
195
+ await run('journalctl', ['--user', '-u', systemdUnitName(), '-n', String(lines), '--no-pager']);
196
+ }
197
+
198
+ async function systemdTail() {
199
+ await run('journalctl', ['--user', '-u', systemdUnitName(), '-f']);
200
+ }
201
+
105
202
  async function launchctlTry(args) {
106
203
  try {
107
204
  await runCapture('launchctl', args);
@@ -306,7 +403,8 @@ async function postStartDiagnostics() {
306
403
  async function stopLaunchAgent({ persistent }) {
307
404
  const { plistPath } = getDefaultAutostartPaths();
308
405
  if (!existsSync(plistPath)) {
309
- throw new Error(`[local] LaunchAgent plist not found at ${plistPath}. Run: happys service:install (or happys bootstrap -- --autostart)`);
406
+ // Service isn't installed for this stack (common for ad-hoc stacks). Treat as a no-op.
407
+ return;
310
408
  }
311
409
 
312
410
  const { label } = getDefaultAutostartPaths();
@@ -400,8 +498,8 @@ async function tailLogs() {
400
498
  }
401
499
 
402
500
  async function main() {
403
- if (process.platform !== 'darwin') {
404
- throw new Error('[local] service commands are only supported on macOS (LaunchAgents).');
501
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
502
+ throw new Error('[local] service commands are only supported on macOS (launchd) and Linux (systemd user).');
405
503
  }
406
504
 
407
505
  const argv = process.argv.slice(2);
@@ -439,19 +537,6 @@ async function main() {
439
537
  return;
440
538
  case 'status':
441
539
  if (json) {
442
- const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
443
- let launchctlLine = null;
444
- try {
445
- const list = await runCapture('launchctl', ['list']);
446
- launchctlLine =
447
- list
448
- .split('\n')
449
- .map((l) => l.trim())
450
- .find((l) => l.endsWith(` ${label}`) || l === label || l.includes(`\t${label}`)) ?? null;
451
- } catch {
452
- launchctlLine = null;
453
- }
454
-
455
540
  const internalUrl = getInternalUrl();
456
541
  let health = null;
457
542
  try {
@@ -462,46 +547,100 @@ async function main() {
462
547
  health = { ok: false, status: null, body: null };
463
548
  }
464
549
 
465
- printResult({
466
- json,
467
- data: { label, plistPath, stdoutPath, stderrPath, internalUrl, launchctlLine, health },
468
- });
550
+ if (process.platform === 'darwin') {
551
+ const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
552
+ let launchctlLine = null;
553
+ try {
554
+ const list = await runCapture('launchctl', ['list']);
555
+ launchctlLine =
556
+ list
557
+ .split('\n')
558
+ .map((l) => l.trim())
559
+ .find((l) => l.endsWith(` ${label}`) || l === label || l.includes(`\t${label}`)) ?? null;
560
+ } catch {
561
+ launchctlLine = null;
562
+ }
563
+ printResult({ json, data: { label, plistPath, stdoutPath, stderrPath, internalUrl, launchctlLine, health } });
564
+ } else {
565
+ const unitName = systemdUnitName();
566
+ const unitPath = systemdUnitPath();
567
+ let systemctlStatus = null;
568
+ try {
569
+ systemctlStatus = await runCapture('systemctl', ['--user', 'status', unitName, '--no-pager']);
570
+ } catch (e) {
571
+ systemctlStatus = e && typeof e === 'object' && 'out' in e ? e.out : null;
572
+ }
573
+ printResult({ json, data: { unitName, unitPath, internalUrl, systemctlStatus, health } });
574
+ }
469
575
  } else {
470
- await showStatus();
576
+ if (process.platform === 'darwin') {
577
+ await showStatus();
578
+ } else {
579
+ await systemdStatus();
580
+ }
471
581
  }
472
582
  return;
473
583
  case 'start':
474
- await startLaunchAgent({ persistent: false });
584
+ if (process.platform === 'darwin') {
585
+ await startLaunchAgent({ persistent: false });
586
+ } else {
587
+ await systemdStart({ persistent: false });
588
+ }
475
589
  await postStartDiagnostics();
476
590
  if (json) printResult({ json, data: { ok: true, action: 'start' } });
477
591
  return;
478
592
  case 'stop':
479
- await stopLaunchAgent({ persistent: false });
593
+ if (process.platform === 'darwin') {
594
+ await stopLaunchAgent({ persistent: false });
595
+ } else {
596
+ await systemdStop({ persistent: false });
597
+ }
480
598
  if (json) printResult({ json, data: { ok: true, action: 'stop' } });
481
599
  return;
482
600
  case 'restart':
483
- if (!(await restartLaunchAgentBestEffort())) {
484
- await stopLaunchAgent({ persistent: false });
485
- await waitForLaunchAgentStopped();
486
- await startLaunchAgent({ persistent: false });
601
+ if (process.platform === 'darwin') {
602
+ if (!(await restartLaunchAgentBestEffort())) {
603
+ await stopLaunchAgent({ persistent: false });
604
+ await waitForLaunchAgentStopped();
605
+ await startLaunchAgent({ persistent: false });
606
+ }
607
+ } else {
608
+ await systemdRestart();
487
609
  }
488
610
  await postStartDiagnostics();
489
611
  if (json) printResult({ json, data: { ok: true, action: 'restart' } });
490
612
  return;
491
613
  case 'enable':
492
- await startLaunchAgent({ persistent: true });
614
+ if (process.platform === 'darwin') {
615
+ await startLaunchAgent({ persistent: true });
616
+ } else {
617
+ await systemdStart({ persistent: true });
618
+ }
493
619
  await postStartDiagnostics();
494
620
  if (json) printResult({ json, data: { ok: true, action: 'enable' } });
495
621
  return;
496
622
  case 'disable':
497
- await stopLaunchAgent({ persistent: true });
623
+ if (process.platform === 'darwin') {
624
+ await stopLaunchAgent({ persistent: true });
625
+ } else {
626
+ await systemdStop({ persistent: true });
627
+ }
498
628
  if (json) printResult({ json, data: { ok: true, action: 'disable' } });
499
629
  return;
500
630
  case 'logs':
501
- await showLogs();
631
+ if (process.platform === 'darwin') {
632
+ await showLogs();
633
+ } else {
634
+ const lines = Number(process.env.HAPPY_STACKS_LOG_LINES ?? process.env.HAPPY_LOCAL_LOG_LINES ?? 120) || 120;
635
+ await systemdLogs({ lines });
636
+ }
502
637
  return;
503
638
  case 'tail':
504
- await tailLogs();
639
+ if (process.platform === 'darwin') {
640
+ await tailLogs();
641
+ } else {
642
+ await systemdTail();
643
+ }
505
644
  return;
506
645
  default:
507
646
  throw new Error(`[local] unknown command: ${cmd}`);