happy-stacks 0.3.0 → 0.4.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 (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -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