happy-stacks 0.1.0 → 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 (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  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/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -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/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /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
@@ -1,12 +1,21 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { mkdir, writeFile, readFile } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { spawnSync } from 'node:child_process';
6
- import { ensureHomeEnvUpdated } from './utils/config.mjs';
7
-
8
- function expandHome(p) {
9
- return p.replace(/^~(?=\/)/, homedir());
7
+ import { ensureCanonicalHomeEnvUpdated, ensureHomeEnvUpdated } from './utils/config.mjs';
8
+ import { parseDotenv } from './utils/dotenv.mjs';
9
+ import { expandHome } from './utils/canonical_home.mjs';
10
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
11
+
12
+ async function readJsonIfExists(path) {
13
+ try {
14
+ const raw = await readFile(path, 'utf-8');
15
+ return JSON.parse(raw);
16
+ } catch {
17
+ return null;
18
+ }
10
19
  }
11
20
 
12
21
  function getCliRootDir() {
@@ -22,6 +31,47 @@ function parseArgValue(argv, key) {
22
31
  return null;
23
32
  }
24
33
 
34
+ function firstNonEmpty(...values) {
35
+ for (const v of values) {
36
+ const s = (v ?? '').trim();
37
+ if (s) return s;
38
+ }
39
+ return '';
40
+ }
41
+
42
+ async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
43
+ try {
44
+ const contents = await readFile(path, 'utf-8');
45
+ const parsed = parseDotenv(contents);
46
+ for (const [k, v] of parsed.entries()) {
47
+ const allowOverride = override && (!overridePrefix || k.startsWith(overridePrefix));
48
+ if (allowOverride || process.env[k] == null || process.env[k] === '') {
49
+ process.env[k] = v;
50
+ }
51
+ }
52
+ } catch {
53
+ // ignore missing/invalid env file
54
+ }
55
+ }
56
+
57
+ function isWorkspaceBootstrapped(workspaceDir) {
58
+ // Heuristic: if the expected component repos exist in the workspace, we consider bootstrap "already done"
59
+ // and avoid re-running the interactive bootstrap wizard from `happys init`.
60
+ //
61
+ // Users can always re-run bootstrap explicitly:
62
+ // happys bootstrap --interactive
63
+ try {
64
+ const componentsDir = join(workspaceDir, 'components');
65
+ const ui = join(componentsDir, 'happy', 'package.json');
66
+ const cli = join(componentsDir, 'happy-cli', 'package.json');
67
+ const serverLight = join(componentsDir, 'happy-server-light', 'package.json');
68
+ const serverFull = join(componentsDir, 'happy-server', 'package.json');
69
+ return existsSync(ui) && existsSync(cli) && (existsSync(serverLight) || existsSync(serverFull));
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
25
75
  async function writeExecutable(path, contents) {
26
76
  await writeFile(path, contents, { mode: 0o755 });
27
77
  }
@@ -87,64 +137,175 @@ async function main() {
87
137
  if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
88
138
  console.log([
89
139
  '[init] usage:',
90
- ' happys init [--home-dir=/path] [--workspace-dir=/path] [--runtime-dir=/path] [--install-path] [--no-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...]',
91
141
  '',
92
142
  'notes:',
93
- ' - writes ~/.happy-stacks/.env (stable pointer file)',
94
- ' - default workspace: ~/.happy-stacks/workspace',
95
- ' - default runtime: ~/.happy-stacks/runtime (recommended for services/SwiftBar)',
96
- ' - optional: --install-path adds ~/.happy-stacks/bin to your shell PATH (idempotent)',
97
- ' - by default, runs `happys bootstrap --interactive` at the end (TTY only)',
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)',
146
+ ' - runtime install is skipped if the same version is already installed (use --force-runtime to reinstall)',
147
+ ' - set HAPPY_STACKS_INIT_NO_RUNTIME=1 to persist skipping runtime installs on this machine',
148
+ ' - optional: --install-path adds <homeDir>/bin to your shell PATH (idempotent)',
149
+ ' - by default, runs `happys bootstrap --interactive` at the end (TTY only) IF components are not already present',
98
150
  ].join('\n'));
99
151
  return;
100
152
  }
101
153
 
102
154
  const cliRootDir = getCliRootDir();
103
155
 
156
+ // Important: `happys init` must be idempotent and must not "forget" custom dirs from a prior install.
157
+ //
158
+ // Other scripts load this pointer via `scripts/utils/env.mjs`, but `init.mjs` is often run before
159
+ // anything else (or directly from a repo checkout). So we load it here too.
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');
171
+ if (existsSync(canonicalEnvPath)) {
172
+ await loadEnvFile(canonicalEnvPath, { override: false });
173
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_STACKS_' });
174
+ await loadEnvFile(canonicalEnvPath, { override: true, overridePrefix: 'HAPPY_LOCAL_' });
175
+ }
176
+
104
177
  const homeDirRaw = parseArgValue(argv, 'home-dir');
105
- const homeDir = expandHome((homeDirRaw ?? '').trim() || (process.env.HAPPY_STACKS_HOME_DIR ?? '').trim() || join(homedir(), '.happy-stacks'));
178
+ const homeDir = expandHome(firstNonEmpty(
179
+ homeDirRaw,
180
+ process.env.HAPPY_STACKS_HOME_DIR,
181
+ process.env.HAPPY_LOCAL_HOME_DIR,
182
+ join(homedir(), '.happy-stacks'),
183
+ ));
106
184
  process.env.HAPPY_STACKS_HOME_DIR = homeDir;
185
+ process.env.HAPPY_LOCAL_HOME_DIR = process.env.HAPPY_LOCAL_HOME_DIR ?? homeDir;
107
186
 
108
187
  const workspaceDirRaw = parseArgValue(argv, 'workspace-dir');
109
- const workspaceDir = expandHome((workspaceDirRaw ?? '').trim() || join(homeDir, 'workspace'));
110
- process.env.HAPPY_STACKS_WORKSPACE_DIR = process.env.HAPPY_STACKS_WORKSPACE_DIR ?? workspaceDir;
188
+ const workspaceDir = expandHome(firstNonEmpty(
189
+ workspaceDirRaw,
190
+ process.env.HAPPY_STACKS_WORKSPACE_DIR,
191
+ process.env.HAPPY_LOCAL_WORKSPACE_DIR,
192
+ join(homeDir, 'workspace'),
193
+ ));
194
+ process.env.HAPPY_STACKS_WORKSPACE_DIR = workspaceDir;
195
+ process.env.HAPPY_LOCAL_WORKSPACE_DIR = process.env.HAPPY_LOCAL_WORKSPACE_DIR ?? workspaceDir;
111
196
 
112
197
  const runtimeDirRaw = parseArgValue(argv, 'runtime-dir');
113
- const runtimeDir = expandHome((runtimeDirRaw ?? '').trim() || (process.env.HAPPY_STACKS_RUNTIME_DIR ?? '').trim() || join(homeDir, 'runtime'));
114
- process.env.HAPPY_STACKS_RUNTIME_DIR = process.env.HAPPY_STACKS_RUNTIME_DIR ?? runtimeDir;
198
+ const runtimeDir = expandHome(firstNonEmpty(
199
+ runtimeDirRaw,
200
+ process.env.HAPPY_STACKS_RUNTIME_DIR,
201
+ process.env.HAPPY_LOCAL_RUNTIME_DIR,
202
+ join(homeDir, 'runtime'),
203
+ ));
204
+ process.env.HAPPY_STACKS_RUNTIME_DIR = runtimeDir;
205
+ process.env.HAPPY_LOCAL_RUNTIME_DIR = process.env.HAPPY_LOCAL_RUNTIME_DIR ?? runtimeDir;
206
+
207
+ const storageDirRaw = parseArgValue(argv, 'storage-dir');
208
+ const storageDirOverride = expandHome((storageDirRaw ?? '').trim());
209
+ if (storageDirOverride) {
210
+ process.env.HAPPY_STACKS_STORAGE_DIR = process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride;
211
+ }
212
+
213
+ const cliRootDirRaw = parseArgValue(argv, 'cli-root-dir');
214
+ const cliRootDirOverride = expandHome((cliRootDirRaw ?? '').trim());
215
+ if (cliRootDirOverride) {
216
+ process.env.HAPPY_STACKS_CLI_ROOT_DIR = process.env.HAPPY_STACKS_CLI_ROOT_DIR ?? cliRootDirOverride;
217
+ }
218
+
219
+ const tailscaleBinRaw = parseArgValue(argv, 'tailscale-bin');
220
+ const tailscaleBinOverride = expandHome((tailscaleBinRaw ?? '').trim());
221
+ if (tailscaleBinOverride) {
222
+ process.env.HAPPY_STACKS_TAILSCALE_BIN = process.env.HAPPY_STACKS_TAILSCALE_BIN ?? tailscaleBinOverride;
223
+ }
224
+
225
+ const tailscaleCmdTimeoutMsRaw = parseArgValue(argv, 'tailscale-cmd-timeout-ms');
226
+ const tailscaleCmdTimeoutMsOverride = (tailscaleCmdTimeoutMsRaw ?? '').trim();
227
+ if (tailscaleCmdTimeoutMsOverride) {
228
+ process.env.HAPPY_STACKS_TAILSCALE_CMD_TIMEOUT_MS =
229
+ process.env.HAPPY_STACKS_TAILSCALE_CMD_TIMEOUT_MS ?? tailscaleCmdTimeoutMsOverride;
230
+ }
231
+
232
+ const tailscaleEnableTimeoutMsRaw = parseArgValue(argv, 'tailscale-enable-timeout-ms');
233
+ const tailscaleEnableTimeoutMsOverride = (tailscaleEnableTimeoutMsRaw ?? '').trim();
234
+ if (tailscaleEnableTimeoutMsOverride) {
235
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS =
236
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS ?? tailscaleEnableTimeoutMsOverride;
237
+ }
238
+
239
+ const tailscaleEnableTimeoutMsAutoRaw = parseArgValue(argv, 'tailscale-enable-timeout-ms-auto');
240
+ const tailscaleEnableTimeoutMsAutoOverride = (tailscaleEnableTimeoutMsAutoRaw ?? '').trim();
241
+ if (tailscaleEnableTimeoutMsAutoOverride) {
242
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS_AUTO =
243
+ process.env.HAPPY_STACKS_TAILSCALE_ENABLE_TIMEOUT_MS_AUTO ?? tailscaleEnableTimeoutMsAutoOverride;
244
+ }
245
+
246
+ const tailscaleResetTimeoutMsRaw = parseArgValue(argv, 'tailscale-reset-timeout-ms');
247
+ const tailscaleResetTimeoutMsOverride = (tailscaleResetTimeoutMsRaw ?? '').trim();
248
+ if (tailscaleResetTimeoutMsOverride) {
249
+ process.env.HAPPY_STACKS_TAILSCALE_RESET_TIMEOUT_MS =
250
+ process.env.HAPPY_STACKS_TAILSCALE_RESET_TIMEOUT_MS ?? tailscaleResetTimeoutMsOverride;
251
+ }
115
252
 
116
253
  const nodePath = process.execPath;
117
254
 
118
255
  await mkdir(homeDir, { recursive: true });
256
+ await mkdir(canonicalHomeDir, { recursive: true });
119
257
  await mkdir(workspaceDir, { recursive: true });
120
258
  await mkdir(join(workspaceDir, 'components'), { recursive: true });
121
259
  await mkdir(runtimeDir, { recursive: true });
122
260
  await mkdir(join(homeDir, 'bin'), { recursive: true });
123
261
 
124
- await ensureHomeEnvUpdated({
125
- updates: [
126
- { key: 'HAPPY_STACKS_HOME_DIR', value: homeDir },
127
- { key: 'HAPPY_STACKS_WORKSPACE_DIR', value: workspaceDir },
128
- { key: 'HAPPY_STACKS_RUNTIME_DIR', value: runtimeDir },
129
- { key: 'HAPPY_STACKS_NODE', value: nodePath },
130
- ],
131
- });
262
+ const pointerUpdates = [
263
+ { key: 'HAPPY_STACKS_HOME_DIR', value: homeDir },
264
+ { key: 'HAPPY_STACKS_WORKSPACE_DIR', value: workspaceDir },
265
+ { key: 'HAPPY_STACKS_RUNTIME_DIR', value: runtimeDir },
266
+ { key: 'HAPPY_STACKS_NODE', value: nodePath },
267
+ ];
268
+ if (storageDirOverride) {
269
+ pointerUpdates.push({ key: 'HAPPY_STACKS_STORAGE_DIR', value: storageDirOverride });
270
+ }
271
+ if (cliRootDirOverride) {
272
+ pointerUpdates.push({ key: 'HAPPY_STACKS_CLI_ROOT_DIR', value: cliRootDirOverride });
273
+ }
132
274
 
133
- const installRuntime = !argv.includes('--no-runtime');
275
+ // Write the "real" home env (used by runtime + scripts), AND a stable pointer at ~/.happy-stacks/.env.
276
+ // The pointer file allows launchd/SwiftBar/minimal shells to discover the actual install location
277
+ // even when no env vars are exported.
278
+ await ensureHomeEnvUpdated({ updates: pointerUpdates });
279
+ await ensureCanonicalHomeEnvUpdated({ updates: pointerUpdates });
280
+
281
+ const initNoRuntimeRaw = (process.env.HAPPY_STACKS_INIT_NO_RUNTIME ?? process.env.HAPPY_LOCAL_INIT_NO_RUNTIME ?? '').trim();
282
+ const initNoRuntime = initNoRuntimeRaw === '1' || initNoRuntimeRaw.toLowerCase() === 'true' || initNoRuntimeRaw.toLowerCase() === 'yes';
283
+ const forceRuntime = argv.includes('--force-runtime');
284
+ const skipRuntime = argv.includes('--no-runtime') || (initNoRuntime && !forceRuntime);
285
+ const installRuntime = !skipRuntime;
134
286
  if (installRuntime) {
135
- const pkg = JSON.parse(await readFile(join(cliRootDir, 'package.json'), 'utf-8'));
136
- const version = String(pkg.version ?? '').trim() || 'latest';
137
- const spec = version === '0.0.0' ? 'happy-stacks@latest' : `happy-stacks@${version}`;
287
+ const cliPkg = await readJsonIfExists(join(cliRootDir, 'package.json'));
288
+ const cliVersion = String(cliPkg?.version ?? '').trim() || 'latest';
289
+ const spec = cliVersion === '0.0.0' ? 'happy-stacks@latest' : `happy-stacks@${cliVersion}`;
138
290
 
139
- console.log(`[init] installing runtime into ${runtimeDir} (${spec})...`);
140
- let res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, spec], { stdio: 'inherit' });
141
- if (res.status !== 0) {
142
- // Pre-publish developer experience: if the package isn't on npm yet (E404),
143
- // fall back to installing the local checkout into the runtime prefix.
144
- console.log(`[init] runtime install failed; attempting local install from ${cliRootDir}...`);
145
- res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, cliRootDir], { stdio: 'inherit' });
291
+ const runtimePkgPath = join(runtimeDir, 'node_modules', 'happy-stacks', 'package.json');
292
+ const runtimePkg = await readJsonIfExists(runtimePkgPath);
293
+ const runtimeVersion = String(runtimePkg?.version ?? '').trim();
294
+ const sameVersionInstalled = Boolean(cliVersion && cliVersion !== '0.0.0' && runtimeVersion && runtimeVersion === cliVersion);
295
+
296
+ if (!forceRuntime && sameVersionInstalled) {
297
+ console.log(`[init] runtime already installed in ${runtimeDir} (happy-stacks@${runtimeVersion})`);
298
+ } else {
299
+ console.log(`[init] installing runtime into ${runtimeDir} (${spec})...`);
300
+ let res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, spec], { stdio: 'inherit' });
146
301
  if (res.status !== 0) {
147
- process.exit(res.status ?? 1);
302
+ // Pre-publish developer experience: if the package isn't on npm yet (E404),
303
+ // fall back to installing the local checkout into the runtime prefix.
304
+ console.log(`[init] runtime install failed; attempting local install from ${cliRootDir}...`);
305
+ res = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--silent', '--prefix', runtimeDir, cliRootDir], { stdio: 'inherit' });
306
+ if (res.status !== 0) {
307
+ process.exit(res.status ?? 1);
308
+ }
148
309
  }
