happy-stacks 0.3.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.
- package/README.md +29 -7
- package/bin/happys.mjs +114 -15
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +11 -7
- package/scripts/build.mjs +54 -7
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +181 -46
- package/scripts/edison.mjs +4 -2
- package/scripts/init.mjs +3 -1
- package/scripts/install.mjs +112 -16
- package/scripts/lint.mjs +24 -4
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +83 -9
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +42 -43
- package/scripts/setup_pr.mjs +591 -34
- package/scripts/stack.mjs +503 -45
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +309 -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 +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/dev/daemon.mjs +47 -3
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +15 -25
- package/scripts/utils/dev_auth_key.mjs +169 -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 +24 -20
- 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/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 +42 -38
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +69 -12
- package/scripts/utils/proc/proc.mjs +76 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -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/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/urls.mjs +14 -4
- 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 +2 -2
- 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 +7 -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/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/worktrees.mjs +141 -55
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -26,8 +26,8 @@ export function getHappyStacksHomeDir(env = process.env) {
|
|
|
26
26
|
return PRIMARY_HOME_DIR;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
export function getWorkspaceDir(cliRootDir = null) {
|
|
30
|
-
const fromEnv = (
|
|
29
|
+
export function getWorkspaceDir(cliRootDir = null, env = process.env) {
|
|
30
|
+
const fromEnv = (env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
|
|
31
31
|
if (fromEnv) {
|
|
32
32
|
return expandHome(fromEnv);
|
|
33
33
|
}
|
|
@@ -41,8 +41,8 @@ export function getWorkspaceDir(cliRootDir = null) {
|
|
|
41
41
|
return cliRootDir ? cliRootDir : defaultWorkspace;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export function getComponentsDir(rootDir) {
|
|
45
|
-
const workspaceDir = getWorkspaceDir(rootDir);
|
|
44
|
+
export function getComponentsDir(rootDir, env = process.env) {
|
|
45
|
+
const workspaceDir = getWorkspaceDir(rootDir, env);
|
|
46
46
|
return join(workspaceDir, 'components');
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -50,46 +50,48 @@ export function componentDirEnvKey(name) {
|
|
|
50
50
|
return `HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function normalizePathForEnv(rootDir, raw) {
|
|
53
|
+
function normalizePathForEnv(rootDir, raw, env = process.env) {
|
|
54
54
|
const trimmed = (raw ?? '').trim();
|
|
55
55
|
if (!trimmed) {
|
|
56
56
|
return '';
|
|
57
57
|
}
|
|
58
58
|
const expanded = expandHome(trimmed);
|
|
59
59
|
// If the path is relative, treat it as relative to the workspace root (default: repo root).
|
|
60
|
-
const workspaceDir = getWorkspaceDir(rootDir);
|
|
60
|
+
const workspaceDir = getWorkspaceDir(rootDir, env);
|
|
61
61
|
return expanded.startsWith('/') ? expanded : resolve(workspaceDir, expanded);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export function getComponentDir(rootDir, name) {
|
|
64
|
+
export function getComponentDir(rootDir, name, env = process.env) {
|
|
65
65
|
const stacksKey = componentDirEnvKey(name);
|
|
66
66
|
const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
|
|
67
|
-
const fromEnv = normalizePathForEnv(rootDir,
|
|
67
|
+
const fromEnv = normalizePathForEnv(rootDir, env[stacksKey] ?? env[legacyKey], env);
|
|
68
68
|
if (fromEnv) {
|
|
69
69
|
return fromEnv;
|
|
70
70
|
}
|
|
71
|
-
return join(getComponentsDir(rootDir), name);
|
|
71
|
+
return join(getComponentsDir(rootDir, env), name);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export function getStackName() {
|
|
75
|
-
const raw =
|
|
76
|
-
?
|
|
77
|
-
:
|
|
78
|
-
?
|
|
74
|
+
export function getStackName(env = process.env) {
|
|
75
|
+
const raw = env.HAPPY_STACKS_STACK?.trim()
|
|
76
|
+
? env.HAPPY_STACKS_STACK.trim()
|
|
77
|
+
: env.HAPPY_LOCAL_STACK?.trim()
|
|
78
|
+
? env.HAPPY_LOCAL_STACK.trim()
|
|
79
79
|
: '';
|
|
80
80
|
return raw || 'main';
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export function getStackLabel(stackName =
|
|
84
|
-
|
|
83
|
+
export function getStackLabel(stackName = null, env = process.env) {
|
|
84
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
85
|
+
return name === 'main' ? PRIMARY_LABEL_BASE : `${PRIMARY_LABEL_BASE}.${name}`;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
export function getLegacyStackLabel(stackName =
|
|
88
|
-
|
|
88
|
+
export function getLegacyStackLabel(stackName = null, env = process.env) {
|
|
89
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
90
|
+
return name === 'main' ? LEGACY_LABEL_BASE : `${LEGACY_LABEL_BASE}.${name}`;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export function getStacksStorageRoot() {
|
|
92
|
-
const fromEnv = (
|
|
93
|
+
export function getStacksStorageRoot(env = process.env) {
|
|
94
|
+
const fromEnv = (env.HAPPY_STACKS_STORAGE_DIR ?? env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
|
|
93
95
|
if (fromEnv) {
|
|
94
96
|
return expandHome(fromEnv);
|
|
95
97
|
}
|
|
@@ -100,19 +102,20 @@ export function getLegacyStorageRoot() {
|
|
|
100
102
|
return LEGACY_STORAGE_ROOT;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
export function resolveStackBaseDir(stackName =
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
const
|
|
105
|
+
export function resolveStackBaseDir(stackName = null, env = process.env) {
|
|
106
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
107
|
+
const preferredRoot = getStacksStorageRoot(env);
|
|
108
|
+
const newBase = join(preferredRoot, name);
|
|
109
|
+
const legacyBase = name === 'main' ? LEGACY_STORAGE_ROOT : join(LEGACY_STORAGE_ROOT, 'stacks', name);
|
|
107
110
|
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
108
111
|
|
|
109
112
|
// Prefer the new layout by default.
|
|
110
113
|
//
|
|
111
114
|
// For non-main stacks, keep legacy layout if the legacy env exists and the new env does not.
|
|
112
115
|
// This avoids breaking existing stacks until `happys stack migrate` is run.
|
|
113
|
-
if (allowLegacy &&
|
|
114
|
-
const newEnv = join(preferredRoot,
|
|
115
|
-
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks',
|
|
116
|
+
if (allowLegacy && name !== 'main') {
|
|
117
|
+
const newEnv = join(preferredRoot, name, 'env');
|
|
118
|
+
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
|
|
116
119
|
if (!existsSync(newEnv) && existsSync(legacyEnv)) {
|
|
117
120
|
return { baseDir: legacyBase, isLegacy: true };
|
|
118
121
|
}
|
|
@@ -121,30 +124,31 @@ export function resolveStackBaseDir(stackName = getStackName()) {
|
|
|
121
124
|
return { baseDir: newBase, isLegacy: false };
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
export function resolveStackEnvPath(stackName =
|
|
125
|
-
const
|
|
127
|
+
export function resolveStackEnvPath(stackName = null, env = process.env) {
|
|
128
|
+
const name = (stackName ?? '').toString().trim() || getStackName(env);
|
|
129
|
+
const { baseDir: activeBase, isLegacy } = resolveStackBaseDir(name, env);
|
|
126
130
|
// New layout: ~/.happy/stacks/<name>/env
|
|
127
|
-
const newEnv = join(getStacksStorageRoot(),
|
|
131
|
+
const newEnv = join(getStacksStorageRoot(env), name, 'env');
|
|
128
132
|
// Legacy layout: ~/.happy/local/stacks/<name>/env
|
|
129
|
-
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks',
|
|
133
|
+
const legacyEnv = join(LEGACY_STORAGE_ROOT, 'stacks', name, 'env');
|
|
130
134
|
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
131
135
|
|
|
132
136
|
if (existsSync(newEnv)) {
|
|
133
|
-
return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(),
|
|
137
|
+
return { envPath: newEnv, isLegacy: false, baseDir: join(getStacksStorageRoot(env), name) };
|
|
134
138
|
}
|
|
135
139
|
if (allowLegacy && existsSync(legacyEnv)) {
|
|
136
|
-
return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks',
|
|
140
|
+
return { envPath: legacyEnv, isLegacy: true, baseDir: join(LEGACY_STORAGE_ROOT, 'stacks', name) };
|
|
137
141
|
}
|
|
138
142
|
return { envPath: newEnv, isLegacy, baseDir: activeBase };
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
export function getDefaultAutostartPaths() {
|
|
142
|
-
const stackName = getStackName();
|
|
143
|
-
const { baseDir, isLegacy } = resolveStackBaseDir(stackName);
|
|
145
|
+
export function getDefaultAutostartPaths(env = process.env) {
|
|
146
|
+
const stackName = getStackName(env);
|
|
147
|
+
const { baseDir, isLegacy } = resolveStackBaseDir(stackName, env);
|
|
144
148
|
const logsDir = join(baseDir, 'logs');
|
|
145
149
|
|
|
146
|
-
const primaryLabel = getStackLabel(stackName);
|
|
147
|
-
const legacyLabel = getLegacyStackLabel(stackName);
|
|
150
|
+
const primaryLabel = getStackLabel(stackName, env);
|
|
151
|
+
const legacyLabel = getLegacyStackLabel(stackName, env);
|
|
148
152
|
const primaryPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${primaryLabel}.plist`);
|
|
149
153
|
const legacyPlistPath = join(homedir(), 'Library', 'LaunchAgents', `${legacyLabel}.plist`);
|
|
150
154
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function runWithConcurrencyLimit({ items, limit, fn }) {
|
|
2
|
+
const list = Array.isArray(items) ? items : [];
|
|
3
|
+
const max = Number(limit);
|
|
4
|
+
const concurrency = Number.isFinite(max) && max > 0 ? Math.floor(max) : 4;
|
|
5
|
+
|
|
6
|
+
const results = new Array(list.length);
|
|
7
|
+
let nextIndex = 0;
|
|
8
|
+
|
|
9
|
+
const worker = async () => {
|
|
10
|
+
while (true) {
|
|
11
|
+
const i = nextIndex;
|
|
12
|
+
nextIndex += 1;
|
|
13
|
+
if (i >= list.length) return;
|
|
14
|
+
results[i] = await fn(list[i], i);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const workers = [];
|
|
19
|
+
for (let i = 0; i < Math.min(concurrency, list.length); i++) {
|
|
20
|
+
workers.push(worker());
|
|
21
|
+
}
|
|
22
|
+
await Promise.all(workers);
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
|
|
@@ -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';
|
|
@@ -113,14 +113,37 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
113
113
|
}
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
const patchesMtimeMs = async () => {
|
|
117
|
+
// Happy's mobile app (and some other repos) use patch-package and keep patches under `patches/`.
|
|
118
|
+
// If a patch file changes but yarn.lock/package.json do not, Yarn won't reinstall and
|
|
119
|
+
// patch-package won't re-apply the patch, leading to confusing "why isn't my patch wired?"
|
|
120
|
+
// failures later (e.g. during iOS pod install).
|
|
121
|
+
const patchesDir = join(dir, 'patches');
|
|
122
|
+
if (!(await pathExists(patchesDir))) return 0;
|
|
123
|
+
try {
|
|
124
|
+
const entries = await readdir(patchesDir, { withFileTypes: true });
|
|
125
|
+
let max = 0;
|
|
126
|
+
for (const e of entries) {
|
|
127
|
+
if (!e.isFile()) continue;
|
|
128
|
+
if (!e.name.endsWith('.patch')) continue;
|
|
129
|
+
const m = await mtimeMs(join(patchesDir, e.name));
|
|
130
|
+
if (m > max) max = m;
|
|
131
|
+
}
|
|
132
|
+
return max;
|
|
133
|
+
} catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
116
138
|
if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
|
|
117
139
|
const lockM = await mtimeMs(yarnLock);
|
|
118
140
|
const pkgM = await mtimeMs(pkgJson);
|
|
119
141
|
const intM = await mtimeMs(yarnIntegrity);
|
|
120
|
-
|
|
142
|
+
const patchM = await patchesMtimeMs();
|
|
143
|
+
if (!intM || lockM > intM || pkgM > intM || patchM > intM) {
|
|
121
144
|
if (!quiet) {
|
|
122
145
|
// eslint-disable-next-line no-console
|
|
123
|
-
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
|
|
146
|
+
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
|
|
124
147
|
}
|
|
125
148
|
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
126
149
|
}
|
|
@@ -161,14 +184,20 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
|
161
184
|
// - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
|
|
162
185
|
const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
|
|
163
186
|
const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
|
|
164
|
-
if (mode === 'never') {
|
|
165
|
-
return { built: false, reason: 'mode_never' };
|
|
166
|
-
}
|
|
167
187
|
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
168
188
|
const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
|
|
169
189
|
const gitSig = await computeGitWorktreeSignature(cliDir);
|
|
170
190
|
const prev = await readJsonIfExists(buildStatePath);
|
|
171
191
|
|
|
192
|
+
// "never" should prevent rebuild churn, but it must not make the stack unrunnable.
|
|
193
|
+
// If the dist entrypoint is missing, build once even in "never" mode.
|
|
194
|
+
if (mode === 'never') {
|
|
195
|
+
if (await pathExists(distEntrypoint)) {
|
|
196
|
+
return { built: false, reason: 'mode_never' };
|
|
197
|
+
}
|
|
198
|
+
// fallthrough to build
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
if (mode === 'auto') {
|
|
173
202
|
// If dist doesn't exist, we must build.
|
|
174
203
|
if (!(await pathExists(distEntrypoint))) {
|
|
@@ -186,6 +215,18 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
|
186
215
|
const pm = await getComponentPm(cliDir);
|
|
187
216
|
await run(pm.cmd, ['build'], { cwd: cliDir });
|
|
188
217
|
|
|
218
|
+
// Sanity check: happy-cli daemon entrypoint must exist after a successful build.
|
|
219
|
+
// Without this, watch-based rebuilds can restart the daemon into a MODULE_NOT_FOUND crash,
|
|
220
|
+
// which looks like the UI "dies out of nowhere" even though the root cause is missing build output.
|
|
221
|
+
if (!(await pathExists(distEntrypoint))) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[local] happy-cli build finished but did not produce expected entrypoint.\n` +
|
|
224
|
+
`Expected: ${distEntrypoint}\n` +
|
|
225
|
+
`Fix: run the component build directly and inspect its output:\n` +
|
|
226
|
+
` cd "${cliDir}" && ${pm.cmd} build`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
189
230
|
// Persist new build state (best-effort).
|
|
190
231
|
const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
|
|
191
232
|
if (nowSig) {
|
|
@@ -301,17 +342,33 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
|
|
|
301
342
|
}
|
|
302
343
|
|
|
303
344
|
export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
|
|
304
|
-
const
|
|
345
|
+
const usesObjectStyle = typeof dir === 'object' && dir !== null;
|
|
346
|
+
const componentDir = usesObjectStyle ? dir.dir : dir;
|
|
347
|
+
const componentLabel = usesObjectStyle ? dir.label : label;
|
|
348
|
+
const componentBin = usesObjectStyle ? dir.bin : bin;
|
|
349
|
+
const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
|
|
350
|
+
const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
|
|
351
|
+
const options = usesObjectStyle ? (dir.options ?? {}) : {};
|
|
352
|
+
|
|
353
|
+
const pm = await getComponentPm(componentDir);
|
|
305
354
|
if (pm.name === 'yarn') {
|
|
306
|
-
return spawnProc(
|
|
355
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
307
356
|
}
|
|
308
|
-
return spawnProc(
|
|
357
|
+
return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
309
358
|
}
|
|
310
359
|
|
|
311
360
|
export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
|
|
312
|
-
const
|
|
361
|
+
const usesObjectStyle = typeof dir === 'object' && dir !== null;
|
|
362
|
+
const componentDir = usesObjectStyle ? dir.dir : dir;
|
|
363
|
+
const componentLabel = usesObjectStyle ? dir.label : label;
|
|
364
|
+
const componentScript = usesObjectStyle ? dir.script : script;
|
|
365
|
+
const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
|
|
366
|
+
const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
|
|
367
|
+
const options = usesObjectStyle ? (dir.options ?? {}) : {};
|
|
368
|
+
|
|
369
|
+
const pm = await getComponentPm(componentDir);
|
|
313
370
|
if (pm.name === 'yarn') {
|
|
314
|
-
return spawnProc(
|
|
371
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
315
372
|
}
|
|
316
|
-
return spawnProc(
|
|
373
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
317
374
|
}
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
function nextLineBreakIndex(s) {
|
|
4
|
+
const n = s.indexOf('\n');
|
|
5
|
+
const r = s.indexOf('\r');
|
|
6
|
+
if (n < 0) return r;
|
|
7
|
+
if (r < 0) return n;
|
|
8
|
+
return Math.min(n, r);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function consumeLineBreak(buf) {
|
|
12
|
+
if (buf.startsWith('\r\n')) return buf.slice(2);
|
|
13
|
+
if (buf.startsWith('\n') || buf.startsWith('\r')) return buf.slice(1);
|
|
14
|
+
return buf;
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
function writeWithPrefix(stream, prefix, bufState, chunk) {
|
|
4
18
|
const s = chunk.toString();
|
|
5
19
|
bufState.buf += s;
|
|
6
20
|
while (true) {
|
|
7
|
-
const idx = bufState.buf
|
|
21
|
+
const idx = nextLineBreakIndex(bufState.buf);
|
|
8
22
|
if (idx < 0) break;
|
|
9
23
|
const line = bufState.buf.slice(0, idx);
|
|
10
|
-
bufState.buf = bufState.buf.slice(idx
|
|
24
|
+
bufState.buf = consumeLineBreak(bufState.buf.slice(idx));
|
|
11
25
|
stream.write(`${prefix}${line}\n`);
|
|
12
26
|
}
|
|
13
27
|
}
|
|
@@ -133,3 +147,63 @@ export async function runCapture(cmd, args, options = {}) {
|
|
|
133
147
|
});
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
export async function runCaptureResult(cmd, args, options = {}) {
|
|
151
|
+
const { timeoutMs, ...spawnOptions } = options ?? {};
|
|
152
|
+
const startedAt = Date.now();
|
|
153
|
+
return await new Promise((resolvePromise) => {
|
|
154
|
+
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
|
|
155
|
+
let out = '';
|
|
156
|
+
let err = '';
|
|
157
|
+
const t =
|
|
158
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
159
|
+
? setTimeout(() => {
|
|
160
|
+
try {
|
|
161
|
+
proc.kill('SIGKILL');
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
resolvePromise({
|
|
166
|
+
ok: false,
|
|
167
|
+
exitCode: null,
|
|
168
|
+
signal: null,
|
|
169
|
+
out,
|
|
170
|
+
err,
|
|
171
|
+
timedOut: true,
|
|
172
|
+
startedAt,
|
|
173
|
+
finishedAt: Date.now(),
|
|
174
|
+
durationMs: Date.now() - startedAt,
|
|
175
|
+
});
|
|
176
|
+
}, timeoutMs)
|
|
177
|
+
: null;
|
|
178
|
+
proc.stdout?.on('data', (d) => (out += d.toString()));
|
|
179
|
+
proc.stderr?.on('data', (d) => (err += d.toString()));
|
|
180
|
+
proc.on('error', (e) => {
|
|
181
|
+
if (t) clearTimeout(t);
|
|
182
|
+
resolvePromise({
|
|
183
|
+
ok: false,
|
|
184
|
+
exitCode: null,
|
|
185
|
+
signal: null,
|
|
186
|
+
out,
|
|
187
|
+
err: err + (err.endsWith('\n') || !err ? '' : '\n') + String(e) + '\n',
|
|
188
|
+
timedOut: false,
|
|
189
|
+
startedAt,
|
|
190
|
+
finishedAt: Date.now(),
|
|
191
|
+
durationMs: Date.now() - startedAt,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
proc.on('close', (code, signal) => {
|
|
195
|
+
if (t) clearTimeout(t);
|
|
196
|
+
resolvePromise({
|
|
197
|
+
ok: code === 0,
|
|
198
|
+
exitCode: code,
|
|
199
|
+
signal: signal ?? null,
|
|
200
|
+
out,
|
|
201
|
+
err,
|
|
202
|
+
timedOut: false,
|
|
203
|
+
startedAt,
|
|
204
|
+
finishedAt: Date.now(),
|
|
205
|
+
durationMs: Date.now() - startedAt,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { inferRemoteNameForOwner, parseGithubOwner } from '../git/worktrees.mjs';
|
|
2
|
+
import { gitCapture, gitOk, normalizeRemoteName, resolveRemoteDefaultBranch, ensureRemoteRefAvailable } from '../git/git.mjs';
|
|
3
|
+
|
|
4
|
+
async function currentBranchName({ cwd }) {
|
|
5
|
+
const branch = (await gitCapture({ cwd, args: ['branch', '--show-current'] }).catch(() => '')).trim();
|
|
6
|
+
return branch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function branchOwnerPrefix(branch) {
|
|
10
|
+
const b = String(branch ?? '').trim();
|
|
11
|
+
if (!b || !b.includes('/')) return '';
|
|
12
|
+
return b.split('/')[0] ?? '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function inferRemoteFromBranchOwner({ cwd }) {
|
|
16
|
+
const branch = await currentBranchName({ cwd });
|
|
17
|
+
const owner = branchOwnerPrefix(branch);
|
|
18
|
+
if (!owner) return '';
|
|
19
|
+
|
|
20
|
+
// Confirm this "owner" is plausible (matches at least one remote's GitHub owner).
|
|
21
|
+
for (const remoteName of ['upstream', 'origin', 'fork']) {
|
|
22
|
+
try {
|
|
23
|
+
const url = (await gitCapture({ cwd, args: ['remote', 'get-url', remoteName] })).trim();
|
|
24
|
+
const parsedOwner = parseGithubOwner(url);
|
|
25
|
+
if (parsedOwner && parsedOwner === owner) {
|
|
26
|
+
return remoteName;
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fall back to the generic inference helper (it checks remotes in priority order).
|
|
34
|
+
return await inferRemoteNameForOwner({ repoDir: cwd, owner });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveBaseRef({
|
|
38
|
+
cwd,
|
|
39
|
+
baseRefOverride = '',
|
|
40
|
+
baseRemoteOverride = '',
|
|
41
|
+
baseBranchOverride = '',
|
|
42
|
+
stackRemoteFallback = '',
|
|
43
|
+
} = {}) {
|
|
44
|
+
const repoDir = String(cwd ?? '').trim();
|
|
45
|
+
if (!repoDir) {
|
|
46
|
+
throw new Error('[review] missing cwd for base resolution');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!(await gitOk({ cwd: repoDir, args: ['rev-parse', '--is-inside-work-tree'] }))) {
|
|
50
|
+
throw new Error(`[review] not a git repository: ${repoDir}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const explicitRef = String(baseRefOverride ?? '').trim();
|
|
54
|
+
if (explicitRef) {
|
|
55
|
+
return { baseRef: explicitRef, remote: '', branch: '' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const stackFallback = String(stackRemoteFallback ?? '').trim();
|
|
59
|
+
const inferredRemote = await inferRemoteFromBranchOwner({ cwd: repoDir });
|
|
60
|
+
const rawRemote = String(baseRemoteOverride ?? '').trim() || inferredRemote || stackFallback || 'upstream';
|
|
61
|
+
const remote = await normalizeRemoteName({ cwd: repoDir, remote: rawRemote });
|
|
62
|
+
|
|
63
|
+
const branch = String(baseBranchOverride ?? '').trim() || (await resolveRemoteDefaultBranch({ cwd: repoDir, remote }));
|
|
64
|
+
const ok = await ensureRemoteRefAvailable({ cwd: repoDir, remote, branch });
|
|
65
|
+
if (!ok) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[review] unable to resolve base ref refs/remotes/${remote}/${branch} in ${repoDir}\n` +
|
|
68
|
+
`[review] hint: ensure remote "${remote}" exists and has a configured HEAD/default branch (or pass --base-ref).`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { baseRef: `${remote}/${branch}`, remote, branch };
|
|
73
|
+
}
|
|
74
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { runCapture } from '../proc/proc.mjs';
|
|
7
|
+
import { resolveBaseRef } from './base_ref.mjs';
|
|
8
|
+
|
|
9
|
+
async function runGit(cwd, args) {
|
|
10
|
+
await runCapture('git', args, { cwd });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function makeRepoWithRemoteHead() {
|
|
14
|
+
const root = await mkdtemp(join(tmpdir(), 'hs-review-base-ref-'));
|
|
15
|
+
const remote = join(root, 'remote.git');
|
|
16
|
+
const local = join(root, 'local');
|
|
17
|
+
|
|
18
|
+
await runGit(root, ['init', '--bare', remote]);
|
|
19
|
+
await runGit(root, ['init', '-b', 'main', local]);
|
|
20
|
+
await runGit(local, ['config', 'user.email', 'test@example.com']);
|
|
21
|
+
await runGit(local, ['config', 'user.name', 'Test User']);
|
|
22
|
+
await writeFile(join(local, 'file.txt'), 'hello\n', 'utf-8');
|
|
23
|
+
await runGit(local, ['add', '.']);
|
|
24
|
+
await runGit(local, ['commit', '-m', 'initial']);
|
|
25
|
+
await runGit(local, ['remote', 'add', 'upstream', remote]);
|
|
26
|
+
await runGit(local, ['push', '-u', 'upstream', 'main']);
|
|
27
|
+
// Ensure refs/remotes/upstream/HEAD exists.
|
|
28
|
+
await runGit(local, ['remote', 'set-head', 'upstream', '--auto']);
|
|
29
|
+
|
|
30
|
+
return { root, local };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('resolveBaseRef uses explicit --base-ref override', async () => {
|
|
34
|
+
const { root, local } = await makeRepoWithRemoteHead();
|
|
35
|
+
try {
|
|
36
|
+
const res = await resolveBaseRef({ cwd: local, baseRefOverride: 'upstream/main' });
|
|
37
|
+
assert.equal(res.baseRef, 'upstream/main');
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(root, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('resolveBaseRef infers default branch from refs/remotes/<remote>/HEAD', async () => {
|
|
44
|
+
const { root, local } = await makeRepoWithRemoteHead();
|
|
45
|
+
try {
|
|
46
|
+
const res = await resolveBaseRef({ cwd: local, baseRemoteOverride: 'upstream' });
|
|
47
|
+
assert.equal(res.baseRef, 'upstream/main');
|
|
48
|
+
assert.equal(res.remote, 'upstream');
|
|
49
|
+
assert.equal(res.branch, 'main');
|
|
50
|
+
} finally {
|
|
51
|
+
await rm(root, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { runCaptureResult } from '../../proc/proc.mjs';
|
|
2
|
+
|
|
3
|
+
export async function runCodeRabbitReview({ repoDir, baseRef, env }) {
|
|
4
|
+
const args = [
|
|
5
|
+
'review',
|
|
6
|
+
'--plain',
|
|
7
|
+
'--no-color',
|
|
8
|
+
'--type',
|
|
9
|
+
'all',
|
|
10
|
+
'--cwd',
|
|
11
|
+
repoDir,
|
|
12
|
+
];
|
|
13
|
+
if (baseRef) {
|
|
14
|
+
args.push('--base', baseRef);
|
|
15
|
+
}
|
|
16
|
+
const res = await runCaptureResult('coderabbit', args, { cwd: repoDir, env });
|
|
17
|
+
return { ...res, stdout: res.out, stderr: res.err };
|
|
18
|
+
}
|
|
19
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { runCaptureResult } from '../../proc/proc.mjs';
|
|
2
|
+
|
|
3
|
+
export function extractCodexReviewFromJsonl(jsonlText) {
|
|
4
|
+
const lines = String(jsonlText ?? '')
|
|
5
|
+
.split('\n')
|
|
6
|
+
.map((l) => l.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
|
|
9
|
+
// JSONL events typically look like: { "type": "...", "payload": {...} } or similar.
|
|
10
|
+
// We keep this resilient by searching for keys matching the exec output format.
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
let obj = null;
|
|
13
|
+
try {
|
|
14
|
+
obj = JSON.parse(line);
|
|
15
|
+
} catch {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const msg = obj?.msg ?? obj?.payload ?? obj;
|
|
19
|
+
// We’ve observed EventMsg names like "ExitedReviewMode" in Codex protocol events.
|
|
20
|
+
// Accept several shapes:
|
|
21
|
+
// - { msg: { ExitedReviewMode: { review_output: {...} } } }
|
|
22
|
+
// - { type: "ExitedReviewMode", review_output: {...} }
|
|
23
|
+
const exited =
|
|
24
|
+
msg?.ExitedReviewMode ??
|
|
25
|
+
(obj?.type === 'ExitedReviewMode' ? obj : null) ??
|
|
26
|
+
(msg?.type === 'ExitedReviewMode' ? msg : null);
|
|
27
|
+
|
|
28
|
+
const reviewOutput = exited?.review_output ?? exited?.reviewOutput ?? null;
|
|
29
|
+
if (reviewOutput) return reviewOutput;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runCodexReview({ repoDir, baseRef, env, jsonMode }) {
|
|
35
|
+
const args = ['review', '--cd', repoDir, '--color=never'];
|
|
36
|
+
|
|
37
|
+
if (baseRef) {
|
|
38
|
+
args.push('--base', baseRef);
|
|
39
|
+
} else {
|
|
40
|
+
// Codex requires one of --uncommitted/--base/--commit/prompt; baseRef should exist in our flow.
|
|
41
|
+
args.push('--uncommitted');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (jsonMode) {
|
|
45
|
+
args.push('--json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const res = await runCaptureResult('codex', args, { cwd: repoDir, env });
|
|
49
|
+
return { ...res, stdout: res.out, stderr: res.err };
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getComponentsDir, getComponentDir } from '../paths/paths.mjs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function isStackMode(env = process.env) {
|
|
5
|
+
const stack = String(env.HAPPY_STACKS_STACK ?? env.HAPPY_LOCAL_STACK ?? '').trim();
|
|
6
|
+
const envFile = String(env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
|
|
7
|
+
return Boolean(stack && envFile);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function defaultComponentCheckoutDir(rootDir, component) {
|
|
11
|
+
return join(getComponentsDir(rootDir), component);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveDefaultStackReviewComponents({ rootDir, components }) {
|
|
15
|
+
const list = Array.isArray(components) ? components : [];
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const c of list) {
|
|
18
|
+
const effective = getComponentDir(rootDir, c);
|
|
19
|
+
const def = defaultComponentCheckoutDir(rootDir, c);
|
|
20
|
+
if (effective !== def) out.push(c);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolveDefaultStackReviewComponents } from './targets.mjs';
|
|
4
|
+
|
|
5
|
+
test('resolveDefaultStackReviewComponents returns only non-default pinned components', () => {
|
|
6
|
+
const rootDir = '/tmp/hs-root';
|
|
7
|
+
const keys = [
|
|
8
|
+
'HAPPY_STACKS_WORKSPACE_DIR',
|
|
9
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY',
|
|
10
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
|
|
11
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI',
|
|
12
|
+
'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER',
|
|
13
|
+
];
|
|
14
|
+
const old = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
15
|
+
try {
|
|
16
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = '/tmp/hs-root';
|
|
17
|
+
// Default checkouts
|
|
18
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY = '';
|
|
19
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = '';
|
|
20
|
+
// Pinned overrides
|
|
21
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = '/tmp/custom/happy-cli';
|
|
22
|
+
process.env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = '/tmp/custom/happy-server';
|
|
23
|
+
|
|
24
|
+
const comps = resolveDefaultStackReviewComponents({
|
|
25
|
+
rootDir,
|
|
26
|
+
components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(comps.sort(), ['happy-cli', 'happy-server'].sort());
|
|
29
|
+
} finally {
|
|
30
|
+
for (const k of keys) {
|
|
31
|
+
if (old[k] == null) delete process.env[k];
|
|
32
|
+
else process.env[k] = old[k];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|