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
@@ -0,0 +1,160 @@
1
+ import './utils/env.mjs';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+
5
+ import { parseArgs } from './utils/cli/args.mjs';
6
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
7
+ import { run, runCapture } from './utils/proc.mjs';
8
+ import { getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
9
+
10
+ function usage() {
11
+ return [
12
+ '[stop] usage:',
13
+ ' happys stop [--except-stacks=main,exp1] [--yes] [--aggressive] [--sweep-owned] [--no-docker] [--no-service] [--json]',
14
+ '',
15
+ 'Stops stacks and related local processes (server, daemon, Expo, managed infra) using stack-scoped commands.',
16
+ '',
17
+ 'Examples:',
18
+ ' happys stop --except-stacks=main --yes',
19
+ ' happys stop --yes --no-docker',
20
+ ' happys stop --except-stacks=main --yes --aggressive',
21
+ ' happys stop --except-stacks=main --yes --aggressive --sweep-owned',
22
+ ].join('\n');
23
+ }
24
+
25
+ function parseCsv(raw) {
26
+ const s = String(raw ?? '').trim();
27
+ if (!s) return [];
28
+ return s
29
+ .split(',')
30
+ .map((p) => p.trim())
31
+ .filter(Boolean);
32
+ }
33
+
34
+ async function listAllStackNames() {
35
+ try {
36
+ // Reuse stack.mjs for enumeration (avoids duplicating legacy/new stack dir logic).
37
+ // Note: `stack list` intentionally omits `main`, so we add it back.
38
+ const rootDir = getRootDir(import.meta.url);
39
+ const out = await runCapture(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'list', '--json'], { cwd: rootDir });
40
+ const parsed = JSON.parse(out);
41
+ const stacks = Array.isArray(parsed?.stacks) ? parsed.stacks : [];
42
+ const all = ['main', ...stacks.filter((s) => s !== 'main')];
43
+ return all.sort();
44
+ } catch {
45
+ return ['main'];
46
+ }
47
+ }
48
+
49
+ async function main() {
50
+ const rootDir = getRootDir(import.meta.url);
51
+ const argv = process.argv.slice(2);
52
+ const { flags, kv } = parseArgs(argv);
53
+ const json = wantsJson(argv, { flags });
54
+
55
+ if (wantsHelp(argv, { flags })) {
56
+ printResult({ json, data: { ok: true }, text: usage() });
57
+ return;
58
+ }
59
+
60
+ const exceptStacks = new Set(parseCsv(kv.get('--except-stacks')));
61
+ const yes = flags.has('--yes');
62
+ const aggressive = flags.has('--aggressive');
63
+ const sweepOwned = flags.has('--sweep-owned');
64
+ const noDocker = flags.has('--no-docker');
65
+ const noService = flags.has('--no-service');
66
+
67
+ const stacks = await listAllStackNames();
68
+ const targets = stacks.filter((n) => !exceptStacks.has(n));
69
+
70
+ if (!targets.length) {
71
+ printResult({ json, data: { ok: true, stopped: [], skipped: stacks }, text: '[stop] nothing to do (all stacks excluded)' });
72
+ return;
73
+ }
74
+
75
+ if (!yes && !(process.stdin.isTTY && process.stdout.isTTY)) {
76
+ throw new Error('[stop] refusing to stop stacks without --yes in non-interactive mode');
77
+ }
78
+
79
+ if (!yes) {
80
+ // Simple confirm prompt (avoid importing wizard/rl here).
81
+ // eslint-disable-next-line no-console
82
+ console.log(`[stop] will stop stacks: ${targets.join(', ')}`);
83
+ // eslint-disable-next-line no-console
84
+ console.log('[stop] re-run with --yes to proceed');
85
+ process.exit(1);
86
+ }
87
+
88
+ const results = [];
89
+ const errors = [];
90
+ const skipped = [];
91
+
92
+ for (const stackName of targets) {
93
+ if (stackName !== 'main') {
94
+ const { envPath } = resolveStackEnvPath(stackName);
95
+ // Stack name might appear in directory listings, but if it has no env file, treat it as non-existent.
96
+ if (!existsSync(envPath)) {
97
+ skipped.push({ stackName, reason: 'missing_env', envPath });
98
+ continue;
99
+ }
100
+ }
101
+ try {
102
+ if (!noService) {
103
+ // Best-effort: stop autostart service for the stack so it doesn't restart what we just stopped.
104
+ // eslint-disable-next-line no-await-in-loop
105
+ await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'service', stackName, 'stop'], { cwd: rootDir }).catch(() => {});
106
+ }
107
+
108
+ const args = [
109
+ join(rootDir, 'scripts', 'stack.mjs'),
110
+ 'stop',
111
+ stackName,
112
+ ...(aggressive ? ['--aggressive'] : []),
113
+ ...(sweepOwned ? ['--sweep-owned'] : []),
114
+ ...(noDocker ? ['--no-docker'] : []),
115
+ ];
116
+ if (json) {
117
+ // eslint-disable-next-line no-await-in-loop
118
+ const out = await runCapture(process.execPath, [...args, '--json'], { cwd: rootDir });
119
+ results.push({ stackName, out: JSON.parse(out) });
120
+ } else {
121
+ // eslint-disable-next-line no-await-in-loop
122
+ await run(process.execPath, args, { cwd: rootDir });
123
+ results.push({ stackName, ok: true });
124
+ }
125
+ } catch (e) {
126
+ errors.push({ stackName, error: e instanceof Error ? e.message : String(e) });
127
+ }
128
+ }
129
+
130
+ if (json) {
131
+ printResult({
132
+ json,
133
+ data: { ok: errors.length === 0, stopped: results, skipped, errors, exceptStacks: Array.from(exceptStacks) },
134
+ });
135
+ return;
136
+ }
137
+
138
+ // eslint-disable-next-line no-console
139
+ console.log(
140
+ `[stop] done (stopped=${results.length}${skipped.length ? ` skipped=${skipped.length}` : ''}${errors.length ? ` errors=${errors.length}` : ''})`
141
+ );
142
+ if (skipped.length) {
143
+ for (const s of skipped) {
144
+ // eslint-disable-next-line no-console
145
+ console.log(`[stop] skipped (${s.stackName}): ${s.reason}${s.envPath ? ` (${s.envPath})` : ''}`);
146
+ }
147
+ }
148
+ if (errors.length) {
149
+ for (const e of errors) {
150
+ // eslint-disable-next-line no-console
151
+ console.warn(`[stop] error (${e.stackName}): ${e.error}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ main().catch((err) => {
157
+ console.error('[stop] failed:', err);
158
+ process.exit(1);
159
+ });
160
+
@@ -1,7 +1,10 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { run, runCapture } from './utils/proc.mjs';
4
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
4
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
5
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
6
+ import { constants } from 'node:fs';
7
+ import { access } from 'node:fs/promises';
5
8
 
6
9
  /**
7
10
  * Manage Tailscale Serve for exposing the local UI/API over HTTPS (secure context).
@@ -43,34 +46,123 @@ function extractHttpsUrl(serveStatusText) {
43
46
  return m ? m[0] : null;
44
47
  }
45
48
 
49
+ function tailscaleStatusMatchesInternalServerUrl(status, internalServerUrl) {
50
+ const raw = (internalServerUrl ?? '').trim();
51
+ if (!raw) return true;
52
+
53
+ // Fast path.
54
+ if (status.includes(raw)) return true;
55
+
56
+ // Tailscale typically prints proxy targets like:
57
+ // |-- / proxy http://127.0.0.1:3005
58
+ let port = '';
59
+ try {
60
+ port = new URL(raw).port;
61
+ } catch {
62
+ port = '';
63
+ }
64
+ if (!port) return false;
65
+
66
+ const re = new RegExp(String.raw`\\bproxy\\s+https?:\\/\\/(?:127\\.0\\.0\\.1|localhost|0\\.0\\.0\\.0):${port}\\b`, 'i');
67
+ return re.test(status);
68
+ }
69
+
70
+ export async function tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl) {
71
+ try {
72
+ const status = await tailscaleServeStatus();
73
+ const https = extractHttpsUrl(status);
74
+ if (!https) return null;
75
+ return tailscaleStatusMatchesInternalServerUrl(status, internalServerUrl) ? https : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function extractServeEnableUrl(text) {
82
+ const m = String(text ?? '').match(/https:\/\/login\.tailscale\.com\/f\/serve\?node=\S+/i);
83
+ return m ? m[0] : null;
84
+ }
85
+
86
+ function parseTimeoutMs(raw, defaultMs) {
87
+ const s = (raw ?? '').trim();
88
+ if (!s) return defaultMs;
89
+ const n = Number(s);
90
+ // Allow 0 to disable timeouts for user-triggered commands.
91
+ if (!Number.isFinite(n)) return defaultMs;
92
+ return n > 0 ? n : 0;
93
+ }
94
+
95
+ function tailscaleProbeTimeoutMs() {
96
+ return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_CMD_TIMEOUT_MS, 2500);
97
+ }
98
+
99
+ function tailscaleUserEnableTimeoutMs() {
100
+ return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_ENABLE_TIMEOUT_MS, 30000);
101
+ }
102
+
103
+ function tailscaleAutoEnableTimeoutMs() {
104
+ return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_ENABLE_TIMEOUT_MS_AUTO, tailscaleProbeTimeoutMs());
105
+ }
106
+
107
+ function tailscaleUserResetTimeoutMs() {
108
+ return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_RESET_TIMEOUT_MS, 15000);
109
+ }
110
+
111
+ function tailscaleEnv() {
112
+ // LaunchAgents inherit `XPC_SERVICE_NAME`, which can confuse some CLI tools.
113
+ // In practice, we’ve seen Tailscale commands like `tailscale version` hang under
114
+ // this env. Strip it for any tailscale subprocesses.
115
+ const env = { ...process.env };
116
+ delete env.XPC_SERVICE_NAME;
117
+ return env;
118
+ }
119
+
120
+ async function isExecutable(path) {
121
+ try {
122
+ await access(path, constants.X_OK);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
46
129
  async function resolveTailscaleCmd() {
47
130
  // Allow explicit override (useful for LaunchAgents where aliases don't exist).
48
131
  if (process.env.HAPPY_LOCAL_TAILSCALE_BIN?.trim()) {
49
132
  return process.env.HAPPY_LOCAL_TAILSCALE_BIN.trim();
50
133
  }
51
134
 
52
- // Try PATH first.
135
+ // Try PATH first (without executing `tailscale`, which can hang in some environments).
53
136
  try {
54
- await runCapture('tailscale', ['version']);
55
- return 'tailscale';
137
+ const found = (await runCapture('sh', ['-lc', 'command -v tailscale 2>/dev/null || true'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() })).trim();
138
+ if (found) {
139
+ return found;
140
+ }
56
141
  } catch {
57
- // fall through
142
+ // ignore and fall back
143
+ }
144
+
145
+ // Common macOS app install paths.
146
+ //
147
+ // IMPORTANT:
148
+ // Prefer the lowercase `tailscale` CLI inside the app bundle. The capitalized
149
+ // `Tailscale` binary can behave differently under LaunchAgents (XPC env),
150
+ // potentially hanging instead of printing a version and exiting.
151
+ const appCliPath = '/Applications/Tailscale.app/Contents/MacOS/tailscale';
152
+ if (await isExecutable(appCliPath)) {
153
+ return appCliPath;
58
154
  }
59
155
 
60
- // Common macOS app install path.
61
156
  const appPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
62
- try {
63
- await runCapture(appPath, ['version']);
157
+ if (await isExecutable(appPath)) {
64
158
  return appPath;
65
- } catch {
66
- // fall through
67
159
  }
68
160
 
69
161
  throw new Error(
70
162
  `[local] tailscale CLI not found.\n` +
71
163
  `- Install Tailscale, or\n` +
72
164
  `- Put 'tailscale' on PATH, or\n` +
73
- `- Set HAPPY_LOCAL_TAILSCALE_BIN="${appPath}"`
165
+ `- Set HAPPY_LOCAL_TAILSCALE_BIN="${appCliPath}"`
74
166
  );
75
167
  }
76
168
 
@@ -85,10 +177,10 @@ export async function tailscaleServeHttpsUrl() {
85
177
 
86
178
  export async function tailscaleServeStatus() {
87
179
  const cmd = await resolveTailscaleCmd();
88
- return await runCapture(cmd, ['serve', 'status']);
180
+ return await runCapture(cmd, ['serve', 'status'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() });
89
181
  }
90
182
 
91
- export async function tailscaleServeEnable({ internalServerUrl }) {
183
+ export async function tailscaleServeEnable({ internalServerUrl, timeoutMs } = {}) {
92
184
  const cmd = await resolveTailscaleCmd();
93
185
  const { upstream, servePath } = getServeConfig(internalServerUrl);
94
186
  const args = ['serve', '--bg'];
@@ -96,14 +188,39 @@ export async function tailscaleServeEnable({ internalServerUrl }) {
96
188
  args.push(`--set-path=${servePath}`);
97
189
  }
98
190
  args.push(upstream);
99
- await run(cmd, args);
100
- const status = await runCapture(cmd, ['serve', 'status']).catch(() => '');
191
+ const env = tailscaleEnv();
192
+ const timeout = Number.isFinite(timeoutMs) ? (timeoutMs > 0 ? timeoutMs : 0) : tailscaleUserEnableTimeoutMs();
193
+
194
+ try {
195
+ // `tailscale serve --bg` can hang in some environments (and should never block stack startup).
196
+ // Use a short, best-effort timeout; if it prints an enable URL, open it and return a helpful result.
197
+ await runCapture(cmd, args, { env, timeoutMs: timeout });
198
+ } catch (e) {
199
+ const out = e && typeof e === 'object' && 'out' in e ? e.out : '';
200
+ const err = e && typeof e === 'object' && 'err' in e ? e.err : '';
201
+ const msg = e instanceof Error ? e.message : String(e);
202
+ const combined = `${out ?? ''}\n${err ?? ''}\n${msg ?? ''}`.trim();
203
+ const enableUrl = extractServeEnableUrl(combined);
204
+ if (enableUrl) {
205
+ // User-initiated action (CLI / menubar): open the enable page.
206
+ try {
207
+ await run('open', [enableUrl]);
208
+ } catch {
209
+ // ignore (headless / restricted environment)
210
+ }
211
+ return { status: combined || String(e), httpsUrl: null, enableUrl };
212
+ }
213
+ throw e;
214
+ }
215
+
216
+ const status = await runCapture(cmd, ['serve', 'status'], { env, timeoutMs: tailscaleProbeTimeoutMs() }).catch(() => '');
101
217
  return { status, httpsUrl: status ? extractHttpsUrl(status) : null };
102
218
  }
103
219
 
104
- export async function tailscaleServeReset() {
220
+ export async function tailscaleServeReset({ timeoutMs } = {}) {
105
221
  const cmd = await resolveTailscaleCmd();
106
- await run(cmd, ['serve', 'reset']);
222
+ const timeout = Number.isFinite(timeoutMs) ? (timeoutMs > 0 ? timeoutMs : 0) : tailscaleUserResetTimeoutMs();
223
+ await run(cmd, ['serve', 'reset'], { env: tailscaleEnv(), timeoutMs: timeout });
107
224
  }
108
225
 
109
226
  export async function maybeEnableTailscaleServe({ internalServerUrl }) {
@@ -112,7 +229,8 @@ export async function maybeEnableTailscaleServe({ internalServerUrl }) {
112
229
  return null;
113
230
  }
114
231
  try {
115
- return await tailscaleServeEnable({ internalServerUrl });
232
+ // This is called from automation; it must not hang for long.
233
+ return await tailscaleServeEnable({ internalServerUrl, timeoutMs: tailscaleAutoEnableTimeoutMs() });
116
234
  } catch (e) {
117
235
  throw new Error(`[local] failed to enable tailscale serve (is Tailscale running/authenticated?): ${e instanceof Error ? e.message : String(e)}`);
118
236
  }
@@ -125,7 +243,8 @@ export async function maybeResetTailscaleServe() {
125
243
  return;
126
244
  }
127
245
  try {
128
- await tailscaleServeReset();
246
+ // Shutdown path: never block for long.
247
+ await tailscaleServeReset({ timeoutMs: tailscaleProbeTimeoutMs() });
129
248
  } catch {
130
249
  // ignore
131
250
  }
@@ -160,7 +279,7 @@ export async function resolvePublicServerUrl({
160
279
  }
161
280
 
162
281
  // If serve is already configured, use its HTTPS URL if present.
163
- const existing = await tailscaleServeHttpsUrl();
282
+ const existing = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
164
283
  if (existing) {
165
284
  return { publicServerUrl: existing, source: 'tailscale-status' };
166
285
  }
@@ -172,7 +291,7 @@ export async function resolvePublicServerUrl({
172
291
 
173
292
  // Try enabling serve (best-effort); then wait a bit for Tailscale to be ready/configured.
174
293
  try {
175
- const res = await tailscaleServeEnable({ internalServerUrl });
294
+ const res = await tailscaleServeEnable({ internalServerUrl, timeoutMs: tailscaleAutoEnableTimeoutMs() });
176
295
  if (res?.httpsUrl) {
177
296
  return { publicServerUrl: res.httpsUrl, source: 'tailscale-enable' };
178
297
  }
@@ -185,7 +304,7 @@ export async function resolvePublicServerUrl({
185
304
  : 15000;
186
305
  const deadline = Date.now() + (Number.isFinite(waitMs) ? waitMs : 15000);
187
306
  while (Date.now() < deadline) {
188
- const url = await tailscaleServeHttpsUrl();
307
+ const url = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
189
308
  if (url) {
190
309
  return { publicServerUrl: url, source: 'tailscale-wait' };
191
310
  }
@@ -251,7 +370,22 @@ async function main() {
251
370
  return;
252
371
  }
253
372
  case 'enable': {
373
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
374
+ throw new Error(
375
+ '[tailscale] enable is disabled in sandbox mode.\n' +
376
+ 'Reason: Tailscale Serve is global machine state.\n' +
377
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
378
+ );
379
+ }
254
380
  const res = await tailscaleServeEnable({ internalServerUrl });
381
+ if (res?.enableUrl && !res?.httpsUrl) {
382
+ printResult({
383
+ json,
384
+ data: { ok: true, httpsUrl: null, enableUrl: res.enableUrl },
385
+ text: `[local] tailscale serve is not enabled for this tailnet. Opened:\n${res.enableUrl}`,
386
+ });
387
+ return;
388
+ }
255
389
  printResult({
256
390
  json,
257
391
  data: { ok: true, httpsUrl: res.httpsUrl ?? null },
@@ -261,6 +395,13 @@ async function main() {
261
395
  }
262
396
  case 'disable':
263
397
  case 'reset': {
398
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
399
+ throw new Error(
400
+ '[tailscale] disable/reset is disabled in sandbox mode.\n' +
401
+ 'Reason: Tailscale Serve is global machine state.\n' +
402
+ 'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
403
+ );
404
+ }
264
405
  await tailscaleServeReset();
265
406
  printResult({ json, data: { ok: true }, text: '[local] tailscale serve reset' });
266
407
  return;
@@ -0,0 +1,144 @@
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 pickTestScript(scripts) {
33
+ if (!scripts) return null;
34
+ const candidates = [
35
+ 'test',
36
+ 'tst',
37
+ 'test:ci',
38
+ 'test:unit',
39
+ 'check:test',
40
+ ];
41
+ return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
42
+ }
43
+
44
+ async function main() {
45
+ const argv = process.argv.slice(2);
46
+ const { flags } = parseArgs(argv);
47
+ const json = wantsJson(argv, { flags });
48
+
49
+ if (wantsHelp(argv, { flags })) {
50
+ printResult({
51
+ json,
52
+ data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
53
+ text: [
54
+ '[test] usage:',
55
+ ' happys test [component...] [--json]',
56
+ '',
57
+ 'components:',
58
+ ` ${DEFAULT_COMPONENTS.join(' | ')}`,
59
+ '',
60
+ 'examples:',
61
+ ' happys test',
62
+ ' happys test happy happy-cli',
63
+ ].join('\n'),
64
+ });
65
+ return;
66
+ }
67
+
68
+ const positionals = argv.filter((a) => !a.startsWith('--'));
69
+ const requested = positionals.length ? positionals : ['all'];
70
+ const wantAll = requested.includes('all');
71
+ const components = wantAll ? DEFAULT_COMPONENTS : requested;
72
+
73
+ const rootDir = getRootDir(import.meta.url);
74
+
75
+ const results = [];
76
+ for (const component of components) {
77
+ if (!DEFAULT_COMPONENTS.includes(component)) {
78
+ results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${DEFAULT_COMPONENTS.join(', ')})` });
79
+ continue;
80
+ }
81
+
82
+ const dir = getComponentDir(rootDir, component);
83
+ if (!(await pathExists(dir))) {
84
+ results.push({ component, ok: false, skipped: false, dir, error: `missing component dir: ${dir}` });
85
+ continue;
86
+ }
87
+
88
+ const scripts = await readScripts(dir);
89
+ if (!scripts) {
90
+ results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
91
+ continue;
92
+ }
93
+
94
+ const script = pickTestScript(scripts);
95
+ if (!script) {
96
+ results.push({ component, ok: true, skipped: true, dir, reason: 'no test script found in package.json' });
97
+ continue;
98
+ }
99
+
100
+ await ensureDepsInstalled(dir, component);
101
+ const pm = await detectPackageManagerCmd(dir);
102
+
103
+ try {
104
+ // eslint-disable-next-line no-console
105
+ console.log(`[test] ${component}: running ${pm.name} ${script}`);
106
+ await run(pm.cmd, pm.argsForScript(script), { cwd: dir, env: process.env });
107
+ results.push({ component, ok: true, skipped: false, dir, pm: pm.name, script });
108
+ } catch (e) {
109
+ results.push({ component, ok: false, skipped: false, dir, pm: pm.name, script, error: String(e?.message ?? e) });
110
+ }
111
+ }
112
+
113
+ const ok = results.every((r) => r.ok);
114
+ if (json) {
115
+ printResult({ json, data: { ok, results } });
116
+ return;
117
+ }
118
+
119
+ const lines = ['[test] results:'];
120
+ for (const r of results) {
121
+ if (r.ok && r.skipped) {
122
+ lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
123
+ } else if (r.ok) {
124
+ lines.push(`- ✅ ${r.component}: ok (${r.pm} ${r.script})`);
125
+ } else {
126
+ lines.push(`- ❌ ${r.component}: failed (${r.pm ?? 'unknown'} ${r.script ?? ''})`);
127
+ if (r.error) lines.push(` - ${r.error}`);
128
+ }
129
+ }
130
+ if (!ok) {
131
+ lines.push('');
132
+ lines.push('[test] failed');
133
+ }
134
+ printResult({ json: false, text: lines.join('\n') });
135
+ if (!ok) {
136
+ process.exit(1);
137
+ }
138
+ }
139
+
140
+ main().catch((err) => {
141
+ console.error('[test] failed:', err);
142
+ process.exit(1);
143
+ });
144
+