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/happy.mjs CHANGED
@@ -1,16 +1,12 @@
1
1
  import './utils/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';
5
+ import { parseArgs } from './utils/cli/args.mjs';
6
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
7
+ import { expandHome } from './utils/canonical_home.mjs';
8
8
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
9
9
 
10
- function expandHome(p) {
11
- return p.replace(/^~(?=\/)/, homedir());
12
- }
13
-
14
10
  function resolveCliHomeDir() {
15
11
  const fromExplicit = (process.env.HAPPY_HOME_DIR ?? '').trim();
16
12
  if (fromExplicit) {
package/scripts/init.mjs CHANGED
@@ -6,10 +6,8 @@ import { fileURLToPath } from 'node:url';
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/config.mjs';
8
8
  import { parseDotenv } from './utils/dotenv.mjs';
9
-
10
- function expandHome(p) {
11
- return p.replace(/^~(?=\/)/, homedir());
12
- }
9
+ import { expandHome } from './utils/canonical_home.mjs';
10
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
13
11
 
14
12
  async function readJsonIfExists(path) {
15
13
  try {
@@ -139,15 +137,15 @@ async function main() {
139
137
  if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
140
138
  console.log([
141
139
  '[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...]',
140
+ ' 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
141
  '',
144
142
  '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)',
143
+ ' - writes <canonicalHomeDir>/.env (stable pointer file; default: ~/.happy-stacks/.env)',
144
+ ' - default workspace: <homeDir>/workspace',
145
+ ' - default runtime: <homeDir>/runtime (recommended for services/SwiftBar)',
148
146
  ' - runtime install is skipped if the same version is already installed (use --force-runtime to reinstall)',
149
147
  ' - 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)',
148
+ ' - optional: --install-path adds <homeDir>/bin to your shell PATH (idempotent)',
151
149
  ' - by default, runs `happys bootstrap --interactive` at the end (TTY only) IF components are not already present',
152
150
  ].join('\n'));
153
151
  return;
@@ -159,7 +157,17 @@ async function main() {
159
157
  //
160
158
  // Other scripts load this pointer via `scripts/utils/env.mjs`, but `init.mjs` is often run before
161
159
  // anything else (or directly from a repo checkout). So we load it here too.
162
- const canonicalEnvPath = join(homedir(), '.happy-stacks', '.env');
160
+ const canonicalHomeDirRaw = parseArgValue(argv, 'canonical-home-dir');
161
+ const canonicalHomeDir = expandHome(firstNonEmpty(
162
+ canonicalHomeDirRaw,
163
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR,
164
+ process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR,
165
+ join(homedir(), '.happy-stacks'),
166
+ ));
167
+ process.env.HAPPY_STACKS_CANONICAL_HOME_DIR = canonicalHomeDir;
168
+ process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR = process.env.HAPPY_LOCAL_CANONICAL_HOME_DIR ?? canonicalHomeDir;
169
+
170
+ const canonicalEnvPath = join(canonicalHomeDir, '.env');
163
171
  if (existsSync(canonicalEnvPath)) {
164
172
  await loadEnvFile(canonicalEnvPath, { override: false });
165
173
  await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
@@ -245,6 +253,7 @@ async function main() {
245
253
  const nodePath = process.execPath;
246
254
 
247
255
  await mkdir(homeDir, { recursive: true });
256
+ await mkdir(canonicalHomeDir, { recursive: true });
248
257
  await mkdir(workspaceDir, { recursive: true });
249
258
  await mkdir(join(workspaceDir, 'components'), { recursive: true });
250
259
  await mkdir(runtimeDir, { recursive: true });
@@ -306,10 +315,10 @@ async function main() {
306
315
  const shim = [
307
316
  '#!/bin/bash',
308
317
  'set -euo pipefail',
309
- 'CANONICAL_ENV="$HOME/.happy-stacks/.env"',
318
+ `CANONICAL_ENV="${canonicalEnvPath}"`,
310
319
  '',
311
320
  '# 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.',
321
+ '# read the stable pointer file at CANONICAL_ENV to discover the real dirs.',
313
322
  'if [[ -f "$CANONICAL_ENV" ]]; then',
314
323
  ' if [[ -z "${HAPPY_STACKS_HOME_DIR:-}" ]]; then',
315
324
  ' HAPPY_STACKS_HOME_DIR="$(grep -E \'^HAPPY_STACKS_HOME_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_HOME_DIR=//\')" || true',
@@ -333,7 +342,7 @@ async function main() {
333
342
  ' fi',
334
343
  'fi',
335
344
  '',
336
- 'HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"',
345
+ `HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-${canonicalHomeDir}}"`,
337
346
  'ENV_FILE="$HOME_DIR/.env"',
338
347
  'WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$HOME_DIR/workspace}"',
339
348
  'if [[ -d "$WORKDIR" ]]; then',
@@ -370,22 +379,31 @@ async function main() {
370
379
  await writeExecutable(happysShimPath, shim);
371
380
  await writeExecutable(happyShimPath, `#!/bin/bash\nset -euo pipefail\nexec \"${happysShimPath}\" happy \"$@\"\n`);
372
381
 
382
+ let didInstallPath = false;
373
383
  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}`);
384
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
385
+ console.log('[init] sandbox mode: skipping --install-path (would modify your shell config)');
386
+ console.log('[init] tip: set HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1 if you really want to test PATH modifications');
377
387
  } else {
378
- console.log(`[init] PATH already configured in ${res.path}`);
388
+ const res = await ensurePathInstalled({ homeDir });
389
+ didInstallPath = true;
390
+ if (res.updated) {
391
+ console.log(`[init] added ${homeDir}/bin to PATH via ${res.path}`);
392
+ } else {
393
+ console.log(`[init] PATH already configured in ${res.path}`);
394
+ }
379
395
  }
380
396
  }
381
397
 
398
+ const invokedBySetup = (process.env.HAPPY_STACKS_SETUP_CHILD ?? '').trim() === '1';
399
+
382
400
  console.log('[init] complete');
383
401
  console.log(`[init] home: ${homeDir}`);
384
402
  console.log(`[init] workspace: ${workspaceDir}`);
385
403
  console.log(`[init] shims: ${homeDir}/bin`);
386
404
  console.log('');
387
405
 
388
- if (!argv.includes('--install-path')) {
406
+ if (!argv.includes('--install-path') || !didInstallPath) {
389
407
  console.log('[init] note: to use `happys` / `happy` from any terminal, add shims to PATH:');
390
408
  console.log(` export PATH="${homeDir}/bin:$PATH"`);
391
409
  console.log(' (or re-run: happys init --install-path)');
@@ -422,13 +440,18 @@ async function main() {
422
440
 
423
441
  if (wantBootstrap && alreadyBootstrapped && !bootstrapExplicit) {
424
442
  console.log('[init] bootstrap: already set up; skipping');
425
- console.log('[init] tip: to re-run setup: happys bootstrap --interactive');
443
+ console.log('[init] tip: for guided onboarding: happys setup');
426
444
  console.log('');
427
445
  }
428
446
 
447
+ // When `happys setup` drives init, avoid printing confusing “next steps”.
448
+ if (invokedBySetup) {
449
+ return;
450
+ }
451
+
429
452
  console.log('[init] next steps:');
430
453
  console.log(` export PATH=\"${homeDir}/bin:$PATH\"`);
431
- console.log(' happys bootstrap --interactive');
454
+ console.log(' happys setup');
432
455
  }
433
456
 
434
457
  main().catch((err) => {
@@ -1,5 +1,5 @@
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 { run } from './utils/proc.mjs';
5
5
  import { getComponentDir, getRootDir } from './utils/paths.mjs';
@@ -8,15 +8,16 @@ import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } fro
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';
11
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
12
12
  import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
13
- import { isTty, prompt, promptSelect, withRl } from './utils/wizard.mjs';
13
+ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/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,145 @@
1
+ import './utils/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.mjs';
5
+ import { ensureDepsInstalled, requirePnpm } from './utils/pm.mjs';
6
+ import { pathExists } from './utils/fs.mjs';
7
+ import { run } from './utils/proc.mjs';
8
+ import { join } from 'node:path';
9
+ import { readFile } from 'node:fs/promises';
10
+
11
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
12
+
13
+ async function detectPackageManagerCmd(dir) {
14
+ if (await pathExists(join(dir, 'yarn.lock'))) {
15
+ return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
16
+ }
17
+ await requirePnpm();
18
+ return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
19
+ }
20
+
21
+ async function readScripts(dir) {
22
+ try {
23
+ const raw = await readFile(join(dir, 'package.json'), 'utf-8');
24
+ const pkg = JSON.parse(raw);
25
+ const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
26
+ return scripts;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function pickLintScript(scripts) {
33
+ if (!scripts) return null;
34
+ const candidates = [
35
+ 'lint',
36
+ 'lint:ci',
37
+ 'check',
38
+ 'check:lint',
39
+ 'eslint',
40
+ 'eslint:check',
41
+ ];
42
+ return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
43
+ }
44
+
45
+ async function main() {
46
+ const argv = process.argv.slice(2);
47
+ const { flags } = parseArgs(argv);
48
+ const json = wantsJson(argv, { flags });
49
+
50
+ if (wantsHelp(argv, { flags })) {
51
+ printResult({
52
+ json,
53
+ data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
54
+ text: [
55
+ '[lint] usage:',
56
+ ' happys lint [component...] [--json]',
57
+ '',
58
+ 'components:',
59
+ ` ${DEFAULT_COMPONENTS.join(' | ')}`,
60
+ '',
61
+ 'examples:',
62
+ ' happys lint',
63
+ ' happys lint happy happy-cli',
64
+ ].join('\n'),
65
+ });
66
+ return;
67
+ }
68
+
69
+ const positionals = argv.filter((a) => !a.startsWith('--'));
70
+ const requested = positionals.length ? positionals : ['all'];
71
+ const wantAll = requested.includes('all');
72
+ const components = wantAll ? DEFAULT_COMPONENTS : requested;
73
+
74
+ const rootDir = getRootDir(import.meta.url);
75
+
76
+ const results = [];
77
+ for (const component of components) {
78
+ if (!DEFAULT_COMPONENTS.includes(component)) {
79
+ results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${DEFAULT_COMPONENTS.join(', ')})` });
80
+ continue;
81
+ }
82
+
83
+ const dir = getComponentDir(rootDir, component);
84
+ if (!(await pathExists(dir))) {
85
+ results.push({ component, ok: false, skipped: false, dir, error: `missing component dir: ${dir}` });
86
+ continue;
87
+ }
88
+
89
+ const scripts = await readScripts(dir);
90
+ if (!scripts) {
91
+ results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
92
+ continue;
93
+ }
94
+
95
+ const script = pickLintScript(scripts);
96
+ if (!script) {
97
+ results.push({ component, ok: true, skipped: true, dir, reason: 'no lint script found in package.json' });
98
+ continue;
99
+ }
100
+
101
+ await ensureDepsInstalled(dir, component);
102
+ const pm = await detectPackageManagerCmd(dir);
103
+
104
+ try {
105
+ // eslint-disable-next-line no-console
106
+ console.log(`[lint] ${component}: running ${pm.name} ${script}`);
107
+ await run(pm.cmd, pm.argsForScript(script), { cwd: dir, env: process.env });
108
+ results.push({ component, ok: true, skipped: false, dir, pm: pm.name, script });
109
+ } catch (e) {
110
+ results.push({ component, ok: false, skipped: false, dir, pm: pm.name, script, error: String(e?.message ?? e) });
111
+ }
112
+ }
113
+
114
+ const ok = results.every((r) => r.ok);
115
+ if (json) {
116
+ printResult({ json, data: { ok, results } });
117
+ return;
118
+ }
119
+
120
+ const lines = ['[lint] results:'];
121
+ for (const r of results) {
122
+ if (r.ok && r.skipped) {
123
+ lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
124
+ } else if (r.ok) {
125
+ lines.push(`- ✅ ${r.component}: ok (${r.pm} ${r.script})`);
126
+ } else {
127
+ lines.push(`- ❌ ${r.component}: failed (${r.pm ?? 'unknown'} ${r.script ?? ''})`);
128
+ if (r.error) lines.push(` - ${r.error}`);
129
+ }
130
+ }
131
+ if (!ok) {
132
+ lines.push('');
133
+ lines.push('[lint] failed');
134
+ }
135
+ printResult({ json: false, text: lines.join('\n') });
136
+ if (!ok) {
137
+ process.exit(1);
138
+ }
139
+ }
140
+
141
+ main().catch((err) => {
142
+ console.error('[lint] failed:', err);
143
+ process.exit(1);
144
+ });
145
+
@@ -3,9 +3,12 @@ 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 { createHash } from 'node:crypto';
6
7
  import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
7
- import { parseArgs } from './utils/args.mjs';
8
- import { printResult, wantsHelp, wantsJson } from './utils/cli.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_local.mjs';
11
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
9
12
 
10
13
  async function ensureSwiftbarAssets({ cliRootDir }) {
11
14
  const homeDir = getHappyStacksHomeDir();
@@ -34,9 +37,20 @@ function openSwiftbarPluginsDir() {
34
37
  }
35
38
  }
36
39
 
37
- function removeSwiftbarPlugins() {
40
+ function sandboxPluginBasename() {
41
+ const sandboxDir = (process.env.HAPPY_STACKS_SANDBOX_DIR ?? '').trim();
42
+ if (!sandboxDir) return '';
43
+ const hash = createHash('sha256').update(sandboxDir).digest('hex').slice(0, 10);
44
+ return `happy-stacks.sandbox-${hash}`;
45
+ }
46
+
47
+ function removeSwiftbarPlugins({ patterns }) {
48
+ const pats = (patterns ?? []).filter(Boolean);
49
+ const args = pats.length ? pats.map((p) => `"${p}"`).join(' ') : '"happy-stacks.*.sh" "happy-local.*.sh"';
38
50
  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';
51
+ `DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; ` +
52
+ `if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; ` +
53
+ `if [[ -d "$DIR" ]]; then rm -f "$DIR"/${args} 2>/dev/null || true; echo "$DIR"; else echo ""; fi`;
40
54
  const res = spawnSync('bash', ['-lc', s], { encoding: 'utf-8' });
41
55
  if (res.status !== 0) {
42
56
  return null;
@@ -45,6 +59,14 @@ function removeSwiftbarPlugins() {
45
59
  return out || null;
46
60
  }
47
61
 
62
+ function normalizeMenubarMode(raw) {
63
+ const v = String(raw ?? '').trim().toLowerCase();
64
+ if (!v) return '';
65
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host' || v === 'host') return 'selfhost';
66
+ if (v === 'dev' || v === 'developer') return 'dev';
67
+ return '';
68
+ }
69
+
48
70
  async function main() {
49
71
  const rawArgv = process.argv.slice(2);
50
72
  const argv = rawArgv[0] === 'menubar' ? rawArgv.slice(1) : rawArgv;
@@ -55,16 +77,19 @@ async function main() {
55
77
  if (wantsHelp(argv, { flags }) || cmd === 'help') {
56
78
  printResult({
57
79
  json,
58
- data: { commands: ['install', 'uninstall', 'open'] },
80
+ data: { commands: ['install', 'uninstall', 'open', 'mode', 'status'] },
59
81
  text: [
60
82
  '[menubar] usage:',
61
83
  ' happys menubar install [--json]',
62
84
  ' happys menubar uninstall [--json]',
63
85
  ' happys menubar open [--json]',
86
+ ' happys menubar mode <selfhost|dev> [--json]',
87
+ ' happys menubar status [--json]',
64
88
  '',
65
89
  'notes:',
66
90
  ' - installs SwiftBar plugin into the active SwiftBar plugin folder',
67
- ' - keeps plugin source under ~/.happy-stacks/extras/swiftbar for stability',
91
+ ' - keeps plugin source under <homeDir>/extras/swiftbar for stability',
92
+ ' - sandbox mode: install/uninstall are disabled by default (set HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1 to override)',
68
93
  ].join('\n'),
69
94
  });
70
95
  return;
@@ -82,15 +107,63 @@ async function main() {
82
107
  }
83
108
 
84
109
  if (cmd === 'menubar:uninstall' || cmd === 'uninstall') {
85
- const dir = removeSwiftbarPlugins();
110
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
111
+ printResult({ json, data: { ok: true, skipped: 'sandbox' }, text: '[menubar] uninstall skipped (sandbox mode)' });
112
+ return;
113
+ }
114
+ const patterns = isSandboxed()
115
+ ? [`${sandboxPluginBasename()}.*.sh`]
116
+ : ['happy-stacks.*.sh', 'happy-local.*.sh'];
117
+ const dir = removeSwiftbarPlugins({ patterns });
86
118
  printResult({ json, data: { ok: true, pluginsDir: dir }, text: dir ? `[menubar] removed plugins from ${dir}` : '[menubar] no plugins dir found' });
87
119
  return;
88
120
  }
89
121
 
122
+ if (cmd === 'status') {
123
+ const mode = (process.env.HAPPY_STACKS_MENUBAR_MODE ?? process.env.HAPPY_LOCAL_MENUBAR_MODE ?? 'dev').trim() || 'dev';
124
+ printResult({ json, data: { ok: true, mode }, text: `[menubar] mode: ${mode}` });
125
+ return;
126
+ }
127
+
128
+ if (cmd === 'mode') {
129
+ const positionals = argv.filter((a) => !a.startsWith('--'));
130
+ const raw = positionals[1] ?? '';
131
+ const mode = normalizeMenubarMode(raw);
132
+ if (!mode) {
133
+ throw new Error('[menubar] usage: happys menubar mode <selfhost|dev> [--json]');
134
+ }
135
+ await ensureEnvLocalUpdated({
136
+ rootDir: cliRootDir,
137
+ updates: [
138
+ { key: 'HAPPY_STACKS_MENUBAR_MODE', value: mode },
139
+ { key: 'HAPPY_LOCAL_MENUBAR_MODE', value: mode },
140
+ ],
141
+ });
142
+ printResult({ json, data: { ok: true, mode }, text: `[menubar] mode set: ${mode}` });
143
+ return;
144
+ }
145
+
90
146
  if (cmd === 'menubar:install' || cmd === 'install') {
147
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
148
+ throw new Error(
149
+ '[menubar] install is disabled in sandbox mode.\n' +
150
+ 'Reason: SwiftBar plugin installation writes to a global user folder.\n' +
151
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
152
+ );
153
+ }
91
154
  const { destDir } = await ensureSwiftbarAssets({ cliRootDir });
92
155
  const installer = join(destDir, 'install.sh');
93
- const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env: { ...process.env, HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir() } });
156
+ const env = {
157
+ ...process.env,
158
+ HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir(),
159
+ ...(isSandboxed()
160
+ ? {
161
+ HAPPY_STACKS_SWIFTBAR_PLUGIN_BASENAME: sandboxPluginBasename(),
162
+ HAPPY_STACKS_SWIFTBAR_PLUGIN_WRAPPER: '1',
163
+ }
164
+ : {}),
165
+ };
166
+ const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env });
94
167
  if (res.status !== 0) {
95
168
  process.exit(res.status ?? 1);
96
169
  }
@@ -3,14 +3,15 @@ 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';
6
+ import { parseArgs } from './utils/cli/args.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
8
  import { parseDotenv } from './utils/dotenv.mjs';
9
9
  import { ensureEnvFileUpdated } from './utils/env_file.mjs';
10
10
  import { resolveStackEnvPath } from './utils/paths.mjs';
11
11
  import { ensureDepsInstalled } from './utils/pm.mjs';
12
12
  import { ensureHappyServerManagedInfra, applyHappyServerMigrations } from './utils/happy_server_infra.mjs';
13
13
  import { runCapture } from './utils/proc.mjs';
14
+ import { pickNextFreeTcpPort } from './utils/ports.mjs';
14
15
 
15
16
  function usage() {
16
17
  return [
@@ -97,9 +98,15 @@ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles,
97
98
  }
98
99
 
99
100
  const toPortRaw = getEnvValue(toEnv, 'HAPPY_STACKS_SERVER_PORT') || getEnvValue(toEnv, 'HAPPY_LOCAL_SERVER_PORT');
100
- const toPort = toPortRaw ? Number(toPortRaw) : NaN;
101
+ let toPort = toPortRaw ? Number(toPortRaw) : NaN;
102
+ const toEphemeral = !toPortRaw;
101
103
  if (!Number.isFinite(toPort) || toPort <= 0) {
102
- throw new Error(`[migrate] to-stack is missing a valid server port (HAPPY_STACKS_SERVER_PORT)`);
104
+ // Ephemeral-port stacks don't pin ports in env. Pick a free port for this one-off migration run.
105
+ toPort = await pickNextFreeTcpPort(3005);
106
+ if (!json) {
107
+ // eslint-disable-next-line no-console
108
+ console.log(`[migrate] to-stack has no pinned port; using ephemeral port ${toPort} for this migration run`);
109
+ }
103
110
  }
104
111
 
105
112
  // Ensure target secret is the same as source so auth tokens remain valid after migration.
@@ -111,17 +118,6 @@ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles,
111
118
  updates: [{ key: 'HAPPY_STACKS_HANDY_MASTER_SECRET_FILE', value: targetSecretPath }],
112
119
  });
113
120
 
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
121
  // Resolve component dirs (prefer stack-pinned dirs).
126
122
  const lightDir = getEnvValue(fromEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT') || getEnvValue(fromEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT');
127
123
  const fullDir = getEnvValue(toEnv, 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER') || getEnvValue(toEnv, 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER');
@@ -132,6 +128,20 @@ async function migrateLightToServer({ rootDir, fromStack, toStack, includeFiles,
132
128
  await ensureDepsInstalled(lightDir, 'happy-server-light');
133
129
  await ensureDepsInstalled(fullDir, 'happy-server');
134
130
 
131
+ // Bring up infra and ensure env vars are present.
132
+ const infra = await ensureHappyServerManagedInfra({
133
+ stackName: toStack,
134
+ baseDir: to.baseDir,
135
+ serverPort: toPort,
136
+ publicServerUrl: `http://127.0.0.1:${toPort}`,
137
+ envPath: to.envPath,
138
+ env: {
139
+ ...process.env,
140
+ ...(toEphemeral ? { HAPPY_STACKS_EPHEMERAL_PORTS: '1', HAPPY_LOCAL_EPHEMERAL_PORTS: '1' } : {}),
141
+ },
142
+ });
143
+ await applyHappyServerMigrations({ serverDir: fullDir, env: { ...process.env, ...infra.env } });
144
+
135
145
  // Copy sqlite DB to a snapshot so migration is consistent even if the source server is running.
136
146
  const snapshotDir = join(to.baseDir, 'migrations');
137
147
  await mkdir(snapshotDir, { recursive: true });
@@ -1,11 +1,12 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { killPortListeners, pickNextFreeTcpPort } from './utils/ports.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { pickNextFreeTcpPort } from './utils/ports.mjs';
4
4
  import { run, runCapture, spawnProc } from './utils/proc.mjs';
5
5
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
6
6
  import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/pm.mjs';
7
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
7
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
8
  import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo.mjs';
9
+ import { killProcessGroupOwnedByStack } from './utils/ownership.mjs';
9
10
 
10
11
  /**
11
12
  * Mobile dev helper for the embedded `components/happy` Expo app.
@@ -306,11 +307,16 @@ async function main() {
306
307
  }
307
308
  if (restart && running.state?.pid) {
308
309
  const prevPid = Number(running.state.pid);
309
- const prevPort = Number(running.state.port);
310
- if (Number.isFinite(prevPort) && prevPort > 0) {
311
- await killPortListeners(prevPort, { label: 'expo' });
310
+ const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || autostart.stackName;
311
+ const envPath = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
312
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-mobile', json: true });
313
+ if (!res.killed) {
314
+ // eslint-disable-next-line no-console
315
+ console.warn(
316
+ `[mobile] not stopping existing Metro pid=${prevPid} because it does not look stack-owned.\n` +
317
+ `[mobile] continuing by starting a new Metro on a free port.`
318
+ );
312
319
  }
313
- await killPid(prevPid);
314
320
  }
315
321
 
316
322
  const requestedPort = Number.parseInt(String(portRaw), 10);