happy-stacks 0.4.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.
- package/README.md +64 -33
- package/bin/happys.mjs +44 -1
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +1 -2
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +1 -1
- package/scripts/auth.mjs +21 -3
- package/scripts/build.mjs +1 -1
- package/scripts/dev.mjs +20 -7
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +2 -2
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +5 -2
- package/scripts/install.mjs +99 -57
- package/scripts/migrate.mjs +3 -12
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/review.mjs +715 -24
- package/scripts/review_pr.mjs +5 -20
- package/scripts/run.mjs +21 -15
- package/scripts/setup.mjs +147 -25
- package/scripts/setup_pr.mjs +19 -28
- package/scripts/stack.mjs +493 -157
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tui.mjs +88 -2
- package/scripts/utils/cli/cli_registry.mjs +20 -5
- package/scripts/utils/cli/cwd_scope.mjs +56 -2
- package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
- package/scripts/utils/cli/prereqs.mjs +8 -5
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +14 -1
- package/scripts/utils/dev/expo_dev.mjs +188 -4
- package/scripts/utils/dev/server.mjs +21 -17
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/git/worktrees.mjs +63 -12
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/paths/paths.mjs +118 -3
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/pm.mjs +113 -16
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +68 -10
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +56 -14
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +32 -22
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/stack/editor_workspace.mjs +4 -4
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/startup.mjs +113 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +627 -137
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
|
@@ -50,12 +50,12 @@ export async function requirePnpm() {
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async function getComponentPm(dir) {
|
|
53
|
+
async function getComponentPm(dir, env = process.env) {
|
|
54
54
|
const yarnLock = join(dir, 'yarn.lock');
|
|
55
55
|
if (await pathExists(yarnLock)) {
|
|
56
56
|
// IMPORTANT: when happy-stacks itself is pinned to pnpm via Corepack, running `yarn`
|
|
57
57
|
// from the happy-stacks cwd can be blocked. Always probe yarn with cwd=componentDir.
|
|
58
|
-
if (!(await commandExists('yarn', { cwd: dir }))) {
|
|
58
|
+
if (!(await commandExists('yarn', { cwd: dir, env }))) {
|
|
59
59
|
throw new Error(`[local] yarn is required for component at ${dir} (yarn.lock present). Install it via Corepack: \`corepack enable\``);
|
|
60
60
|
}
|
|
61
61
|
return { name: 'yarn', cmd: 'yarn' };
|
|
@@ -66,6 +66,23 @@ async function getComponentPm(dir) {
|
|
|
66
66
|
return { name: 'pnpm', cmd: 'pnpm' };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
const _yarnReadyKeys = new Set();
|
|
70
|
+
|
|
71
|
+
async function ensureYarnReady({ dir, env, quiet = false }) {
|
|
72
|
+
const e = env && typeof env === 'object' ? env : process.env;
|
|
73
|
+
// In stack mode we isolate HOME/cache; key by effective HOME+XDG cache so we only do this once.
|
|
74
|
+
const key = `${resolve(dir)}|${String(e.HOME ?? '')}|${String(e.XDG_CACHE_HOME ?? '')}`;
|
|
75
|
+
if (_yarnReadyKeys.has(key)) return;
|
|
76
|
+
|
|
77
|
+
// If stdin isn't a TTY (e.g. `happys tui ...` uses stdio:ignore for child stdin),
|
|
78
|
+
// Corepack prompts can deadlock. Provide a single "yes" to unblock initial downloads.
|
|
79
|
+
const isTui = (e.HAPPY_STACKS_TUI ?? e.HAPPY_LOCAL_TUI ?? '').toString().trim() === '1';
|
|
80
|
+
const autoYes = isTui || !process.stdin.isTTY;
|
|
81
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
82
|
+
await run('yarn', ['--version'], { cwd: dir, env: e, stdio, ...(autoYes ? { input: 'y\n' } : {}) });
|
|
83
|
+
_yarnReadyKeys.add(key);
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
export async function requireDir(label, dir) {
|
|
70
87
|
if (await pathExists(dir)) {
|
|
71
88
|
return;
|
|
@@ -76,7 +93,71 @@ export async function requireDir(label, dir) {
|
|
|
76
93
|
);
|
|
77
94
|
}
|
|
78
95
|
|
|
79
|
-
|
|
96
|
+
function resolveStackCacheBaseDirFromEnv(env) {
|
|
97
|
+
const envFile = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim();
|
|
98
|
+
if (!envFile) return null;
|
|
99
|
+
try {
|
|
100
|
+
return join(dirname(envFile), 'cache');
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function applyStackCacheEnv(baseEnv) {
|
|
107
|
+
const env = { ...(baseEnv && typeof baseEnv === 'object' ? baseEnv : process.env) };
|
|
108
|
+
const envFile = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim();
|
|
109
|
+
const stackCacheBase = resolveStackCacheBaseDirFromEnv(env);
|
|
110
|
+
if (!stackCacheBase) return env;
|
|
111
|
+
|
|
112
|
+
// Prisma engines currently default to ~/.cache/prisma (via os.homedir()).
|
|
113
|
+
// In stack mode, isolate HOME for package-manager driven commands so Prisma/Yarn/NPM don't
|
|
114
|
+
// depend on global home caches (and so sandboxed runs can succeed).
|
|
115
|
+
const isolateHomeRaw = (env.HAPPY_STACKS_PM_ISOLATE_HOME ?? env.HAPPY_LOCAL_PM_ISOLATE_HOME ?? '').toString().trim();
|
|
116
|
+
const isolateHome = isolateHomeRaw ? isolateHomeRaw !== '0' : true;
|
|
117
|
+
if (isolateHome && envFile) {
|
|
118
|
+
const stackHome = join(dirname(envFile), 'home');
|
|
119
|
+
env.HOME = stackHome;
|
|
120
|
+
env.USERPROFILE = stackHome;
|
|
121
|
+
try {
|
|
122
|
+
await mkdir(stackHome, { recursive: true });
|
|
123
|
+
} catch {
|
|
124
|
+
// best-effort
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!(env.XDG_CACHE_HOME ?? '').toString().trim()) {
|
|
129
|
+
env.XDG_CACHE_HOME = join(stackCacheBase, 'xdg');
|
|
130
|
+
}
|
|
131
|
+
if (!(env.YARN_CACHE_FOLDER ?? '').toString().trim()) {
|
|
132
|
+
env.YARN_CACHE_FOLDER = join(stackCacheBase, 'yarn');
|
|
133
|
+
}
|
|
134
|
+
if (!(env.npm_config_cache ?? '').toString().trim()) {
|
|
135
|
+
env.npm_config_cache = join(stackCacheBase, 'npm');
|
|
136
|
+
}
|
|
137
|
+
// Corepack caches downloaded package managers (like Yarn) under COREPACK_HOME.
|
|
138
|
+
// In stack mode we want this to be stable and writable so first-run downloads don't prompt/hang in TUI.
|
|
139
|
+
if (!(env.COREPACK_HOME ?? '').toString().trim()) {
|
|
140
|
+
env.COREPACK_HOME = join(stackCacheBase, 'corepack');
|
|
141
|
+
}
|
|
142
|
+
// Avoid Corepack mutating package.json by auto-adding a packageManager field.
|
|
143
|
+
// (This is safe and reduces noise when Corepack is used implicitly.)
|
|
144
|
+
if (!(env.COREPACK_ENABLE_AUTO_PIN ?? '').toString().trim()) {
|
|
145
|
+
env.COREPACK_ENABLE_AUTO_PIN = '0';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await mkdir(env.XDG_CACHE_HOME, { recursive: true });
|
|
150
|
+
await mkdir(env.YARN_CACHE_FOLDER, { recursive: true });
|
|
151
|
+
await mkdir(env.npm_config_cache, { recursive: true });
|
|
152
|
+
await mkdir(env.COREPACK_HOME, { recursive: true });
|
|
153
|
+
} catch {
|
|
154
|
+
// best-effort
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return env;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function ensureDepsInstalled(dir, label, { quiet = false, env: envIn = process.env } = {}) {
|
|
80
161
|
const pkgJson = join(dir, 'package.json');
|
|
81
162
|
if (!(await pathExists(pkgJson))) {
|
|
82
163
|
return;
|
|
@@ -84,8 +165,12 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
84
165
|
|
|
85
166
|
const nodeModules = join(dir, 'node_modules');
|
|
86
167
|
const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
|
|
87
|
-
const pm = await getComponentPm(dir);
|
|
88
168
|
const stdio = quiet ? 'ignore' : 'inherit';
|
|
169
|
+
const env = await applyStackCacheEnv(envIn);
|
|
170
|
+
const pm = await getComponentPm(dir, env);
|
|
171
|
+
if (pm.name === 'yarn') {
|
|
172
|
+
await ensureYarnReady({ dir, env, quiet });
|
|
173
|
+
}
|
|
89
174
|
|
|
90
175
|
if (await pathExists(nodeModules)) {
|
|
91
176
|
const yarnLock = join(dir, 'yarn.lock');
|
|
@@ -100,7 +185,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
100
185
|
console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
|
|
101
186
|
}
|
|
102
187
|
await rm(nodeModules, { recursive: true, force: true });
|
|
103
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
188
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
104
189
|
}
|
|
105
190
|
|
|
106
191
|
// If dependencies changed since the last install, re-run install even if node_modules exists.
|
|
@@ -145,7 +230,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
145
230
|
// eslint-disable-next-line no-console
|
|
146
231
|
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
|
|
147
232
|
}
|
|
148
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
233
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
149
234
|
}
|
|
150
235
|
}
|
|
151
236
|
|
|
@@ -157,7 +242,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
157
242
|
// eslint-disable-next-line no-console
|
|
158
243
|
console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
|
|
159
244
|
}
|
|
160
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
245
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
161
246
|
}
|
|
162
247
|
}
|
|
163
248
|
|
|
@@ -168,7 +253,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
168
253
|
// eslint-disable-next-line no-console
|
|
169
254
|
console.log(`[local] installing ${label} dependencies (first run)...`);
|
|
170
255
|
}
|
|
171
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
256
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
172
257
|
}
|
|
173
258
|
|
|
174
259
|
export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
@@ -329,11 +414,15 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
|
|
|
329
414
|
const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
|
|
330
415
|
const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
|
|
331
416
|
|
|
332
|
-
const
|
|
417
|
+
const envIn = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
|
|
418
|
+
const env = await applyStackCacheEnv(envIn);
|
|
333
419
|
const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
|
|
334
420
|
const stdio = quiet ? 'ignore' : 'inherit';
|
|
335
421
|
|
|
336
|
-
const pm = await getComponentPm(dir);
|
|
422
|
+
const pm = await getComponentPm(dir, env);
|
|
423
|
+
if (pm.name === 'yarn') {
|
|
424
|
+
await ensureYarnReady({ dir, env, quiet });
|
|
425
|
+
}
|
|
337
426
|
if (pm.name === 'yarn') {
|
|
338
427
|
await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
|
|
339
428
|
return;
|
|
@@ -350,11 +439,15 @@ export async function pmSpawnBin(dir, label, bin, args, { env = process.env } =
|
|
|
350
439
|
const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
|
|
351
440
|
const options = usesObjectStyle ? (dir.options ?? {}) : {};
|
|
352
441
|
|
|
353
|
-
const
|
|
442
|
+
const effectiveEnv = await applyStackCacheEnv(componentEnv);
|
|
443
|
+
const pm = await getComponentPm(componentDir, effectiveEnv);
|
|
354
444
|
if (pm.name === 'yarn') {
|
|
355
|
-
|
|
445
|
+
await ensureYarnReady({ dir: componentDir, env: effectiveEnv, quiet: false });
|
|
356
446
|
}
|
|
357
|
-
|
|
447
|
+
if (pm.name === 'yarn') {
|
|
448
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
449
|
+
}
|
|
450
|
+
return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
358
451
|
}
|
|
359
452
|
|
|
360
453
|
export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
|
|
@@ -366,9 +459,13 @@ export async function pmSpawnScript(dir, label, script, args, { env = process.en
|
|
|
366
459
|
const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
|
|
367
460
|
const options = usesObjectStyle ? (dir.options ?? {}) : {};
|
|
368
461
|
|
|
369
|
-
const
|
|
462
|
+
const effectiveEnv = await applyStackCacheEnv(componentEnv);
|
|
463
|
+
const pm = await getComponentPm(componentDir, effectiveEnv);
|
|
464
|
+
if (pm.name === 'yarn') {
|
|
465
|
+
await ensureYarnReady({ dir: componentDir, env: effectiveEnv, quiet: false });
|
|
466
|
+
}
|
|
370
467
|
if (pm.name === 'yarn') {
|
|
371
|
-
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs],
|
|
468
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
372
469
|
}
|
|
373
|
-
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs],
|
|
470
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
374
471
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { pmSpawnBin, pmSpawnScript } from './pm.mjs';
|
|
8
|
+
|
|
9
|
+
async function writeJson(path, obj) {
|
|
10
|
+
await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function waitExit(child) {
|
|
14
|
+
return await new Promise((resolve) => {
|
|
15
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function writeStubYarn({ binDir }) {
|
|
20
|
+
await mkdir(binDir, { recursive: true });
|
|
21
|
+
const yarnPath = join(binDir, 'yarn');
|
|
22
|
+
await writeFile(
|
|
23
|
+
yarnPath,
|
|
24
|
+
[
|
|
25
|
+
'#!/usr/bin/env node',
|
|
26
|
+
'const args = process.argv.slice(2);',
|
|
27
|
+
// ensureYarnReady calls: yarn --version
|
|
28
|
+
"if (args.includes('--version')) { console.log('1.22.22'); process.exit(0); }",
|
|
29
|
+
// pmSpawn* calls: yarn run <script/bin> ...
|
|
30
|
+
'if (args[0] === "run") process.exit(0);',
|
|
31
|
+
'process.exit(0);',
|
|
32
|
+
].join('\n') + '\n',
|
|
33
|
+
'utf-8'
|
|
34
|
+
);
|
|
35
|
+
await chmod(yarnPath, 0o755);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('pmSpawnScript does not reference effectiveEnv before initialization', async (t) => {
|
|
39
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-pm-spawn-script-'));
|
|
40
|
+
t.after(async () => {
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const componentDir = join(root, 'component');
|
|
45
|
+
await mkdir(componentDir, { recursive: true });
|
|
46
|
+
await writeJson(join(componentDir, 'package.json'), { name: 'component', version: '0.0.0' });
|
|
47
|
+
await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
48
|
+
|
|
49
|
+
const binDir = join(root, 'bin');
|
|
50
|
+
await writeStubYarn({ binDir });
|
|
51
|
+
|
|
52
|
+
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` };
|
|
53
|
+
const child = await pmSpawnScript(componentDir, 'spawn-test', 'noop', [], { env });
|
|
54
|
+
const res = await waitExit(child);
|
|
55
|
+
assert.equal(res.code, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('pmSpawnBin does not reference effectiveEnv before initialization', async (t) => {
|
|
59
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-pm-spawn-bin-'));
|
|
60
|
+
t.after(async () => {
|
|
61
|
+
await rm(root, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const componentDir = join(root, 'component');
|
|
65
|
+
await mkdir(componentDir, { recursive: true });
|
|
66
|
+
await writeJson(join(componentDir, 'package.json'), { name: 'component', version: '0.0.0' });
|
|
67
|
+
await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
68
|
+
|
|
69
|
+
const binDir = join(root, 'bin');
|
|
70
|
+
await writeStubYarn({ binDir });
|
|
71
|
+
|
|
72
|
+
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` };
|
|
73
|
+
const child = await pmSpawnBin(componentDir, 'spawn-test', 'prisma', ['generate'], { env });
|
|
74
|
+
const res = await waitExit(child);
|
|
75
|
+
assert.equal(res.code, 0);
|
|
76
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { ensureDepsInstalled, pmExecBin } from './pm.mjs';
|
|
8
|
+
|
|
9
|
+
async function writeYarnEnvDumpStub({ binDir, outputPath }) {
|
|
10
|
+
await mkdir(binDir, { recursive: true });
|
|
11
|
+
const yarnPath = join(binDir, 'yarn');
|
|
12
|
+
await writeFile(
|
|
13
|
+
yarnPath,
|
|
14
|
+
[
|
|
15
|
+
'#!/usr/bin/env node',
|
|
16
|
+
"const { writeFileSync } = require('node:fs');",
|
|
17
|
+
"const out = {",
|
|
18
|
+
' XDG_CACHE_HOME: process.env.XDG_CACHE_HOME ?? null,',
|
|
19
|
+
' YARN_CACHE_FOLDER: process.env.YARN_CACHE_FOLDER ?? null,',
|
|
20
|
+
' npm_config_cache: process.env.npm_config_cache ?? null,',
|
|
21
|
+
'};',
|
|
22
|
+
"writeFileSync(process.env.OUTPUT_PATH, JSON.stringify(out, null, 2) + '\\n');",
|
|
23
|
+
'process.exit(0);',
|
|
24
|
+
].join('\n') + '\n',
|
|
25
|
+
'utf-8'
|
|
26
|
+
);
|
|
27
|
+
await chmod(yarnPath, 0o755);
|
|
28
|
+
await writeFile(outputPath, '', 'utf-8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function expectedCacheEnv({ envPath }) {
|
|
32
|
+
const base = join(dirname(envPath), 'cache');
|
|
33
|
+
return {
|
|
34
|
+
xdg: join(base, 'xdg'),
|
|
35
|
+
yarn: join(base, 'yarn'),
|
|
36
|
+
npm: join(base, 'npm'),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function withEnv(vars, fn) {
|
|
41
|
+
const old = {};
|
|
42
|
+
for (const k of Object.keys(vars)) old[k] = process.env[k];
|
|
43
|
+
try {
|
|
44
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
45
|
+
if (v == null) delete process.env[k];
|
|
46
|
+
else process.env[k] = String(v);
|
|
47
|
+
}
|
|
48
|
+
return await fn();
|
|
49
|
+
} finally {
|
|
50
|
+
for (const [k, v] of Object.entries(old)) {
|
|
51
|
+
if (v == null) delete process.env[k];
|
|
52
|
+
else process.env[k] = v;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('ensureDepsInstalled sets stack-scoped cache env vars for yarn installs', async (t) => {
|
|
58
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-pm-stack-cache-install-'));
|
|
59
|
+
t.after(async () => {
|
|
60
|
+
await rm(root, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const stackDir = join(root, 'stacks', 'exp1');
|
|
64
|
+
const envPath = join(stackDir, 'env');
|
|
65
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
66
|
+
await writeFile(envPath, 'HAPPY_STACKS_STACK=exp1\n', 'utf-8');
|
|
67
|
+
|
|
68
|
+
const componentDir = join(root, 'component');
|
|
69
|
+
await mkdir(componentDir, { recursive: true });
|
|
70
|
+
await writeFile(join(componentDir, 'package.json'), '{}\n', 'utf-8');
|
|
71
|
+
await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
72
|
+
|
|
73
|
+
const binDir = join(root, 'bin');
|
|
74
|
+
const outputPath = join(root, 'env.json');
|
|
75
|
+
await writeYarnEnvDumpStub({ binDir, outputPath });
|
|
76
|
+
|
|
77
|
+
const exp = expectedCacheEnv({ envPath });
|
|
78
|
+
const oldPath = process.env.PATH;
|
|
79
|
+
|
|
80
|
+
await withEnv(
|
|
81
|
+
{
|
|
82
|
+
PATH: `${binDir}:${oldPath ?? ''}`,
|
|
83
|
+
OUTPUT_PATH: outputPath,
|
|
84
|
+
HAPPY_STACKS_ENV_FILE: envPath,
|
|
85
|
+
HAPPY_LOCAL_ENV_FILE: envPath,
|
|
86
|
+
XDG_CACHE_HOME: null,
|
|
87
|
+
YARN_CACHE_FOLDER: null,
|
|
88
|
+
npm_config_cache: null,
|
|
89
|
+
},
|
|
90
|
+
async () => {
|
|
91
|
+
await ensureDepsInstalled(componentDir, 'test-component', { quiet: true });
|
|
92
|
+
const parsed = JSON.parse(await readFile(outputPath, 'utf-8'));
|
|
93
|
+
assert.equal(parsed.XDG_CACHE_HOME, exp.xdg);
|
|
94
|
+
assert.equal(parsed.YARN_CACHE_FOLDER, exp.yarn);
|
|
95
|
+
assert.equal(parsed.npm_config_cache, exp.npm);
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('pmExecBin sets stack-scoped cache env vars for yarn runs', async (t) => {
|
|
101
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-pm-stack-cache-exec-'));
|
|
102
|
+
t.after(async () => {
|
|
103
|
+
await rm(root, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const stackDir = join(root, 'stacks', 'exp1');
|
|
107
|
+
const envPath = join(stackDir, 'env');
|
|
108
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
109
|
+
await writeFile(envPath, 'HAPPY_STACKS_STACK=exp1\n', 'utf-8');
|
|
110
|
+
|
|
111
|
+
const componentDir = join(root, 'component');
|
|
112
|
+
await mkdir(componentDir, { recursive: true });
|
|
113
|
+
await writeFile(join(componentDir, 'package.json'), '{}\n', 'utf-8');
|
|
114
|
+
await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
|
|
115
|
+
|
|
116
|
+
const binDir = join(root, 'bin');
|
|
117
|
+
const outputPath = join(root, 'env.json');
|
|
118
|
+
await writeYarnEnvDumpStub({ binDir, outputPath });
|
|
119
|
+
|
|
120
|
+
const exp = expectedCacheEnv({ envPath });
|
|
121
|
+
const oldPath = process.env.PATH;
|
|
122
|
+
|
|
123
|
+
await withEnv(
|
|
124
|
+
{
|
|
125
|
+
PATH: `${binDir}:${oldPath ?? ''}`,
|
|
126
|
+
OUTPUT_PATH: outputPath,
|
|
127
|
+
HAPPY_STACKS_ENV_FILE: envPath,
|
|
128
|
+
HAPPY_LOCAL_ENV_FILE: envPath,
|
|
129
|
+
XDG_CACHE_HOME: null,
|
|
130
|
+
YARN_CACHE_FOLDER: null,
|
|
131
|
+
npm_config_cache: null,
|
|
132
|
+
},
|
|
133
|
+
async () => {
|
|
134
|
+
await pmExecBin({ dir: componentDir, bin: 'prisma', args: ['generate'], env: process.env, quiet: true });
|
|
135
|
+
const parsed = JSON.parse(await readFile(outputPath, 'utf-8'));
|
|
136
|
+
assert.equal(parsed.XDG_CACHE_HOME, exp.xdg);
|
|
137
|
+
assert.equal(parsed.YARN_CACHE_FOLDER, exp.yarn);
|
|
138
|
+
assert.equal(parsed.npm_config_cache, exp.npm);
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
2
3
|
|
|
3
4
|
function nextLineBreakIndex(s) {
|
|
4
5
|
const n = s.indexOf('\n');
|
|
@@ -80,9 +81,25 @@ export function killProcessTree(child, signal) {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export async function run(cmd, args, options = {}) {
|
|
83
|
-
const { timeoutMs, ...spawnOptions } = options ?? {};
|
|
84
|
+
const { timeoutMs, input, ...spawnOptions } = options ?? {};
|
|
84
85
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
85
|
-
const
|
|
86
|
+
const baseStdio = spawnOptions.stdio ?? 'inherit';
|
|
87
|
+
const stdio =
|
|
88
|
+
input != null
|
|
89
|
+
? Array.isArray(baseStdio)
|
|
90
|
+
? ['pipe', baseStdio[1] ?? 'inherit', baseStdio[2] ?? 'inherit']
|
|
91
|
+
: ['pipe', baseStdio, baseStdio]
|
|
92
|
+
: baseStdio;
|
|
93
|
+
|
|
94
|
+
const proc = spawn(cmd, args, { stdio, shell: false, ...spawnOptions });
|
|
95
|
+
if (input != null && proc.stdin) {
|
|
96
|
+
try {
|
|
97
|
+
proc.stdin.write(String(input));
|
|
98
|
+
proc.stdin.end();
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore
|
|
101
|
+
}
|
|
102
|
+
}
|
|
86
103
|
const t =
|
|
87
104
|
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
88
105
|
? setTimeout(() => {
|
|
@@ -148,13 +165,46 @@ export async function runCapture(cmd, args, options = {}) {
|
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
export async function runCaptureResult(cmd, args, options = {}) {
|
|
151
|
-
const { timeoutMs, ...spawnOptions } = options ?? {};
|
|
168
|
+
const { timeoutMs, streamLabel, teeFile, teeLabel, ...spawnOptions } = options ?? {};
|
|
152
169
|
const startedAt = Date.now();
|
|
153
170
|
return await new Promise((resolvePromise) => {
|
|
154
171
|
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
|
|
155
172
|
let out = '';
|
|
156
173
|
let err = '';
|
|
157
|
-
const
|
|
174
|
+
const label = String(streamLabel ?? '').trim();
|
|
175
|
+
const shouldStream = Boolean(label);
|
|
176
|
+
const outState = { buf: '' };
|
|
177
|
+
const errState = { buf: '' };
|
|
178
|
+
const prefix = shouldStream ? `[${label}] ` : '';
|
|
179
|
+
|
|
180
|
+
const teePath = String(teeFile ?? '').trim();
|
|
181
|
+
const shouldTee = Boolean(teePath);
|
|
182
|
+
const teeOutState = { buf: '' };
|
|
183
|
+
const teeErrState = { buf: '' };
|
|
184
|
+
const teePrefix = (() => {
|
|
185
|
+
const t = String(teeLabel ?? '').trim();
|
|
186
|
+
if (t) return `[${t}] `;
|
|
187
|
+
if (label) return `[${label}] `;
|
|
188
|
+
return '';
|
|
189
|
+
})();
|
|
190
|
+
const teeStream = shouldTee ? createWriteStream(teePath, { flags: 'a' }) : null;
|
|
191
|
+
|
|
192
|
+
function resolveWith(res) {
|
|
193
|
+
if (shouldStream) {
|
|
194
|
+
flushPrefixed(process.stdout, prefix, outState);
|
|
195
|
+
flushPrefixed(process.stderr, prefix, errState);
|
|
196
|
+
}
|
|
197
|
+
if (shouldTee && teeStream) {
|
|
198
|
+
flushPrefixed(teeStream, teePrefix, teeOutState);
|
|
199
|
+
flushPrefixed(teeStream, teePrefix, teeErrState);
|
|
200
|
+
try {
|
|
201
|
+
teeStream.end();
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
resolvePromise(res);
|
|
207
|
+
} const t =
|
|
158
208
|
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
159
209
|
? setTimeout(() => {
|
|
160
210
|
try {
|
|
@@ -162,7 +212,7 @@ export async function runCaptureResult(cmd, args, options = {}) {
|
|
|
162
212
|
} catch {
|
|
163
213
|
// ignore
|
|
164
214
|
}
|
|
165
|
-
|
|
215
|
+
resolveWith({
|
|
166
216
|
ok: false,
|
|
167
217
|
exitCode: null,
|
|
168
218
|
signal: null,
|
|
@@ -175,11 +225,19 @@ export async function runCaptureResult(cmd, args, options = {}) {
|
|
|
175
225
|
});
|
|
176
226
|
}, timeoutMs)
|
|
177
227
|
: null;
|
|
178
|
-
proc.stdout?.on('data', (d) =>
|
|
179
|
-
|
|
228
|
+
proc.stdout?.on('data', (d) => {
|
|
229
|
+
out += d.toString();
|
|
230
|
+
if (shouldStream) writeWithPrefix(process.stdout, prefix, outState, d);
|
|
231
|
+
if (shouldTee && teeStream) writeWithPrefix(teeStream, teePrefix, teeOutState, d);
|
|
232
|
+
});
|
|
233
|
+
proc.stderr?.on('data', (d) => {
|
|
234
|
+
err += d.toString();
|
|
235
|
+
if (shouldStream) writeWithPrefix(process.stderr, prefix, errState, d);
|
|
236
|
+
if (shouldTee && teeStream) writeWithPrefix(teeStream, teePrefix, teeErrState, d);
|
|
237
|
+
});
|
|
180
238
|
proc.on('error', (e) => {
|
|
181
239
|
if (t) clearTimeout(t);
|
|
182
|
-
|
|
240
|
+
resolveWith({
|
|
183
241
|
ok: false,
|
|
184
242
|
exitCode: null,
|
|
185
243
|
signal: null,
|
|
@@ -193,7 +251,7 @@ export async function runCaptureResult(cmd, args, options = {}) {
|
|
|
193
251
|
});
|
|
194
252
|
proc.on('close', (code, signal) => {
|
|
195
253
|
if (t) clearTimeout(t);
|
|
196
|
-
|
|
254
|
+
resolveWith({
|
|
197
255
|
ok: code === 0,
|
|
198
256
|
exitCode: code,
|
|
199
257
|
signal: signal ?? null,
|
|
@@ -206,4 +264,4 @@ export async function runCaptureResult(cmd, args, options = {}) {
|
|
|
206
264
|
});
|
|
207
265
|
});
|
|
208
266
|
});
|
|
209
|
-
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { runCaptureResult } from './proc.mjs';
|
|
8
|
+
|
|
9
|
+
test('runCaptureResult captures stdout/stderr', async () => {
|
|
10
|
+
const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
|
|
11
|
+
env: process.env,
|
|
12
|
+
});
|
|
13
|
+
assert.equal(res.ok, true);
|
|
14
|
+
assert.equal(res.exitCode, 0);
|
|
15
|
+
assert.match(res.out, /hello/);
|
|
16
|
+
assert.match(res.err, /oops/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('runCaptureResult streams output when streamLabel is set (without affecting captured output)', async () => {
|
|
20
|
+
const stdoutWrites = [];
|
|
21
|
+
const stderrWrites = [];
|
|
22
|
+
|
|
23
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
24
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
25
|
+
|
|
26
|
+
// Capture streaming output without polluting the test runner output.
|
|
27
|
+
// eslint-disable-next-line no-console
|
|
28
|
+
process.stdout.write = (chunk) => {
|
|
29
|
+
stdoutWrites.push(String(chunk));
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
process.stderr.write = (chunk) => {
|
|
34
|
+
stderrWrites.push(String(chunk));
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
|
|
40
|
+
env: process.env,
|
|
41
|
+
streamLabel: 'proc-test',
|
|
42
|
+
});
|
|
43
|
+
assert.equal(res.ok, true);
|
|
44
|
+
assert.equal(res.exitCode, 0);
|
|
45
|
+
assert.match(res.out, /hello/);
|
|
46
|
+
assert.match(res.err, /oops/);
|
|
47
|
+
|
|
48
|
+
const streamedOut = stdoutWrites.join('');
|
|
49
|
+
const streamedErr = stderrWrites.join('');
|
|
50
|
+
assert.match(streamedOut, /\[proc-test\] hello/);
|
|
51
|
+
assert.match(streamedErr, /\[proc-test\] oops/);
|
|
52
|
+
} finally {
|
|
53
|
+
process.stdout.write = origStdoutWrite;
|
|
54
|
+
process.stderr.write = origStderrWrite;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('runCaptureResult can tee streamed output to a file', async () => {
|
|
59
|
+
const teeFile = join(tmpdir(), `happy-proc-tee-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
60
|
+
try {
|
|
61
|
+
const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
|
|
62
|
+
env: process.env,
|
|
63
|
+
teeFile,
|
|
64
|
+
teeLabel: 'tee-test',
|
|
65
|
+
});
|
|
66
|
+
assert.equal(res.ok, true);
|
|
67
|
+
const raw = readFileSync(teeFile, 'utf-8');
|
|
68
|
+
assert.match(raw, /\[tee-test\] hello/);
|
|
69
|
+
assert.match(raw, /\[tee-test\] oops/);
|
|
70
|
+
} finally {
|
|
71
|
+
try {
|
|
72
|
+
rmSync(teeFile, { force: true });
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|