149
310
  }
150
311
  }
@@ -154,15 +315,58 @@ async function main() {
154
315
  const shim = [
155
316
  '#!/bin/bash',
156
317
  'set -euo pipefail',
157
- 'HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"',
318
+ `CANONICAL_ENV="${canonicalEnvPath}"`,
319
+ '',
320
+ '# Best-effort: if env vars are not exported (common under launchd/SwiftBar),',
321
+ '# read the stable pointer file at CANONICAL_ENV to discover the real dirs.',
322
+ 'if [[ -f "$CANONICAL_ENV" ]]; then',
323
+ ' if [[ -z "${HAPPY_STACKS_HOME_DIR:-}" ]]; then',
324
+ ' HAPPY_STACKS_HOME_DIR="$(grep -E \'^HAPPY_STACKS_HOME_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_HOME_DIR=//\')" || true',
325
+ ' export HAPPY_STACKS_HOME_DIR',
326
+ ' fi',
327
+ ' if [[ -z "${HAPPY_STACKS_WORKSPACE_DIR:-}" ]]; then',
328
+ ' HAPPY_STACKS_WORKSPACE_DIR="$(grep -E \'^HAPPY_STACKS_WORKSPACE_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_WORKSPACE_DIR=//\')" || true',
329
+ ' export HAPPY_STACKS_WORKSPACE_DIR',
330
+ ' fi',
331
+ ' if [[ -z "${HAPPY_STACKS_RUNTIME_DIR:-}" ]]; then',
332
+ ' HAPPY_STACKS_RUNTIME_DIR="$(grep -E \'^HAPPY_STACKS_RUNTIME_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_RUNTIME_DIR=//\')" || true',
333
+ ' export HAPPY_STACKS_RUNTIME_DIR',
334
+ ' fi',
335
+ ' if [[ -z "${HAPPY_STACKS_NODE:-}" ]]; then',
336
+ ' HAPPY_STACKS_NODE="$(grep -E \'^HAPPY_STACKS_NODE=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_NODE=//\')" || true',
337
+ ' export HAPPY_STACKS_NODE',
338
+ ' fi',
339
+ ' if [[ -z "${HAPPY_STACKS_CLI_ROOT_DIR:-}" ]]; then',
340
+ ' HAPPY_STACKS_CLI_ROOT_DIR="$(grep -E \'^HAPPY_STACKS_CLI_ROOT_DIR=\' "$CANONICAL_ENV" | head -n 1 | sed \'s/^HAPPY_STACKS_CLI_ROOT_DIR=//\')" || true',
341
+ ' export HAPPY_STACKS_CLI_ROOT_DIR',
342
+ ' fi',
343
+ 'fi',
344
+ '',
345
+ `HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-${canonicalHomeDir}}"`,
158
346
  'ENV_FILE="$HOME_DIR/.env"',
159
- 'NODE_BIN=""',
160
- 'if [[ -f "$ENV_FILE" ]]; then',
347
+ 'WORKDIR="${HAPPY_STACKS_WORKSPACE_DIR:-$HOME_DIR/workspace}"',
348
+ 'if [[ -d "$WORKDIR" ]]; then',
349
+ ' cd "$WORKDIR"',
350
+ 'else',
351
+ ' cd "$HOME"',
352
+ 'fi',
353
+ 'NODE_BIN="${HAPPY_STACKS_NODE:-}"',
354
+ 'if [[ -z "$NODE_BIN" && -f "$ENV_FILE" ]]; then',
161
355
  ' NODE_BIN="$(grep -E \'^HAPPY_STACKS_NODE=\' "$ENV_FILE" | head -n 1 | sed \'s/^HAPPY_STACKS_NODE=//\')"',
162
356
  'fi',
163
357
  'if [[ -z "$NODE_BIN" ]]; then',
164
358
  ' NODE_BIN="$(command -v node 2>/dev/null || true)"',
165
359
  'fi',
360
+ 'CLI_ROOT_DIR="${HAPPY_STACKS_CLI_ROOT_DIR:-}"',
361
+ 'if [[ -z "$CLI_ROOT_DIR" && -f "$ENV_FILE" ]]; then',
362
+ ' CLI_ROOT_DIR="$(grep -E \'^HAPPY_STACKS_CLI_ROOT_DIR=\' "$ENV_FILE" | head -n 1 | sed \'s/^HAPPY_STACKS_CLI_ROOT_DIR=//\')" || true',
363
+ 'fi',
364
+ 'if [[ -n "$CLI_ROOT_DIR" ]]; then',
365
+ ' CLI_ENTRY="$CLI_ROOT_DIR/bin/happys.mjs"',
366
+ ' if [[ -f "$CLI_ENTRY" ]]; then',
367
+ ' exec "$NODE_BIN" "$CLI_ENTRY" "$@"',
368
+ ' fi',
369
+ 'fi',
166
370
  'RUNTIME_DIR="${HAPPY_STACKS_RUNTIME_DIR:-$HOME_DIR/runtime}"',
167
371
  'ENTRY="$RUNTIME_DIR/node_modules/happy-stacks/bin/happys.mjs"',
168
372
  'if [[ -f "$ENTRY" ]]; then',
@@ -175,22 +379,31 @@ async function main() {
175
379
  await writeExecutable(happysShimPath, shim);
176
380
  await writeExecutable(happyShimPath, `#!/bin/bash\nset -euo pipefail\nexec \"${happysShimPath}\" happy \"$@\"\n`);
177
381
 
382
+ let didInstallPath = false;
178
383
  if (argv.includes('--install-path')) {
179
- const res = await ensurePathInstalled({ homeDir });
180
- if (res.updated) {
181
- 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');
182
387
  } else {
183
- 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
+ }
184
395
  }
185
396
  }
186
397
 
398
+ const invokedBySetup = (process.env.HAPPY_STACKS_SETUP_CHILD ?? '').trim() === '1';
399
+
187
400
  console.log('[init] complete');
188
401
  console.log(`[init] home: ${homeDir}`);
189
402
  console.log(`[init] workspace: ${workspaceDir}`);
190
403
  console.log(`[init] shims: ${homeDir}/bin`);
191
404
  console.log('');
192
405
 
193
- if (!argv.includes('--install-path')) {
406
+ if (!argv.includes('--install-path') || !didInstallPath) {
194
407
  console.log('[init] note: to use `happys` / `happy` from any terminal, add shims to PATH:');
195
408
  console.log(` export PATH="${homeDir}/bin:$PATH"`);
196
409
  console.log(' (or re-run: happys init --install-path)');
@@ -202,11 +415,15 @@ async function main() {
202
415
 
203
416
  const wantBootstrap = !argv.includes('--no-bootstrap');
204
417
  const isTty = process.stdout.isTTY && process.stdin.isTTY;
205
- const shouldBootstrap = wantBootstrap;
418
+ const alreadyBootstrapped = isWorkspaceBootstrapped(workspaceDir);
419
+ const bootstrapExplicit = bootstrapArgs.length > 0;
420
+ const shouldBootstrap = wantBootstrap && (bootstrapExplicit || !alreadyBootstrapped);
206
421
 
207
422
  if (shouldBootstrap) {
208
423
  const nextArgs = [...bootstrapArgs];
209
- if (isTty && !nextArgs.includes('--interactive') && !nextArgs.includes('-i')) {
424
+ // Only auto-enable the interactive wizard when init is driving bootstrap with no explicit args.
425
+ // If users pass args after `--`, we assume they know what they want and avoid injecting prompts.
426
+ if (!bootstrapExplicit && isTty && !nextArgs.includes('--interactive') && !nextArgs.includes('-i')) {
210
427
  nextArgs.unshift('--interactive');
211
428
  }
212
429
  console.log('[init] running bootstrap...');
@@ -221,12 +438,24 @@ async function main() {
221
438
  return;
222
439
  }
223
440
 
441
+ if (wantBootstrap && alreadyBootstrapped && !bootstrapExplicit) {
442
+ console.log('[init] bootstrap: already set up; skipping');
443
+ console.log('[init] tip: for guided onboarding: happys setup');
444
+ console.log('');
445
+ }
446
+
447
+ // When `happys setup` drives init, avoid printing confusing “next steps”.
448
+ if (invokedBySetup) {
449
+ return;
450
+ }
451
+
224
452
  console.log('[init] next steps:');
225
453
  console.log(` export PATH=\"${homeDir}/bin:$PATH\"`);
226
- console.log(' happys bootstrap --interactive');
454
+ console.log(' happys setup');
227
455
  }
228
456
 
229
457
  main().catch((err) => {
230
458
  console.error('[init] failed:', err);
231
459
  process.exit(1);
232
460
  });
461
+
@@ -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
+