happy-stacks 0.3.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 +93 -40
- package/bin/happys.mjs +158 -16
- 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 +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- 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/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -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 +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- 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/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- 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/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- 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 +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -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 +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- 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/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -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/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -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/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- 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
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
|
-
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
import { pathExists } from '../fs/fs.mjs';
|
|
@@ -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.
|
|
@@ -113,16 +198,39 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
113
198
|
}
|
|
114
199
|
};
|
|
115
200
|
|
|
201
|
+
const patchesMtimeMs = async () => {
|
|
202
|
+
// Happy's mobile app (and some other repos) use patch-package and keep patches under `patches/`.
|
|
203
|
+
// If a patch file changes but yarn.lock/package.json do not, Yarn won't reinstall and
|
|
204
|
+
// patch-package won't re-apply the patch, leading to confusing "why isn't my patch wired?"
|
|
205
|
+
// failures later (e.g. during iOS pod install).
|
|
206
|
+
const patchesDir = join(dir, 'patches');
|
|
207
|
+
if (!(await pathExists(patchesDir))) return 0;
|
|
208
|
+
try {
|
|
209
|
+
const entries = await readdir(patchesDir, { withFileTypes: true });
|
|
210
|
+
let max = 0;
|
|
211
|
+
for (const e of entries) {
|
|
212
|
+
if (!e.isFile()) continue;
|
|
213
|
+
if (!e.name.endsWith('.patch')) continue;
|
|
214
|
+
const m = await mtimeMs(join(patchesDir, e.name));
|
|
215
|
+
if (m > max) max = m;
|
|
216
|
+
}
|
|
217
|
+
return max;
|
|
218
|
+
} catch {
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
116
223
|
if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
|
|
117
224
|
const lockM = await mtimeMs(yarnLock);
|
|
118
225
|
const pkgM = await mtimeMs(pkgJson);
|
|
119
226
|
const intM = await mtimeMs(yarnIntegrity);
|
|
120
|
-
|
|
227
|
+
const patchM = await patchesMtimeMs();
|
|
228
|
+
if (!intM || lockM > intM || pkgM > intM || patchM > intM) {
|
|
121
229
|
if (!quiet) {
|
|
122
230
|
// eslint-disable-next-line no-console
|
|
123
|
-
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
|
|
231
|
+
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
|
|
124
232
|
}
|
|
125
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
233
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
126
234
|
}
|
|
127
235
|
}
|
|
128
236
|
|
|
@@ -134,7 +242,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
134
242
|
// eslint-disable-next-line no-console
|
|
135
243
|
console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
|
|
136
244
|
}
|
|
137
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
245
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
138
246
|
}
|
|
139
247
|
}
|
|
140
248
|
|
|
@@ -145,7 +253,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
145
253
|
// eslint-disable-next-line no-console
|
|
146
254
|
console.log(`[local] installing ${label} dependencies (first run)...`);
|
|
147
255
|
}
|
|
148
|
-
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
256
|
+
await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
|
|
149
257
|
}
|
|
150
258
|
|
|
151
259
|
export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
@@ -161,14 +269,20 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
|
161
269
|
// - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
|
|
162
270
|
const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
|
|
163
271
|
const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
|
|
164
|
-
if (mode === 'never') {
|
|
165
|
-
return { built: false, reason: 'mode_never' };
|
|
166
|
-
}
|
|
167
272
|
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
168
273
|
const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
|
|
169
274
|
const gitSig = await computeGitWorktreeSignature(cliDir);
|
|
170
275
|
const prev = await readJsonIfExists(buildStatePath);
|
|
171
276
|
|
|
277
|
+
// "never" should prevent rebuild churn, but it must not make the stack unrunnable.
|
|
278
|
+
// If the dist entrypoint is missing, build once even in "never" mode.
|
|
279
|
+
if (mode === 'never') {
|
|
280
|
+
if (await pathExists(distEntrypoint)) {
|
|
281
|
+
return { built: false, reason: 'mode_never' };
|
|
282
|
+
}
|
|
283
|
+
// fallthrough to build
|
|
284
|
+
}
|
|
285
|
+
|
|
172
286
|
if (mode === 'auto') {
|
|
173
287
|
// If dist doesn't exist, we must build.
|
|
174
288
|
if (!(await pathExists(distEntrypoint))) {
|
|
@@ -186,6 +300,18 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
|
186
300
|
const pm = await getComponentPm(cliDir);
|
|
187
301
|
await run(pm.cmd, ['build'], { cwd: cliDir });
|
|
188
302
|
|
|
303
|
+
// Sanity check: happy-cli daemon entrypoint must exist after a successful build.
|
|
304
|
+
// Without this, watch-based rebuilds can restart the daemon into a MODULE_NOT_FOUND crash,
|
|
305
|
+
// which looks like the UI "dies out of nowhere" even though the root cause is missing build output.
|
|
306
|
+
if (!(await pathExists(distEntrypoint))) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`[local] happy-cli build finished but did not produce expected entrypoint.\n` +
|
|
309
|
+
`Expected: ${distEntrypoint}\n` +
|
|
310
|
+
`Fix: run the component build directly and inspect its output:\n` +
|
|
311
|
+
` cd "${cliDir}" && ${pm.cmd} build`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
189
315
|
// Persist new build state (best-effort).
|
|
190
316
|
const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
|
|
191
317
|
if (nowSig) {
|
|
@@ -288,11 +414,15 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
|
|
|
288
414
|
const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
|
|
289
415
|
const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
|
|
290
416
|
|
|
291
|
-
const
|
|
417
|
+
const envIn = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
|
|
418
|
+
const env = await applyStackCacheEnv(envIn);
|
|
292
419
|
const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
|
|
293
420
|
const stdio = quiet ? 'ignore' : 'inherit';
|
|
294
421
|
|
|
295
|
-
const pm = await getComponentPm(dir);
|
|
422
|
+
const pm = await getComponentPm(dir, env);
|
|
423
|
+
if (pm.name === 'yarn') {
|
|
424
|
+
await ensureYarnReady({ dir, env, quiet });
|
|
425
|
+
}
|
|
296
426
|
if (pm.name === 'yarn') {
|
|
297
427
|
await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
|
|
298
428
|
return;
|
|
@@ -301,17 +431,41 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
|
|
|
301
431
|
}
|
|
302
432
|
|
|
303
433
|
export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
|
|
304
|
-
const
|
|
434
|
+
const usesObjectStyle = typeof dir === 'object' && dir !== null;
|
|
435
|
+
const componentDir = usesObjectStyle ? dir.dir : dir;
|
|
436
|
+
const componentLabel = usesObjectStyle ? dir.label : label;
|
|
437
|
+
const componentBin = usesObjectStyle ? dir.bin : bin;
|
|
438
|
+
const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
|
|
439
|
+
const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
|
|
440
|
+
const options = usesObjectStyle ? (dir.options ?? {}) : {};
|
|
441
|
+
|
|
442
|
+
const effectiveEnv = await applyStackCacheEnv(componentEnv);
|
|
443
|
+
const pm = await getComponentPm(componentDir, effectiveEnv);
|
|
444
|
+
if (pm.name === 'yarn') {
|
|
445
|
+
await ensureYarnReady({ dir: componentDir, env: effectiveEnv, quiet: false });
|
|
446
|
+
}
|
|
305
447
|
if (pm.name === 'yarn') {
|
|
306
|
-
return spawnProc(
|
|
448
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
307
449
|
}
|
|
308
|
-
return spawnProc(
|
|
450
|
+
return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
309
451
|
}
|
|
310
452
|
|
|
311
453
|
export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
|
|
312
|
-
const
|
|
454
|
+
const usesObjectStyle = typeof dir === 'object' && dir !== null;
|
|
455
|
+
const componentDir = usesObjectStyle ? dir.dir : dir;
|
|
456
|
+
const componentLabel = usesObjectStyle ? dir.label : label;
|
|
457
|
+
const componentScript = usesObjectStyle ? dir.script : script;
|
|
458
|
+
const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
|
|
459
|
+
const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
|
|
460
|
+
const options = usesObjectStyle ? (dir.options ?? {}) : {};
|
|
461
|
+
|
|
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
|
+
}
|
|
313
467
|
if (pm.name === 'yarn') {
|
|
314
|
-
return spawnProc(
|
|
468
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
315
469
|
}
|
|
316
|
-
return spawnProc(
|
|
470
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
|
|
317
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
|
+
|