happy-stacks 0.1.2 → 0.3.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 (116) hide show
  1. package/README.md +164 -89
  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 +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -0
  64. package/scripts/utils/dev/daemon.mjs +104 -0
  65. package/scripts/utils/dev/expo_web.mjs +112 -0
  66. package/scripts/utils/dev/server.mjs +183 -0
  67. package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
package/scripts/happy.mjs CHANGED
@@ -1,27 +1,12 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
4
  import { join } from 'node:path';
6
- import { parseArgs } from './utils/args.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
9
-
10
- function expandHome(p) {
11
- return p.replace(/^~(?=\/)/, homedir());
12
- }
13
-
14
- function resolveCliHomeDir() {
15
- const fromExplicit = (process.env.HAPPY_HOME_DIR ?? '').trim();
16
- if (fromExplicit) {
17
- return expandHome(fromExplicit);
18
- }
19
- const fromStacks = (process.env.HAPPY_STACKS_CLI_HOME_DIR ?? process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
20
- if (fromStacks) {
21
- return expandHome(fromStacks);
22
- }
23
- return join(getDefaultAutostartPaths().baseDir, 'cli');
24
- }
5
+ import { parseArgs } from './utils/cli/args.mjs';
6
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
7
+ import { getComponentDir, getRootDir, getStackName } from './utils/paths/paths.mjs';
8
+ import { resolveCliHomeDir } from './utils/stack/dirs.mjs';
9
+ import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv } from './utils/server/urls.mjs';
25
10
 
