happy-stacks 0.1.2 → 0.2.0

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 (91) hide show
  1. package/README.md +121 -83
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/run.mjs CHANGED
@@ -1,19 +1,25 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { pathExists } from './utils/fs.mjs';
4
4
  import { killProcessTree, runCapture, spawnProc } from './utils/proc.mjs';
5
5
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
6
  import { killPortListeners } from './utils/ports.mjs';
7
7
  import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server.mjs';
8
- import { ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/pm.mjs';
8
+ import { ensureCliBuilt, ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/pm.mjs';
9
9
  import { homedir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { setTimeout as delay } from 'node:timers/promises';
12
- import { maybeResetTailscaleServe, resolvePublicServerUrl } from './tailscale.mjs';
12
+ import { maybeResetTailscaleServe } from './tailscale.mjs';
13
13
  import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
14
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
14
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
15
  import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.mjs';
16
16
  import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/happy_server_infra.mjs';
17
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './utils/stack_startup.mjs';
18
+ import { recordStackRuntimeStart, recordStackRuntimeUpdate } from './utils/stack_runtime_state.mjs';
19
+ import { resolveStackContext } from './utils/stack_context.mjs';
20
+ import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv, resolveServerUrls } from './utils/server_urls.mjs';
21
+ import { resolveLocalhostHost } from './utils/localhost_host.mjs';
22
+ import { openUrlInBrowser } from './utils/browser.mjs';
17
23
 
18
24
  /**
19
25
  * Run the local stack in "production-like" mode:
@@ -31,7 +37,7 @@ async function main() {
31
37
  if (wantsHelp(argv, { flags })) {
32
38
  printResult({
33
39
  json,
34
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart'], json: true },
40
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser'], json: true },
35
41
  text: [
36
42
  '[start] usage:',
37
43
  ' happys start [--server=happy-server|happy-server-light] [--restart] [--json]',
@@ -44,17 +50,14 @@ async function main() {
44
50
 
45
51
  const rootDir = getRootDir(import.meta.url);
46
52
 
47
- const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
48
- ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
49
- : 3005;
53
+ const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
50
54
 
51
55
  // Internal URL used by local processes on this machine.
52
56
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
53
57
  // Public URL is what you might share/open (e.g. https://<machine>.<tailnet>.ts.net).
54
58
  // We auto-prefer the Tailscale HTTPS URL when available, unless explicitly overridden.
55
- const defaultPublicUrl = `http://localhost:${serverPort}`;
56
- const envPublicUrl = process.env.HAPPY_LOCAL_SERVER_URL?.trim() ? process.env.HAPPY_LOCAL_SERVER_URL.trim() : '';
57
- let publicServerUrl = envPublicUrl || defaultPublicUrl;
59
+ const { defaultPublicUrl, envPublicUrl, publicServerUrl: publicServerUrlPreview } = getPublicServerUrlEnvOverride({ serverPort });
60
+ let publicServerUrl = publicServerUrlPreview;
58
61
 
59
62
  const serverComponentName = getServerComponentName({ kv });
60
63
  if (serverComponentName === 'both') {
@@ -64,6 +67,7 @@ async function main() {
64
67
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
65
68
  const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
66
69
  const serveUi = serveUiWanted;
70
+ const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
67
71
  const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
68
72
  const autostart = getDefaultAutostartPaths();
69
73
  const uiBuildDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
@@ -120,18 +124,29 @@ async function main() {
120
124
  const children = [];
121
125
  let shuttingDown = false;
122
126
  const baseEnv = { ...process.env };
127
+ const stackCtx = resolveStackContext({ env: baseEnv, autostart });
128
+ const { stackMode, runtimeStatePath, stackName, ephemeral } = stackCtx;
129
+
130
+ // Ensure happy-cli is install+build ready before starting the daemon.
131
+ const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
132
+ await ensureCliBuilt(cliDir, { buildCli });
123
133
 
124
134
  // Ensure server deps exist before any Prisma/docker work.
125
135
  await ensureDepsInstalled(serverDir, serverComponentName);
126
136
 
127
- // Public URL automation: auto-prefer https://*.ts.net on every start.
128
- const resolved = await resolvePublicServerUrl({
129
- internalServerUrl,
130
- defaultPublicUrl,
131
- envPublicUrl,
132
- allowEnable: true,
133
- });
134
- publicServerUrl = resolved.publicServerUrl;
137
+ // Public URL automation:
138
+ // - Only the main stack should ever auto-enable Tailscale Serve by default.
139
+ // - Non-main stacks default to localhost unless the user explicitly configured a public URL
140
+ // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
141
+ const allowEnableTailscale = !stackMode || stackName === 'main';
142
+ const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
143
+ if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
144
+ const src = String(resolvedUrls.publicServerUrlSource ?? '');
145
+ const hasStackScopedTailscale = src.startsWith('tailscale-');
146
+ publicServerUrl = hasStackScopedTailscale ? resolvedUrls.publicServerUrl : resolvedUrls.defaultPublicUrl;
147
+ } else {
148
+ publicServerUrl = resolvedUrls.publicServerUrl;
149
+ }
135
150
 
136
151
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
137
152
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
@@ -140,9 +155,21 @@ async function main() {
140
155
  return;
141
156
  }
142
157
 
158
+ // Stack runtime state (stack-scoped commands only): record the runner PID + chosen ports so stop/restart never kills other stacks.
159
+ if (stackMode && runtimeStatePath) {
160
+ await recordStackRuntimeStart(runtimeStatePath, {
161
+ stackName,
162
+ script: 'run.mjs',
163
+ ephemeral,
164
+ ownerPid: process.pid,
165
+ ports: { server: serverPort },
166
+ }).catch(() => {});
167
+ }
168
+
143
169
  // Server
144
170
  // If a previous run left a server behind, free the port first (prevents false "ready" checks).
145
- if (!serverAlreadyRunning || restart) {
171
+ // NOTE: In stack mode we avoid killing arbitrary port listeners (fail-closed instead).
172
+ if ((!serverAlreadyRunning || restart) && !stackMode) {
146
173
  await killPortListeners(serverPort, { label: 'server' });
147
174
  }
148
175
 
@@ -161,6 +188,8 @@ async function main() {
161
188
  }
162
189
  : {}),
163
190
  };
191
+ let serverLightAccountCount = null;
192
+ let happyServerAccountCount = null;
164
193
  if (serverComponentName === 'happy-server-light') {
165
194
  const dataDir = baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR?.trim()
166
195
  ? baseEnv.HAPPY_SERVER_LIGHT_DATA_DIR.trim()
@@ -173,12 +202,14 @@ async function main() {
173
202
  ? baseEnv.DATABASE_URL.trim()
174
203
  : `file:${join(dataDir, 'happy-server-light.sqlite')}`;
175
204
 
176
- // Optional: update SQLite schema on interactive start only.
177
- // We intentionally do NOT run this under launchd KeepAlive (no TTY) to avoid restart loops.
178
- const prismaPushOnStart = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '').trim() === '1';
179
- if (prismaPushOnStart && process.stdout.isTTY) {
180
- await pmExecBin({ dir: serverDir, bin: 'prisma', args: ['db', 'push'], env: serverEnv });
181
- }
205
+ // Reliability: ensure DB schema exists before daemon hits /v1/machines (health checks don't cover DB readiness).
206
+ const acct = await getAccountCountForServerComponent({
207
+ serverComponentName,
208
+ serverDir,
209
+ env: serverEnv,
210
+ bestEffort: false,
211
+ });
212
+ serverLightAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
182
213
  }
183
214
  let effectiveInternalServerUrl = internalServerUrl;
184
215
  if (serverComponentName === 'happy-server') {
@@ -198,16 +229,32 @@ async function main() {
198
229
  const backendPortRaw = (baseEnv.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? baseEnv.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT ?? '').trim();
199
230
  const backendPort = backendPortRaw ? Number(backendPortRaw) : serverPort + 10;
200
231
  const backendUrl = `http://127.0.0.1:${backendPort}`;
201
- await killPortListeners(backendPort, { label: 'happy-server-backend' });
232
+ if (!stackMode) {
233
+ await killPortListeners(backendPort, { label: 'happy-server-backend' });
234
+ }
202
235
 
203
236
  const backendEnv = { ...serverEnv, ...infra.env, PORT: String(backendPort) };
204
237
  const autoMigrate = (baseEnv.HAPPY_STACKS_PRISMA_MIGRATE ?? baseEnv.HAPPY_LOCAL_PRISMA_MIGRATE ?? '1') !== '0';
205
238
  if (autoMigrate) {
206
239
  await applyHappyServerMigrations({ serverDir, env: backendEnv });
207
240
  }
241
+ // Account probe should use the *actual* DATABASE_URL/infra env (ephemeral stacks do not persist it in env files).
242
+ const acct = await getAccountCountForServerComponent({
243
+ serverComponentName,
244
+ serverDir,
245
+ env: backendEnv,
246
+ bestEffort: true,
247
+ });
248
+ happyServerAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
208
249
 
209
250
  const backend = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: backendEnv });
210
251
  children.push(backend);
252
+ if (stackMode && runtimeStatePath) {
253
+ await recordStackRuntimeUpdate(runtimeStatePath, {
254
+ ports: { server: serverPort, backend: backendPort },
255
+ processes: { happyServerBackendPid: backend.pid },
256
+ }).catch(() => {});
257
+ }
211
258
  await waitForServerReady(backendUrl);
212
259
 
213
260
  const gatewayArgs = [
@@ -225,6 +272,9 @@ async function main() {
225
272
 
226
273
  const gateway = spawnProc('ui', process.execPath, gatewayArgs, { ...backendEnv, PORT: String(serverPort) }, { cwd: rootDir });
227
274
  children.push(gateway);
275
+ if (stackMode && runtimeStatePath) {
276
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { uiGatewayPid: gateway.pid } }).catch(() => {});
277
+ }
228
278
  await waitForServerReady(internalServerUrl);
229
279
  effectiveInternalServerUrl = internalServerUrl;
230
280
 
@@ -237,6 +287,9 @@ async function main() {
237
287
  if (!serverAlreadyRunning || restart) {
238
288
  const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: serverEnv });
239
289
  children.push(server);
290
+ if (stackMode && runtimeStatePath) {
291
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: server.pid } }).catch(() => {});
292
+ }
240
293
  await waitForServerReady(internalServerUrl);
241
294
  } else {
242
295
  console.log(`[local] server already running at ${internalServerUrl}`);
@@ -274,10 +327,44 @@ async function main() {
274
327
  `export HAPPY_HOME_DIR=\"${cliHomeDir}\"\n` +
275
328
  `export HAPPY_WEBAPP_URL=\"${publicServerUrl}\"\n`
276
329
  );
330
+
331
+ // Auto-open UI (interactive only) using the stack-scoped hostname when applicable.
332
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
333
+ if (isInteractive && !noBrowser) {
334
+ const host = resolveLocalhostHost({ stackMode, stackName: autostart.stackName });
335
+ const prefix = uiPrefix.startsWith('/') ? uiPrefix : `/${uiPrefix}`;
336
+ const openUrl = `http://${host}:${serverPort}${prefix}`;
337
+ const res = await openUrlInBrowser(openUrl);
338
+ if (!res.ok) {
339
+ console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
340
+ }
341
+ }
277
342
  }
278
343
 
279
344
  // Daemon
280
345
  if (startDaemon) {
346
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
347
+ if (serverComponentName === 'happy-server' && happyServerAccountCount == null) {
348
+ const acct = await getAccountCountForServerComponent({
349
+ serverComponentName,
350
+ serverDir,
351
+ env: serverEnv,
352
+ bestEffort: true,
353
+ });
354
+ happyServerAccountCount = typeof acct.accountCount === 'number' ? acct.accountCount : null;
355
+ }
356
+ const accountCount =
357
+ serverComponentName === 'happy-server-light' ? serverLightAccountCount : happyServerAccountCount;
358
+ await prepareDaemonAuthSeedIfNeeded({
359
+ rootDir,
360
+ env: baseEnv,
361
+ stackName: autostart.stackName,
362
+ cliHomeDir,
363
+ startDaemon,
364
+ isInteractive,
365
+ accountCount,
366
+ quiet: false,
367
+ });
281
368
  await startLocalDaemonWithAuth({
282
369
  cliBin,
283
370
  cliHomeDir,
package/scripts/self.mjs CHANGED
@@ -2,20 +2,16 @@ import './utils/env.mjs';
2
2
 
3
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
5
  import { join } from 'node:path';
7
6
 
8
- import { parseArgs } from './utils/args.mjs';
7
+ import { parseArgs } from './utils/cli/args.mjs';
9
8
  import { pathExists } from './utils/fs.mjs';
10
9
  import { run, runCapture } from './utils/proc.mjs';
10
+ import { expandHome } from './utils/canonical_home.mjs';
11
11
  import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
12
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
12
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
13
13
  import { getRuntimeDir } from './utils/runtime.mjs';
14
14
 
15
- function expandHome(p) {
16
- return p.replace(/^~(?=\/)/, homedir());
17
- }
18
-
19
15
  function cachePaths() {
20
16
  const home = getHappyStacksHomeDir();
21
17
  return {
@@ -1,10 +1,10 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { getRootDir } from './utils/paths.mjs';
4
4
  import { ensureEnvFileUpdated } from './utils/env_file.mjs';
5
5
  import { resolveUserConfigEnvPath } from './utils/config.mjs';
6
- import { isTty, promptSelect, withRl } from './utils/wizard.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
6
+ import { isTty, promptSelect, withRl } from './utils/cli/wizard.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
8
 
9
9
  const FLAVORS = [
10
10
  { label: 'happy-server-light (recommended default, serves UI)', value: 'happy-server-light' },
@@ -2,13 +2,15 @@ import './utils/env.mjs';
2
2
  import { run, runCapture } from './utils/proc.mjs';
3
3
  import { getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
4
4
  import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/pm.mjs';
5
+ import { getCanonicalHomeDir } from './utils/config.mjs';
6
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
5
7
  import { spawn } from 'node:child_process';
6
8
  import { homedir } from 'node:os';
7
9
  import { existsSync } from 'node:fs';
8
10
  import { rm } from 'node:fs/promises';
9
11
  import { dirname, join, resolve } from 'node:path';
10
12
  import { fileURLToPath } from 'node:url';
11
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
13
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
12
14
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
15
 
14
16
  /**
@@ -63,6 +65,13 @@ function getAutostartEnv({ rootDir }) {
63
65
  }
64
66
 
65
67
  export async function installService() {
68
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
69
+ throw new Error(
70
+ '[local] service install is disabled in sandbox mode.\n' +
71
+ 'Reason: services are global OS state (launchd/systemd) and can affect your real installation.\n' +
72
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
73
+ );
74
+ }
66
75
  if (process.platform !== 'darwin' && process.platform !== 'linux') {
67
76
  throw new Error('[local] service install is only supported on macOS (launchd) and Linux (systemd user).');
68
77
  }
@@ -89,6 +98,10 @@ export async function installService() {
89
98
  }
90
99
 
91
100
  export async function uninstallService() {
101
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
102
+ // Sandbox cleanups should be safe and should not touch global services by default.
103
+ return;
104
+ }
92
105
  if (process.platform !== 'darwin' && process.platform !== 'linux') return;
93
106
 
94
107
  if (process.platform === 'linux') {
@@ -132,7 +145,7 @@ function systemdEnvLines(env) {
132
145
  async function ensureSystemdUserServiceEnabled({ rootDir, label, env }) {
133
146
  const unitPath = systemdUnitPath();
134
147
  await mkdir(dirname(unitPath), { recursive: true });
135
- const happysShim = join(homedir(), '.happy-stacks', 'bin', 'happys');
148
+ const happysShim = join(getCanonicalHomeDir(), 'bin', 'happys');
136
149
  const entry = existsSync(happysShim) ? happysShim : join(rootDir, 'bin', 'happys.mjs');
137
150
  const exec = existsSync(happysShim) ? entry : `${process.execPath} ${entry}`;
138
151