happy-stacks 0.2.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  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 +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
package/scripts/stop.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { join } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
4
 
5
5
  import { parseArgs } from './utils/cli/args.mjs';
6
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';
7
+ import { run, runCapture } from './utils/proc/proc.mjs';
8
+ import { getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
9
9
 
10
10
  function usage() {
11
11
  return [
@@ -1,8 +1,10 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { run, runCapture } from './utils/proc.mjs';
3
+ import { run, runCapture } from './utils/proc/proc.mjs';
4
4
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
5
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
5
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
6
+ import { getInternalServerUrl } from './utils/server/urls.mjs';
7
+ import { resolveCommandPath } from './utils/proc/commands.mjs';
6
8
  import { constants } from 'node:fs';
7
9
  import { access } from 'node:fs/promises';
8
10
 
@@ -21,11 +23,6 @@ import { access } from 'node:fs/promises';
21
23
  * - url (print the first https:// URL from status output)
22
24
  */
23
25
 
24
- function getInternalServerUrl() {
25
- const port = process.env.HAPPY_LOCAL_SERVER_PORT?.trim() ? Number(process.env.HAPPY_LOCAL_SERVER_PORT) : 3005;
26
- return `http://127.0.0.1:${port}`;
27
- }
28
-
29
26
  function getServeConfig(internalServerUrl) {
30
27
  const upstream = process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM?.trim()
31
28
  ? process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM.trim()
@@ -43,7 +40,9 @@ function extractHttpsUrl(serveStatusText) {
43
40
  .find((l) => l.toLowerCase().includes('https://'));
44
41
  if (!line) return null;
45
42
  const m = line.match(/https:\/\/\S+/i);
46
- 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(/\/+$/, '');
47
46
  }
48
47
 
49
48
  function tailscaleStatusMatchesInternalServerUrl(status, internalServerUrl) {
@@ -83,6 +82,16 @@ function extractServeEnableUrl(text) {
83
82
  return m ? m[0] : null;
84
83
  }
85
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
+
86
95
  function parseTimeoutMs(raw, defaultMs) {
87
96
  const s = (raw ?? '').trim();
88
97
  if (!s) return defaultMs;
@@ -134,7 +143,7 @@ async function resolveTailscaleCmd() {
134
143
 
135
144
  // Try PATH first (without executing `tailscale`, which can hang in some environments).
136
145
  try {
137
- const found = (await runCapture('sh', ['-lc', 'command -v tailscale 2>/dev/null || true'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() })).trim();
146
+ const found = await resolveCommandPath('tailscale', { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() });
138
147
  if (found) {
139
148
  return found;
140
149
  }
@@ -176,11 +185,13 @@ export async function tailscaleServeHttpsUrl() {
176
185
  }
177
186
 
178
187
  export async function tailscaleServeStatus() {
188
+ assertTailscaleAllowed('status');
179
189
  const cmd = await resolveTailscaleCmd();
180
190
  return await runCapture(cmd, ['serve', 'status'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() });
181
191
  }
182
192
 
183
193
  export async function tailscaleServeEnable({ internalServerUrl, timeoutMs } = {}) {
194
+ assertTailscaleAllowed('enable');
184
195
  const cmd = await resolveTailscaleCmd();
185
196
  const { upstream, servePath } = getServeConfig(internalServerUrl);
186
197
  const args = ['serve', '--bg'];
@@ -218,12 +229,16 @@ export async function tailscaleServeEnable({ internalServerUrl, timeoutMs } = {}
218
229
  }
219
230
 
220
231
  export async function tailscaleServeReset({ timeoutMs } = {}) {
232
+ assertTailscaleAllowed('reset');
221
233
  const cmd = await resolveTailscaleCmd();
222
234
  const timeout = Number.isFinite(timeoutMs) ? (timeoutMs > 0 ? timeoutMs : 0) : tailscaleUserResetTimeoutMs();
223
235
  await run(cmd, ['serve', 'reset'], { env: tailscaleEnv(), timeoutMs: timeout });
224
236
  }
225
237
 
226
238
  export async function maybeEnableTailscaleServe({ internalServerUrl }) {
239
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
240
+ return null;
241
+ }
227
242
  const enabled = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
228
243
  if (!enabled) {
229
244
  return null;
@@ -237,6 +252,9 @@ export async function maybeEnableTailscaleServe({ internalServerUrl }) {
237
252
  }
238
253
 
239
254
  export async function maybeResetTailscaleServe() {
255
+ if (isSandboxed() && !sandboxAllowsGlobalSideEffects()) {
256
+ return;
257
+ }
240
258
  const enabled = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
241
259
  const resetOnExit = (process.env.HAPPY_LOCAL_TAILSCALE_RESET_ON_EXIT ?? '0') === '1';
242
260
  if (!enabled || !resetOnExit) {
@@ -269,6 +287,7 @@ export async function resolvePublicServerUrl({
269
287
  defaultPublicUrl,
270
288
  envPublicUrl,
271
289
  allowEnable = true,
290
+ stackName = 'main',
272
291
  }) {
273
292
  const preferTailscalePublicUrl = (process.env.HAPPY_LOCAL_TAILSCALE_PREFER_PUBLIC_URL ?? '1') !== '0';
274
293
  const userExplicitlySetPublicUrl =
@@ -278,6 +297,20 @@ export async function resolvePublicServerUrl({
278
297
  return { publicServerUrl: envPublicUrl || defaultPublicUrl, source: 'env' };
279
298
  }
280
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
+
281
314
  // If serve is already configured, use its HTTPS URL if present.
282
315
  const existing = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
283
316
  if (existing) {
@@ -342,7 +375,7 @@ async function main() {
342
375
  return;
343
376
  }
344
377
 
345
- const internalServerUrl = getInternalServerUrl();
378
+ const internalServerUrl = getInternalServerUrl({ env: process.env, defaultPort: 3005 }).internalServerUrl;
346
379
  if (flags.has('--upstream') || kv.get('--upstream')) {
347
380
  process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM = kv.get('--upstream') ?? internalServerUrl;
348
381
  }
package/scripts/test.mjs CHANGED
@@ -1,36 +1,18 @@
1
- import './utils/env.mjs';
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.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';
4
+ import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
+ import { pathExists } from './utils/fs/fs.mjs';
7
+ import { run } from './utils/proc/proc.mjs';
8
+ import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
10
10
 
11
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
- }
12
+ const EXTRA_COMPONENTS = ['stacks'];
13
+ const VALID_COMPONENTS = [...DEFAULT_COMPONENTS, ...EXTRA_COMPONENTS];
31
14
 
32
15
  function pickTestScript(scripts) {
33
- if (!scripts) return null;
34
16
  const candidates = [
35
17
  'test',
36
18
  'tst',
@@ -38,7 +20,7 @@ function pickTestScript(scripts) {
38
20
  'test:unit',
39
21
  'check:test',
40
22
  ];
41
- return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
23
+ return pickFirstScript(scripts, candidates);
42
24
  }
43
25
 
44
26
  async function main() {
@@ -49,33 +31,67 @@ async function main() {
49
31
  if (wantsHelp(argv, { flags })) {
50
32
  printResult({
51
33
  json,
52
- data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
34
+ data: { components: VALID_COMPONENTS, flags: ['--json'] },
53
35
  text: [
54
36
  '[test] usage:',
55
37
  ' happys test [component...] [--json]',
56
38
  '',
57
39
  'components:',
58
- ` ${DEFAULT_COMPONENTS.join(' | ')}`,
40
+ ` ${VALID_COMPONENTS.join(' | ')}`,
59
41
  '',
60
42
  'examples:',
61
43
  ' happys test',
44
+ ' happys test stacks',
62
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.',
63
49
  ].join('\n'),
64
50
  });
65
51
  return;
66
52
  }
67
53
 
54
+ const rootDir = getRootDir(import.meta.url);
55
+
68
56
  const positionals = argv.filter((a) => !a.startsWith('--'));
69
- 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'];
70
74
  const wantAll = requested.includes('all');
75
+ // Default `all` excludes "stacks" to avoid coupling to component repos and their test baselines.
71
76
  const components = wantAll ? DEFAULT_COMPONENTS : requested;
72
77
 
73
- const rootDir = getRootDir(import.meta.url);
74
-
75
78
  const results = [];
76
79
  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(', ')})` });
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
+ }
79
95
  continue;
80
96
  }
81
97
 
@@ -85,7 +101,7 @@ async function main() {
85
101
  continue;
86
102
  }
87
103
 
88
- const scripts = await readScripts(dir);
104
+ const scripts = await readPackageJsonScripts(dir);
89
105
  if (!scripts) {
90
106
  results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
91
107
  continue;