happy-stacks 0.0.0 → 0.1.2

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