26
11
  async function main() {
27
12
  const argv = process.argv.slice(2);
@@ -46,12 +31,12 @@ async function main() {
46
31
 
47
32
  const rootDir = getRootDir(import.meta.url);
48
33
 
49
- const portRaw = (process.env.HAPPY_STACKS_SERVER_PORT ?? process.env.HAPPY_LOCAL_SERVER_PORT ?? '').trim();
50
- const port = portRaw ? Number(portRaw) : 3005;
51
- const serverPort = Number.isFinite(port) ? port : 3005;
34
+ const stackName =
35
+ (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').toString().trim() || getStackName();
36
+ const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
52
37
 
53
38
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
54
- const publicServerUrl = (process.env.HAPPY_STACKS_SERVER_URL ?? process.env.HAPPY_LOCAL_SERVER_URL ?? '').trim() || `http://localhost:${serverPort}`;
39
+ const { publicServerUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort, stackName });
55
40
 
56
41
  const cliHomeDir = resolveCliHomeDir();
57
42
 
package/scripts/init.mjs CHANGED
@@ -4,21 +4,11 @@ import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/config.mjs';
8
- import { parseDotenv } from './utils/dotenv.mjs';
9
-
10
- function expandHome(p) {
11
- return p.replace(/^~(?=\/)/, homedir());
12
- }
13
-
14
- async function readJsonIfExists(path) {
15
- try {
16
- const raw = await readFile(path, 'utf-8');
17
- return JSON.parse(raw);
18
- } catch {
19
- return null;
20
- }
21
- }
7
+ import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/env/config.mjs';
8
+ import { parseDotenv } from './utils/env/dotenv.mjs';
9
+ import { expandHome } from './utils/paths/canonical_home.mjs';
10
+ import { readJsonIfExists } from './utils/fs/json.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
22
12
 
23
13
  function getCliRootDir() {
24
14
  return dirname(dirname(fileURLToPath(import.meta.url)));
@@ -139,15 +129,15 @@ async function main() {
139
129
  if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
140
130
  console.log([
141
131
  '[init] usage:',
142
- ' happys init [--home-dir=/path] [--workspace-dir=/path] [--runtime-dir=/path] [--storage-dir=/path] [--cli-root-dir=/path] [--tailscale-bin=/path] [--tailscale-cmd-timeout-ms=MS] [--tailscale-enable-timeout-ms=MS] [--tailscale-enable-timeout-ms-auto=MS] [--tailscale-reset-timeout-ms=MS] [--install-path] [--no-runtime] [--force-runtime] [--no-bootstrap] [--] [bootstrap args...]',
132
+ ' happys init [--canonical-home-dir=/path] [--home-dir=/path] [--workspace-dir=/path] [--runtime-dir=/path] [--storage-dir=/path] [--cli-root-dir=/path] [--tailscale-bin=/path] [--tailscale-cmd-timeout-ms=MS] [--tailscale-enable-timeout-ms=MS] [--tailscale-enable-timeout-ms-auto=MS] [--tailscale-reset-timeout-ms=MS] [--install-path] [--no-runtime] [--force-runtime] [--no-bootstrap] [--] [bootstrap args...]',
143
133
  '',
144
134
  'notes:',
145
- ' - writes ~/.happy-stacks/.env (stable pointer file)',
146
- ' - default workspace: ~/.happy-stacks/workspace',
147
- ' - default runtime: ~/.happy-stacks/runtime (recommended for services/SwiftBar)',
135
+ ' - writes <canonicalHomeDir>/.env (stable pointer file; default: ~/.happy-stacks/.env)',
136
+ ' - default workspace: <homeDir>/workspace',
137
+ ' - default runtime: <homeDir>/runtime (recommended for services/SwiftBar)',
148
138
  ' - runtime install is skipped if the same version is already installed (use --force-runtime to reinstall)',
149
139
  ' - set HAPPY_STACKS_INIT_NO_RUNTIME=1 to persist skipping runtime installs on this machine',
150
- ' - optional: --install-path adds ~/.happy-stacks/bin to your shell PATH (idempotent)',
140
+ ' - optional: --install-path adds <homeDir>/bin to your shell PATH (idempotent)',
151
141
  ' - by default, runs `happys bootstrap --interactive` at the end (TTY only) IF components are not already present',
152
142
  ].join('\n'));
153
143
  return;
@@ -159,7 +149,17 @@ async function main() {
159
149
  //
160
150
  // Other scripts load this pointer via `scripts/utils/env.mjs`, but `init.mjs` is often run before
161
151
  // anything else (or directly from a repo checkout). So we load it here too.
162
- const canonicalEnvPath = join(homedir(), '.happy-stacks', '.env');
152
+ const canonicalHomeDirRaw = parseArgValue(argv, 'canonical-home-dir');
153
+ const canonicalHomeDir = expandHome(firstNonEmpty(
154
+ canonicalHomeDirRaw,
155
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR,
156
+ process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR,
157
+ join(homedir(), '.happy-stacks'),
158
+ ));
159
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = canonicalHomeDir;
160
+ process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR = process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR ?? canonicalHomeDir;
161
+
162
+ const canonicalEnvPath = join(canonicalHomeDir, '.env');
163
163
  if (existsSync(canonicalEnvPath)) {
164
164
  await loadEnvFile(canonicalEnvPath, { override: false });
165
165
  await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
@@ -245,6 +245,7 @@ async function main() {
245
245
  const nodePath = process.execPath;
246
246
 
247
247
  await mkdir(homeDir, { recursive: true });
248
+ await mkdir(canonicalHomeDir, { recursive: true });
248
249
  await mkdir(workspaceDir, { recursive: true });
249
250
  await mkdir(join(workspaceDir, 'components'), { recursive: true });
250
251
  await mkdir(runtimeDir, { recursive: true });
@@ -306,10 +307,10 @@ async function main() {
306
307
  const shim = [
307
308
  '#!/bin/bash',
308
309
  'set -euo pipefail',
309
- 'CANONICAL_ENV="$HOME/.happy-stacks/.env"',
310
+ `CANONICAL_ENV="${canonicalEnvPath}"`,
310
311
  '',
311
312
  '# Best-effort: if env vars are not exported (common under launchd/SwiftBar),',
312
- '# read the stable pointer file at ~/.happy-stacks/.env to discover the real dirs.',
313
+ '# read the stable pointer file at CANONICAL_ENV to discover the real dirs.',
313
314
  'if [[ -f "$CANONICAL_ENV" ]]; then',
314
315
  ' if [[ -z "${HAPPY_STACKS_HOME_DIR:-}" ]]; then',
315
316
  ' HAPPY_STACKS_HOME_DIR="$(grep -E \'^HAPPY_STACKS_HOME_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_HOME_DIR=//\')" || true',
@@ -333,7 +334,7 @@ async function main() {
333
334
  ' fi',
334
335
  'fi',
335
336
  '',
336
- 'HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"',
337
+ `HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-${canonicalHomeDir}}"`,
337
338
  'ENV_FILE="$HOME_DIR/.env"',
338
339
  'WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$HOME_DIR/workspace}"',
339
340
  'if [[ -d "$WORKDIR" ]]; then',
@@ -370,22 +371,31 @@ async function main() {
370
371
  await writeExecutable(happysShimPath, shim);
371
372
  await writeExecutable(happyShimPath, `#!/bin/bash\nset -euo pipefail\nexec \"${happysShimPath}\" happy \"$@\"\n`);
372
373
 
374
+ let didInstallPath = false;
373
375
  if (argv.includes('--install-path')) {
374
- const res = await ensurePathInstalled({ homeDir });
375
- if (res.updated) {
376
- console.log(`[init] added ${homeDir}/bin to PATH via ${res.path}`);
376
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
377
+ console.log('[init] sandbox mode: skipping --install-path (would modify your shell config)');
378
+ console.log('[init] tip: set HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1 if you really want to test PATH modifications');
377
379
  } else {
378
- console.log(`[init] PATH already configured in ${res.path}`);
380
+ const res = await ensurePathInstalled({ homeDir });
381
+ didInstallPath = true;
382
+ if (res.updated) {
383
+ console.log(`[init] added ${homeDir}/bin to PATH via ${res.path}`);
384
+ } else {
385
+ console.log(`[init] PATH already configured in ${res.path}`);
386
+ }
379
387
  }
380
388
  }
381
389
 
390
+ const invokedBySetup = (process.env.HAPPY_STACKS_SETUP_CHILD ?? '').trim() === '1';
391
+
382
392
  console.log('[init] complete');
383
393
  console.log(`[init] home: ${homeDir}`);
384
394
  console.log(`[init] workspace: ${workspaceDir}`);
385
395
  console.log(`[init] shims: ${homeDir}/bin`);
386
396
  console.log('');
387
397
 
388
- if (!argv.includes('--install-path')) {
398
+ if (!argv.includes('--install-path') || !didInstallPath) {
389
399
  console.log('[init] note: to use `happys` / `happy` from any terminal, add shims to PATH:');
390
400
  console.log(` export PATH="${homeDir}/bin:$PATH"`);
391
401
  console.log(' (or re-run: happys init --install-path)');
@@ -422,13 +432,18 @@ async function main() {
422
432
 
423
433
  if (wantBootstrap && alreadyBootstrapped && !bootstrapExplicit) {
424
434
  console.log('[init] bootstrap: already set up; skipping');
425
- console.log('[init] tip: to re-run setup: happys bootstrap --interactive');
435
+ console.log('[init] tip: for guided onboarding: happys setup');
426
436
  console.log('');
427
437
  }
428
438
 
439
+ // When `happys setup` drives init, avoid printing confusing “next steps”.
440
+ if (invokedBySetup) {
441
+ return;
442
+ }
443
+
429
444
  console.log('[init] next steps:');
430
445
  console.log(` export PATH=\"${homeDir}/bin:$PATH\"`);
431
- console.log(' happys bootstrap --interactive');
446
+ console.log(' happys setup');
432
447
  }
433
448
 
434
449
  main().catch((err) => {
@@ -1,22 +1,23 @@
1
- import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { pathExists } from './utils/fs.mjs';
4
- import { run } from './utils/proc.mjs';
5
- import { getComponentDir, getRootDir } from './utils/paths.mjs';
6
- import { getServerComponentName } from './utils/server.mjs';
7
- import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { pathExists } from './utils/fs/fs.mjs';
4
+ import { run } from './utils/proc/proc.mjs';
5
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
6
+ import { getServerComponentName } from './utils/server/server.mjs';
7
+ import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.mjs';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { mkdir } from 'node:fs/promises';
10
10
  import { installService, uninstallService } from './service.mjs';
11
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
12
- import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
13
- import { isTty, prompt, promptSelect, withRl } from './utils/wizard.mjs';
11
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
12
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
13
+ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
14
15
 
15
16
  /**
16
17
  * Install/setup the local stack:
17
18
  * - ensure components exist (optionally clone if missing)
18
19
  * - install dependencies where needed
19
- * - build happy-cli (optional) and install `happy`/`happys` shims under `~/.happy-stacks/bin`
20
+ * - build happy-cli (optional) and install `happy`/`happys` shims under `<homeDir>/bin`
20
21
  * - build the web UI bundle (so `run` can serve it)
21
22
  * - optional macOS autostart (LaunchAgent)
22
23
  */
@@ -152,7 +153,9 @@ async function interactiveWizard({ rootDir, defaults }) {
152
153
  });
153
154
 
154
155
  const enableAutostart = await promptSelect(rl, {
155
- title: 'Enable macOS autostart (LaunchAgent)?',
156
+ title: isSandboxed()
157
+ ? 'Enable macOS autostart (LaunchAgent)? (NOTE: sandbox mode; this is global OS state)'
158
+ : 'Enable macOS autostart (LaunchAgent)?',
156
159
  options: [
157
160
  { label: 'no (default)', value: false },
158
161
  { label: 'yes', value: true },
@@ -211,6 +214,8 @@ async function main() {
211
214
  const rootDir = getRootDir(import.meta.url);
212
215
 
213
216
  const interactive = flags.has('--interactive') && isTty();
217
+ const allowGlobal = sandboxAllowsGlobalSideEffects();
218
+ const sandboxed = isSandboxed();
214
219
 
215
220
  // Defaults for wizard.
216
221
  const defaultRepoSource = resolveRepoSource({ flags });
@@ -220,7 +225,7 @@ async function main() {
220
225
  upstreamOwner: 'slopus',
221
226
  serverComponentName: getServerComponentName({ kv }),
222
227
  allowClone: !flags.has('--no-clone') && ((process.env.HAPPY_LOCAL_CLONE_MISSING ?? '1') !== '0' || flags.has('--clone')),
223
- enableAutostart: flags.has('--autostart') || (process.env.HAPPY_LOCAL_AUTOSTART ?? '0') === '1',
228
+ enableAutostart: (!sandboxed || allowGlobal) && (flags.has('--autostart') || (process.env.HAPPY_LOCAL_AUTOSTART ?? '0') === '1'),
224
229
  buildTauri: flags.has('--tauri') && !flags.has('--no-tauri'),
225
230
  };
226
231
 
@@ -260,7 +265,8 @@ async function main() {
260
265
  const cloneMissingDefault = (process.env.HAPPY_LOCAL_CLONE_MISSING ?? '1') !== '0';
261
266
  const allowClone =
262
267
  wizard?.allowClone ?? (!flags.has('--no-clone') && (flags.has('--clone') || cloneMissingDefault));
263
- const enableAutostart = wizard?.enableAutostart ?? (flags.has('--autostart') || (process.env.HAPPY_LOCAL_AUTOSTART ?? '0') === '1');
268
+ const enableAutostartRaw = wizard?.enableAutostart ?? (flags.has('--autostart') || (process.env.HAPPY_LOCAL_AUTOSTART ?? '0') === '1');
269
+ const enableAutostart = sandboxed && !allowGlobal ? false : enableAutostartRaw;
264
270
  const disableAutostart = flags.has('--no-autostart');
265
271
 
266
272
  const serverComponentName = (wizard?.serverComponentName ?? getServerComponentName({ kv })).trim();
@@ -366,7 +372,7 @@ async function main() {
366
372
  serverComponentName,
367
373
  dirs: { serverLightDir, serverFullDir, cliDir: cliDirFinal, uiDir: uiDirFinal },
368
374
  cloned: allowClone,
369
- autostart: enableAutostart ? 'enabled' : disableAutostart ? 'disabled' : 'unchanged',
375
+ autostart: enableAutostart ? 'enabled' : sandboxed && enableAutostartRaw && !allowGlobal ? 'skipped (sandbox)' : disableAutostart ? 'disabled' : 'unchanged',
370
376
  interactive: Boolean(wizard),
371
377
  },
372
378
  text: '[local] setup complete',
@@ -0,0 +1,124 @@
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
+ import { pathExists } from './utils/fs/fs.mjs';
7
+ import { run } from './utils/proc/proc.mjs';
8
+ import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+
10
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
11
+
12
+ function pickLintScript(scripts) {
13
+ const candidates = [
14
+ 'lint',
15
+ 'lint:ci',
16
+ 'check',
17
+ 'check:lint',
18
+ 'eslint',
19
+ 'eslint:check',
20
+ ];
21
+ return pickFirstScript(scripts, candidates);
22
+ }
23
+
24
+ async function main() {
25
+ const argv = process.argv.slice(2);
26
+ const { flags } = parseArgs(argv);
27
+ const json = wantsJson(argv, { flags });
28
+
29
+ if (wantsHelp(argv, { flags })) {
30
+ printResult({
31
+ json,
32
+ data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
33
+ text: [
34
+ '[lint] usage:',
35
+ ' happys lint [component...] [--json]',
36
+ '',
37
+ 'components:',
38
+ ` ${DEFAULT_COMPONENTS.join(' | ')}`,
39
+ '',
40
+ 'examples:',
41
+ ' happys lint',
42
+ ' happys lint happy happy-cli',
43
+ ].join('\n'),
44
+ });
45
+ return;
46
+ }
47
+
48
+ const positionals = argv.filter((a) => !a.startsWith('--'));
49
+ const requested = positionals.length ? positionals : ['all'];
50
+ const wantAll = requested.includes('all');
51
+ const components = wantAll ? DEFAULT_COMPONENTS : requested;
52
+
53
+ const rootDir = getRootDir(import.meta.url);
54
+
55
+ const results = [];
56
+ for (const component of components) {
57
+ if (!DEFAULT_COMPONENTS.includes(component)) {
58
+ results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${DEFAULT_COMPONENTS.join(', ')})` });
59
+ continue;
60
+ }
61
+
62
+ const dir = getComponentDir(rootDir, component);
63
+ if (!(await pathExists(dir))) {
64
+ results.push({ component, ok: false, skipped: false, dir, error: `missing component dir: ${dir}` });
65
+ continue;
66
+ }
67
+
68
+ const scripts = await readPackageJsonScripts(dir);
69
+ if (!scripts) {
70
+ results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
71
+ continue;
72
+ }
73
+
74
+ const script = pickLintScript(scripts);
75
+ if (!script) {
76
+ results.push({ component, ok: true, skipped: true, dir, reason: 'no lint script found in package.json' });
77
+ continue;
78
+ }
79
+
80
+ await ensureDepsInstalled(dir, component);
81
+ const pm = await detectPackageManagerCmd(dir);
82
+
83
+ try {
84
+ // eslint-disable-next-line no-console
85
+ console.log(`[lint] ${component}: running ${pm.name} ${script}`);
86
+ await run(pm.cmd, pm.argsForScript(script), { cwd: dir, env: process.env });
87
+ results.push({ component, ok: true, skipped: false, dir, pm: pm.name, script });
88
+ } catch (e) {
89
+ results.push({ component, ok: false, skipped: false, dir, pm: pm.name, script, error: String(e?.message ?? e) });
90
+ }
91
+ }
92
+
93
+ const ok = results.every((r) => r.ok);
94
+ if (json) {
95
+ printResult({ json, data: { ok, results } });
96
+ return;
97
+ }
98
+
99
+ const lines = ['[lint] results:'];
100
+ for (const r of results) {
101
+ if (r.ok && r.skipped) {
102
+ lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
103
+ } else if (r.ok) {
104
+ lines.push(`- ✅ ${r.component}: ok (${r.pm} ${r.script})`);
105
+ } else {
106
+ lines.push(`- ❌ ${r.component}: failed (${r.pm ?? 'unknown'} ${r.script ?? ''})`);
107
+ if (r.error) lines.push(` - ${r.error}`);
108
+ }
109
+ }
110
+ if (!ok) {
111
+ lines.push('');
112
+ lines.push('[lint] failed');
113
+ }
114
+ printResult({ json: false, text: lines.join('\n') });
115
+ if (!ok) {
116
+ process.exit(1);
117
+ }
118
+ }
119
+
120
+ main().catch((err) => {
121
+ console.error('[lint] failed:', err);
122
+ process.exit(1);
123
+ });
124
+
@@ -1,11 +1,15 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { cp, mkdir } from 'node:fs/promises';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
7
- import { parseArgs } from './utils/args.mjs';
8
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
6
+ import { createHash } from 'node:crypto';
7
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths/paths.mjs';
8
+ import { parseArgs } from './utils/cli/args.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
12
+ import { normalizeProfile } from './utils/cli/normalize.mjs';
9
13
 
10
14
  async function ensureSwiftbarAssets({ cliRootDir }) {
11
15
  const homeDir = getHappyStacksHomeDir();
@@ -34,9 +38,20 @@ function openSwiftbarPluginsDir() {
34
38
  }
35
39
  }
36
40
 
37
- function removeSwiftbarPlugins() {
41
+ function sandboxPluginBasename() {
42
+ const sandboxDir = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
43
+ if (!sandboxDir) return '';
44
+ const hash = createHash('sha256').update(sandboxDir).digest('hex').slice(0, 10);
45
+ return `happy-stacks.sandbox-${hash}`;
46
+ }
47
+
48
+ function removeSwiftbarPlugins({ patterns }) {
49
+ const pats = (patterns ?? []).filter(Boolean);
50
+ const args = pats.length ? pats.map((p) => `"${p}"`).join(' ') : '"happy-stacks.*.sh" "happy-local.*.sh"';
38
51
  const s =
39
- 'DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; if [[ -d "$DIR" ]]; then rm -f "$DIR"/happy-stacks.*.sh "$DIR"/happy-local.*.sh 2>/dev/null || true; echo "$DIR"; else echo ""; fi';
52
+ `DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; ` +
53
+ `if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; ` +
54
+ `if [[ -d "$DIR" ]]; then rm -f "$DIR"/${args} 2>/dev/null || true; echo "$DIR"; else echo ""; fi`;
40
55
  const res = spawnSync('bash', ['-lc', s], { encoding: 'utf-8' });
41
56
  if (res.status !== 0) {
42
57
  return null;
@@ -55,16 +70,19 @@ async function main() {
55
70
  if (wantsHelp(argv, { flags }) || cmd === 'help') {
56
71
  printResult({
57
72
  json,
58
- data: { commands: ['install', 'uninstall', 'open'] },
73
+ data: { commands: ['install', 'uninstall', 'open', 'mode', 'status'] },
59
74
  text: [
60
75
  '[menubar] usage:',
61
76
  ' happys menubar install [--json]',
62
77
  ' happys menubar uninstall [--json]',
63
78
  ' happys menubar open [--json]',
79
+ ' happys menubar mode <selfhost|dev> [--json]',
80
+ ' happys menubar status [--json]',
64
81
  '',
65
82
  'notes:',
66
83
  ' - installs SwiftBar plugin into the active SwiftBar plugin folder',
67
- ' - keeps plugin source under ~/.happy-stacks/extras/swiftbar for stability',
84
+ ' - keeps plugin source under <homeDir>/extras/swiftbar for stability',
85
+ ' - sandbox mode: install/uninstall are disabled by default (set HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1 to override)',
68
86
  ].join('\n'),
69
87
  });
70
88
  return;
@@ -82,15 +100,63 @@ async function main() {
82
100
  }
83
101
 
84
102
  if (cmd === 'menubar:uninstall' || cmd === 'uninstall') {
85
- const dir = removeSwiftbarPlugins();
103
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
104
+ printResult({ json, data: { ok: true, skipped: 'sandbox' }, text: '[menubar] uninstall skipped (sandbox mode)' });
105
+ return;
106
+ }
107
+ const patterns = isSandboxed()
108
+ ? [`${sandboxPluginBasename()}.*.sh`]
109
+ : ['happy-stacks.*.sh', 'happy-local.*.sh'];
110
+ const dir = removeSwiftbarPlugins({ patterns });
86
111
  printResult({ json, data: { ok: true, pluginsDir: dir }, text: dir ? `[menubar] removed plugins from ${dir}` : '[menubar] no plugins dir found' });
87
112
  return;
88
113
  }
89
114
 
115
+ if (cmd === 'status') {
116
+ const mode = (process.env.HAPPY_STACKS_MENUBAR_MODE ?? process.env.HAPPY_LOCAL_MENUBAR_MODE ?? 'dev').trim() || 'dev';
117
+ printResult({ json, data: { ok: true, mode }, text: `[menubar] mode: ${mode}` });
118
+ return;
119
+ }
120
+
121
+ if (cmd === 'mode') {
122
+ const positionals = argv.filter((a) => !a.startsWith('--'));
123
+ const raw = positionals[1] ?? '';
124
+ const mode = normalizeProfile(raw);
125
+ if (!mode) {
126
+ throw new Error('[menubar] usage: happys menubar mode <selfhost|dev> [--json]');
127
+ }
128
+ await ensureEnvLocalUpdated({
129
+ rootDir: cliRootDir,
130
+ updates: [
131
+ { key: 'HAPPY_STACKS_MENUBAR_MODE', value: mode },
132
+ { key: 'HAPPY_LOCAL_MENUBAR_MODE', value: mode },
133
+ ],
134
+ });
135
+ printResult({ json, data: { ok: true, mode }, text: `[menubar] mode set: ${mode}` });
136
+ return;
137
+ }
138
+
90
139
  if (cmd === 'menubar:install' || cmd === 'install') {
140
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
141
+ throw new Error(
142
+ '[menubar] install is disabled in sandbox mode.\n' +
143
+ 'Reason: SwiftBar plugin installation writes to a global user folder.\n' +
144
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
145
+ );
146
+ }
91
147
  const { destDir } = await ensureSwiftbarAssets({ cliRootDir });
92
148
  const installer = join(destDir, 'install.sh');
93
- const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env: { ...process.env, HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir() } });
149
+ const env = {
150
+ ...process.env,
151
+ HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir(),
152
+ ...(isSandboxed()
153
+ ? {
154
+ HAPPY_STACKS_SWIFTBAR_PLUGIN_BASENAME: sandboxPluginBasename(),
155
+ HAPPY_STACKS_SWIFTBAR_PLUGIN_WRAPPER: '1',
156
+ }
157
+ : {}),
158
+ };
159
+ const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env });
94
160
  if (res.status !== 0) {
95
161
  process.exit(res.status ?? 1);
96
162
  }
@@ -1,16 +1,18 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { copyFile, mkdir, readFile } from 'node:fs/promises';
3
3
  import { basename, join } from 'node:path';
4
4
  import { createRequire } from 'node:module';
5
5
 
6
- import { parseArgs } from './utils/args.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
- import { parseDotenv } from './utils/dotenv.mjs';
9
- import { ensureEnvFileUpdated } from './utils/env_file.mjs';
10
- import { resolveStackEnvPath } from './utils/paths.mjs';
11
- import { ensureDepsInstalled } from './utils/pm.mjs';
12
- import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/happy_server_infra.mjs';
13
- import { runCapture } from './utils/proc.mjs';
6
+ import { parseArgs } from './utils/cli/args.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
+ import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
9
+ import { readEnvObjectFromFile } from './utils/env/read.mjs';
10
+ import { resolveStackEnvPath } from './utils/paths/paths.mjs';
11
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
12
+ import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/server/infra/happy_server_infra.mjs';
13
+ import { runCapture } from './utils/proc/proc.mjs';
14
+ import { pickNextFreeTcpPort } from './utils/net/ports.mjs';
15
+ import { getEnvValue } from './utils/env/values.mjs';
14
16
 
15
17
  function usage() {
16
18
  return [
@@ -24,18 +26,7 @@ function usage() {
24
26
  ].join('\n');
25
27
  }
26
28
 
27
- async function readEnvObject(envPath) {
28
- try {
29
- const raw = await readFile(envPath, 'utf-8');
30
- return Object.fromEntries(parseDotenv(raw).entries());
31
- } catch {
32
- return {};
33
- }
34
- }
35
-
36
- function getEnvValue(env, key) {
37
- return (env?.[key] ?? '').toString().trim();
38
- }
29
+ const readEnvObject = readEnvObjectFromFile;
39
30
 
40
31
  function parseFileDatabaseUrl(url) {
41
32
  const raw = String(url ?? '').trim();
@@ -97,9 +88,15 @@ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles,
97
88
  }
98
89
 
99
90
  const toPortRaw = getEnvValue(toEnv, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(toEnv, 'HAPPY_LOCAL_SERVER_PORT');
100
- const toPort = toPortRaw ? Number(toPortRaw) : NaN;
91
+ let toPort = toPortRaw ? Number(toPortRaw) : NaN;
92
+ const toEphemeral = !toPortRaw;
101
93
  if (!Number.isFinite(toPort) || toPort <= 0) {
102
- throw new Error(`[migrate] to-stack is missing a valid server port (HAPPY_STACKS_SERVER_PORT)`);
94
+ // Ephemeral-port stacks don't pin ports in env. Pick a free port for this one-off migration run.
95
+ toPort = await pickNextFreeTcpPort(3005);
96
+ if (!json) {
97
+ // eslint-disable-next-line no-console
98
+ console.log(`[migrate] to-stack has no pinned port; using ephemeral port ${toPort} for this migration run`);
99
+ }
103
100
  }
104
101
 
105
102
  // Ensure target secret is the same as source so auth tokens remain valid after migration.
@@ -111,17 +108,6 @@ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles,
111
108
  updates: [{ key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: targetSecretPath }],
112
109
  });
113
110
 
114
- // Bring up infra and ensure env vars are present.
115
- const infra = await ensureHappyServerManagedInfra({
116
- stackName: toStack,
117
- baseDir: to.baseDir,
118
- serverPort: toPort,
119
- publicServerUrl: `http://127.0.0.1:${toPort}`,
120
- envPath: to.envPath,
121
- env: process.env,
122
- });
123
- await applyHappyServerMigrations({ serverDir: fullDir, env: { ...process.env, ...infra.env } });
124
-
125
111
  // Resolve component dirs (prefer stack-pinned dirs).
126
112
  const lightDir = getEnvValue(fromEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT') || getEnvValue(fromEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT');
127
113
  const fullDir = getEnvValue(toEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER') || getEnvValue(toEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER');
@@ -132,6 +118,20 @@ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles,
132
118
  await ensureDepsInstalled(lightDir, 'happy-server-light');
133
119
  await ensureDepsInstalled(fullDir, 'happy-server');
134
120
 
121
+ // Bring up infra and ensure env vars are present.
122
+ const infra = await ensureHappyServerManagedInfra({
123
+ stackName: toStack,
124
+ baseDir: to.baseDir,
125
+ serverPort: toPort,
126
+ publicServerUrl: `http://127.0.0.1:${toPort}`,
127
+ envPath: to.envPath,
128
+ env: {
129
+ ...process.env,
130
+ ...(toEphemeral ? { HAPPY_STACKS_EPHEMERAL_PORTS: '1', HAPPY_LOCAL_EPHEMERAL_PORTS: '1' } : {}),
131
+ },
132
+ });
133
+ await applyHappyServerMigrations({ serverDir: fullDir, env: { ...process.env, ...infra.env } });
134
+
135
135
  // Copy sqlite DB to a snapshot so migration is consistent even if the source server is running.
136
136
  const snapshotDir = join(to.baseDir, 'migrations');
137
137
  await mkdir(snapshotDir, { recursive: true });
@@ -280,7 +280,7 @@ async function main() {
280
280
  throw new Error('[migrate] --to-stack is required');
281
281
  }
282
282
 
283
- const rootDir = (await import('./utils/paths.mjs')).getRootDir(import.meta.url);
283
+ const rootDir = (await import('./utils/paths/paths.mjs')).getRootDir(import.meta.url);
284
284
  await migrateLightToServer({ rootDir, fromStack, toStack, includeFiles, force, json });
285
285
  }
286
286