happy-stacks 0.3.0 → 0.5.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const proc = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
12
+ let stdout = '';
13
+ let stderr = '';
14
+ proc.stdout.on('data', (d) => (stdout += String(d)));
15
+ proc.stderr.on('data', (d) => (stderr += String(d)));
16
+ proc.on('error', reject);
17
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
18
+ });
19
+ }
20
+
21
+ test('happys <stack> <cmd> ... rewrites to happys stack <cmd> <stack> ... when stack exists', async () => {
22
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
23
+ const rootDir = dirname(scriptsDir);
24
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-shorthand-'));
25
+
26
+ const storageDir = join(tmp, 'storage');
27
+ const homeDir = join(tmp, 'home');
28
+ const stackName = 'exp-test';
29
+
30
+ const envPath = join(storageDir, stackName, 'env');
31
+ await mkdir(dirname(envPath), { recursive: true });
32
+ await mkdir(homeDir, { recursive: true });
33
+ await writeFile(envPath, 'FOO=bar\n', 'utf-8');
34
+
35
+ const baseEnv = {
36
+ ...process.env,
37
+ HAPPY_STACKS_HOME_DIR: homeDir,
38
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
39
+ HAPPY_STACKS_CLI_ROOT_DISABLE: '1',
40
+ };
41
+
42
+ const res = await runNode([join(rootDir, 'bin', 'happys.mjs'), stackName, 'env', 'path', '--json'], {
43
+ cwd: rootDir,
44
+ env: baseEnv,
45
+ });
46
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
47
+
48
+ const out = JSON.parse(res.stdout || '{}');
49
+ assert.equal(out.ok, true);
50
+ assert.ok(
51
+ typeof out.envPath === 'string' && out.envPath.endsWith(`/${stackName}/env`),
52
+ `expected envPath to end with /${stackName}/env, got: ${out.envPath}`
53
+ );
54
+ });
55
+
@@ -0,0 +1,128 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ function runNode(args, { cwd, env }) {
10
+ return new Promise((resolve, reject) => {
11
+ const cleanEnv = {};
12
+ for (const [k, v] of Object.entries(env ?? {})) {
13
+ if (v == null) continue;
14
+ cleanEnv[k] = String(v);
15
+ }
16
+ const proc = spawn(process.execPath, args, { cwd, env: cleanEnv, stdio: ['ignore', 'pipe', 'pipe'] });
17
+ let stdout = '';
18
+ let stderr = '';
19
+ proc.stdout.on('data', (d) => (stdout += String(d)));
20
+ proc.stderr.on('data', (d) => (stderr += String(d)));
21
+ proc.on('error', reject);
22
+ proc.on('exit', (code) => resolve({ code: code ?? 0, stdout, stderr }));
23
+ });
24
+ }
25
+
26
+ async function touchWorktree(dir) {
27
+ await mkdir(dir, { recursive: true });
28
+ // In a git worktree, ".git" is often a file; our detection treats either file or dir as truthy.
29
+ await writeFile(join(dir, '.git'), 'gitdir: /dev/null\n', 'utf-8');
30
+ }
31
+
32
+ test('happys stack wt <stack> -- list defaults to active-only (no exhaustive enumeration)', async () => {
33
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
34
+ const rootDir = dirname(scriptsDir);
35
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-wt-list-'));
36
+
37
+ const storageDir = join(tmp, 'storage');
38
+ const homeDir = join(tmp, 'home');
39
+ const workspaceDir = join(tmp, 'workspace');
40
+ const componentsDir = join(workspaceDir, 'components');
41
+ const stackName = 'exp-test';
42
+
43
+ // Create isolated worktrees on disk (inside our temp workspace).
44
+ const wtRoot = join(componentsDir, '.worktrees');
45
+ const happyActive = join(wtRoot, 'happy', 'slopus', 'pr', 'active-branch');
46
+ const happyOther = join(wtRoot, 'happy', 'slopus', 'pr', 'other-branch');
47
+ const cliActive = join(wtRoot, 'happy-cli', 'slopus', 'pr', 'cli-active');
48
+ const cliOther = join(wtRoot, 'happy-cli', 'slopus', 'pr', 'cli-other');
49
+ await touchWorktree(happyActive);
50
+ await touchWorktree(happyOther);
51
+ await touchWorktree(cliActive);
52
+ await touchWorktree(cliOther);
53
+
54
+ // Stack env selects the active worktrees.
55
+ const envPath = join(storageDir, stackName, 'env');
56
+ await mkdir(dirname(envPath), { recursive: true });
57
+ await writeFile(
58
+ envPath,
59
+ [
60
+ `HAPPY_STACKS_STACK=${stackName}`,
61
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${happyActive}`,
62
+ `HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI=${cliActive}`,
63
+ '',
64
+ ].join('\n'),
65
+ 'utf-8'
66
+ );
67
+
68
+ const baseEnv = {
69
+ ...process.env,
70
+ // Prevent loading the user's real ~/.happy-stacks/.env via canonical discovery.
71
+ HAPPY_STACKS_HOME_DIR: homeDir,
72
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
73
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
74
+ };
75
+
76
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'wt', stackName, '--', 'list'], { cwd: rootDir, env: baseEnv });
77
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
78
+
79
+ assert.ok(res.stdout.includes(`- active: ${happyActive}`), `expected happy active in output\n${res.stdout}`);
80
+ assert.ok(res.stdout.includes(`- active: ${cliActive}`), `expected happy-cli active in output\n${res.stdout}`);
81
+
82
+ // Should NOT enumerate other worktrees unless --all was passed.
83
+ assert.ok(!res.stdout.includes(`- ${happyOther}`), `expected happy other to be omitted\n${res.stdout}`);
84
+ assert.ok(!res.stdout.includes(`- ${cliOther}`), `expected happy-cli other to be omitted\n${res.stdout}`);
85
+ });
86
+
87
+ test('happys stack wt <stack> -- list --all shows all worktrees (opt-in)', async () => {
88
+ const scriptsDir = dirname(fileURLToPath(import.meta.url));
89
+ const rootDir = dirname(scriptsDir);
90
+ const tmp = await mkdtemp(join(tmpdir(), 'happy-stacks-stack-wt-list-'));
91
+
92
+ const storageDir = join(tmp, 'storage');
93
+ const homeDir = join(tmp, 'home');
94
+ const workspaceDir = join(tmp, 'workspace');
95
+ const componentsDir = join(workspaceDir, 'components');
96
+ const stackName = 'exp-test';
97
+
98
+ const wtRoot = join(componentsDir, '.worktrees');
99
+ const happyActive = join(wtRoot, 'happy', 'slopus', 'pr', 'active-branch');
100
+ const happyOther = join(wtRoot, 'happy', 'slopus', 'pr', 'other-branch');
101
+ await touchWorktree(happyActive);
102
+ await touchWorktree(happyOther);
103
+
104
+ const envPath = join(storageDir, stackName, 'env');
105
+ await mkdir(dirname(envPath), { recursive: true });
106
+ await writeFile(
107
+ envPath,
108
+ [`HAPPY_STACKS_STACK=${stackName}`, `HAPPY_STACKS_COMPONENT_DIR_HAPPY=${happyActive}`, ''].join('\n'),
109
+ 'utf-8'
110
+ );
111
+
112
+ const baseEnv = {
113
+ ...process.env,
114
+ HAPPY_STACKS_HOME_DIR: homeDir,
115
+ HAPPY_STACKS_STORAGE_DIR: storageDir,
116
+ HAPPY_STACKS_WORKSPACE_DIR: workspaceDir,
117
+ };
118
+
119
+ const res = await runNode([join(rootDir, 'scripts', 'stack.mjs'), 'wt', stackName, '--', 'list', '--all'], {
120
+ cwd: rootDir,
121
+ env: baseEnv,
122
+ });
123
+ assert.equal(res.code, 0, `expected exit 0, got ${res.code}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}`);
124
+
125
+ assert.ok(res.stdout.includes(`- active: ${happyActive}`), `expected happy active in output\n${res.stdout}`);
126
+ assert.ok(res.stdout.includes(`- ${happyOther}`), `expected happy other to be listed with --all\n${res.stdout}`);
127
+ });
128
+
@@ -40,7 +40,9 @@ function extractHttpsUrl(serveStatusText) {
40
40
  .find((l) => l.toLowerCase().includes('https://'));
41
41
  if (!line) return null;
42
42
  const m = line.match(/https:\/\/\S+/i);
43
- return m ? m[0] : null;
43
+ if (!m) return null;
44
+ // Avoid trailing slash for base URLs (some consumers treat it as a path prefix).
45
+ return m[0].replace(/\/+$/, '');
44
46
  }
45
47
 
46
48
  function tailscaleStatusMatchesInternalServerUrl(status, internalServerUrl) {
@@ -80,6 +82,16 @@ function extractServeEnableUrl(text) {
80
82
  return m ? m[0] : null;
81
83
  }
82
84
 
85
+ function assertTailscaleAllowed(action) {
86
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
87
+ throw new Error(
88
+ `[local] tailscale ${action} is disabled in sandbox mode.\n` +
89
+ `Reason: Tailscale Serve is global machine state and sandbox runs must be isolated.\n` +
90
+ `If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1`
91
+ );
92
+ }
93
+ }
94
+
83
95
  function parseTimeoutMs(raw, defaultMs) {
84
96
  const s = (raw ?? '').trim();
85
97
  if (!s) return defaultMs;
@@ -173,11 +185,13 @@ export async function tailscaleServeHttpsUrl() {
173
185
  }
174
186
 
175
187
  export async function tailscaleServeStatus() {
188
+ assertTailscaleAllowed('status');
176
189
  const cmd = await resolveTailscaleCmd();
177
190
  return await runCapture(cmd, ['serve', 'status'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() });
178
191
  }
179
192
 
180
193
  export async function tailscaleServeEnable({ internalServerUrl, timeoutMs } = {}) {
194
+ assertTailscaleAllowed('enable');
181
195
  const cmd = await resolveTailscaleCmd();
182
196
  const { upstream, servePath } = getServeConfig(internalServerUrl);
183
197
  const args = ['serve', '--bg'];
@@ -215,12 +229,16 @@ export async function tailscaleServeEnable({ internalServerUrl, timeoutMs } = {}
215
229
  }
216
230
 
217
231
  export async function tailscaleServeReset({ timeoutMs } = {}) {
232
+ assertTailscaleAllowed('reset');
218
233
  const cmd = await resolveTailscaleCmd();
219
234
  const timeout = Number.isFinite(timeoutMs) ? (timeoutMs > 0 ? timeoutMs : 0) : tailscaleUserResetTimeoutMs();
220
235
  await run(cmd, ['serve', 'reset'], { env: tailscaleEnv(), timeoutMs: timeout });
221
236
  }
222
237
 
223
238
  export async function maybeEnableTailscaleServe({ internalServerUrl }) {
239
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
240
+ return null;
241
+ }
224
242
  const enabled = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
225
243
  if (!enabled) {
226
244
  return null;
@@ -234,6 +252,9 @@ export async function maybeEnableTailscaleServe({ internalServerUrl }) {
234
252
  }
235
253
 
236
254
  export async function maybeResetTailscaleServe() {
255
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
256
+ return;
257
+ }
237
258
  const enabled = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
238
259
  const resetOnExit = (process.env.HAPPY_LOCAL_TAILSCALE_RESET_ON_EXIT ?? '0') === '1';
239
260
  if (!enabled || !resetOnExit) {
@@ -266,6 +287,7 @@ export async function resolvePublicServerUrl({
266
287
  defaultPublicUrl,
267
288
  envPublicUrl,
268
289
  allowEnable = true,
290
+ stackName = 'main',
269
291
  }) {
270
292
  const preferTailscalePublicUrl = (process.env.HAPPY_LOCAL_TAILSCALE_PREFER_PUBLIC_URL ?? '1') !== '0';
271
293
  const userExplicitlySetPublicUrl =
@@ -275,6 +297,20 @@ export async function resolvePublicServerUrl({
275
297
  return { publicServerUrl: envPublicUrl || defaultPublicUrl, source: 'env' };
276
298
  }
277
299
 
300
+ // Non-main stacks:
301
+ // - Never auto-enable (global machine state) by default.
302
+ // - If the caller explicitly allows it AND Tailscale Serve is already configured for this stack's
303
+ // internal URL, prefer the HTTPS URL (safe: status must match the internal URL).
304
+ if (stackName && stackName !== 'main') {
305
+ if (allowEnable) {
306
+ const existing = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
307
+ if (existing) {
308
+ return { publicServerUrl: existing, source: 'tailscale-status' };
309
+ }
310
+ }
311
+ return { publicServerUrl: envPublicUrl || defaultPublicUrl, source: envPublicUrl ? 'env' : 'default' };
312
+ }
313
+
278
314
  // If serve is already configured, use its HTTPS URL if present.
279
315
  const existing = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
280
316
  if (existing) {
package/scripts/test.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
4
+ import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
5
  import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
6
  import { pathExists } from './utils/fs/fs.mjs';
7
7
  import { run } from './utils/proc/proc.mjs';
8
8
  import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
9
10
 
10
11
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
12
+ const EXTRA_COMPONENTS = ['stacks'];
13
+ const VALID_COMPONENTS = [...DEFAULT_COMPONENTS, ...EXTRA_COMPONENTS];
11
14
 
12
15
  function pickTestScript(scripts) {
13
16
  const candidates = [
@@ -28,33 +31,67 @@ async function main() {
28
31
  if (wantsHelp(argv, { flags })) {
29
32
  printResult({
30
33
  json,
31
- data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
34
+ data: { components: VALID_COMPONENTS, flags: ['--json'] },
32
35
  text: [
33
36
  '[test] usage:',
34
37
  ' happys test [component...] [--json]',
35
38
  '',
36
39
  'components:',
37
- ` ${DEFAULT_COMPONENTS.join(' | ')}`,
40
+ ` ${VALID_COMPONENTS.join(' | ')}`,
38
41
  '',
39
42
  'examples:',
40
43
  ' happys test',
44
+ ' happys test stacks',
41
45
  ' happys test happy happy-cli',
46
+ '',
47
+ 'note:',
48
+ ' If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
42
49
  ].join('\n'),
43
50
  });
44
51
  return;
45
52
  }
46
53
 
54
+ const rootDir = getRootDir(import.meta.url);
55
+
47
56
  const positionals = argv.filter((a) => !a.startsWith('--'));
48
- const requested = positionals.length ? positionals : ['all'];
57
+ const inferred =
58
+ positionals.length === 0
59
+ ? inferComponentFromCwd({
60
+ rootDir,
61
+ invokedCwd: getInvokedCwd(process.env),
62
+ components: DEFAULT_COMPONENTS,
63
+ })
64
+ : null;
65
+ if (inferred) {
66
+ const stacksKey = componentDirEnvKey(inferred.component);
67
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
68
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
69
+ process.env[stacksKey] = inferred.repoDir;
70
+ }
71
+ }
72
+
73
+ const requested = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
49
74
  const wantAll = requested.includes('all');
75
+ // Default `all` excludes "stacks" to avoid coupling to component repos and their test baselines.
50
76
  const components = wantAll ? DEFAULT_COMPONENTS : requested;
51
77
 
52
- const rootDir = getRootDir(import.meta.url);
53
-
54
78
  const results = [];
55
79
  for (const component of components) {
56
- if (!DEFAULT_COMPONENTS.includes(component)) {
57
- results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${DEFAULT_COMPONENTS.join(', ')})` });
80
+ if (!VALID_COMPONENTS.includes(component)) {
81
+ results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${VALID_COMPONENTS.join(', ')})` });
82
+ continue;
83
+ }
84
+
85
+ if (component === 'stacks') {
86
+ try {
87
+ // eslint-disable-next-line no-console
88
+ console.log('[test] stacks: running node --test (happy-stacks unit tests)');
89
+ // Restrict to explicit *.test.mjs files to avoid accidentally executing scripts/test.mjs.
90
+ await run('sh', ['-lc', 'node --test "scripts/**/*.test.mjs"'], { cwd: rootDir, env: process.env });
91
+ results.push({ component, ok: true, skipped: false, dir: rootDir, pm: 'node', script: '--test' });
92
+ } catch (e) {
93
+ results.push({ component, ok: false, skipped: false, dir: rootDir, pm: 'node', script: '--test', error: String(e?.message ?? e) });
94
+ }
58
95
  continue;
59
96
  }
60
97