happy-stacks 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -25
- package/bin/happys.mjs +116 -17
- 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 +59 -208
- package/scripts/build.mjs +58 -12
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +168 -20
- package/scripts/dev.mjs +196 -70
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +105 -78
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +8 -14
- package/scripts/install.mjs +119 -23
- package/scripts/lint.mjs +31 -32
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +93 -108
- 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 +95 -21
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +19 -29
- package/scripts/setup.mjs +63 -160
- package/scripts/setup_pr.mjs +592 -52
- package/scripts/stack.mjs +608 -200
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +44 -11
- package/scripts/test.mjs +52 -36
- package/scripts/tui.mjs +314 -74
- package/scripts/typecheck.mjs +31 -32
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- 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/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
- package/scripts/utils/auth/sources.mjs +38 -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/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
- 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 → dev/server.mjs} +22 -32
- package/scripts/utils/dev_auth_key.mjs +1 -1
- package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
- package/scripts/utils/handy_master_secret.mjs +2 -2
- 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/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +56 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -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/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- 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/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
- package/scripts/utils/server/urls.mjs +101 -0
- package/scripts/utils/server/validate.mjs +88 -0
- package/scripts/utils/service/autostart_darwin.mjs +182 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
- package/scripts/utils/stack_context.mjs +3 -3
- package/scripts/utils/stack_runtime_state.mjs +1 -1
- package/scripts/utils/stacks.mjs +2 -2
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/validate.mjs +1 -1
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +171 -113
- package/scripts/utils/auth_sources.mjs +0 -12
- package/scripts/utils/dev_expo_web.mjs +0 -112
- package/scripts/utils/localhost_host.mjs +0 -17
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
- /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
- /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
5
|
+
import { requirePnpm } from './pm.mjs';
|
|
6
|
+
|
|
7
|
+
export async function detectPackageManagerCmd(dir) {
|
|
8
|
+
if (await pathExists(join(dir, 'yarn.lock'))) {
|
|
9
|
+
return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
|
|
10
|
+
}
|
|
11
|
+
await requirePnpm();
|
|
12
|
+
return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readPackageJsonScripts(dir) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await readFile(join(dir, 'package.json'), 'utf-8');
|
|
18
|
+
const pkg = JSON.parse(raw);
|
|
19
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
20
|
+
return scripts;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pickFirstScript(scripts, candidates) {
|
|
27
|
+
if (!scripts) return null;
|
|
28
|
+
const list = Array.isArray(candidates) ? candidates : [];
|
|
29
|
+
return list.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
@@ -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,36 +1,20 @@
|
|
|
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
|
-
import { pathExists } from '
|
|
7
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
8
|
+
import { readJsonIfExists, writeJsonAtomic } from '../fs/json.mjs';
|
|
8
9
|
import { run, runCapture, spawnProc } from './proc.mjs';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { commandExists } from './commands.mjs';
|
|
11
|
+
import { getDefaultAutostartPaths, getHappyStacksHomeDir } from '../paths/paths.mjs';
|
|
12
|
+
import { resolveInstalledPath, resolveInstalledCliRoot } from '../paths/runtime.mjs';
|
|
11
13
|
|
|
12
14
|
function sha256Hex(s) {
|
|
13
15
|
return createHash('sha256').update(String(s ?? ''), 'utf-8').digest('hex');
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
async function readJsonIfExists(path) {
|
|
17
|
-
try {
|
|
18
|
-
if (!path || !existsSync(path)) return null;
|
|
19
|
-
const raw = await readFile(path, 'utf-8');
|
|
20
|
-
return JSON.parse(raw);
|
|
21
|
-
} catch {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function writeJsonAtomic(path, value) {
|
|
27
|
-
const dir = dirname(path);
|
|
28
|
-
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
29
|
-
const tmp = join(dir, `.tmp.${Date.now()}.${Math.random().toString(16).slice(2)}.json`);
|
|
30
|
-
await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
|
|
31
|
-
await rename(tmp, path);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
18
|
function resolveBuildStatePath({ label, dir }) {
|
|
35
19
|
const homeDir = getHappyStacksHomeDir();
|
|
36
20
|
const key = sha256Hex(resolve(dir));
|
|
@@ -56,15 +40,6 @@ async function computeGitWorktreeSignature(dir) {
|
|
|
56
40
|
}
|
|
57
41
|
}
|
|
58
42
|
|
|
59
|
-
async function commandExists(cmd, options = {}) {
|
|
60
|
-
try {
|
|
61
|
-
await runCapture(cmd, ['--version'], options);
|
|
62
|
-
return true;
|
|
63
|
-
} catch {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
43
|
export async function requirePnpm() {
|
|
69
44
|
if (await commandExists('pnpm')) {
|
|
70
45
|
return;
|
|
@@ -138,14 +113,37 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
|
|
|
138
113
|
}
|
|
139
114
|
};
|
|
140
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
|
+
|
|
141
138
|
if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
|
|
142
139
|
const lockM = await mtimeMs(yarnLock);
|
|
143
140
|
const pkgM = await mtimeMs(pkgJson);
|
|
144
141
|
const intM = await mtimeMs(yarnIntegrity);
|
|
145
|
-
|
|
142
|
+
const patchM = await patchesMtimeMs();
|
|
143
|
+
if (!intM || lockM > intM || pkgM > intM || patchM > intM) {
|
|
146
144
|
if (!quiet) {
|
|
147
145
|
// eslint-disable-next-line no-console
|
|
148
|
-
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
|
|
146
|
+
console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
|
|
149
147
|
}
|
|
150
148
|
await run(pm.cmd, ['install'], { cwd: dir, stdio });
|
|
151
149
|
}
|
|
@@ -186,14 +184,20 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
|
186
184
|
// - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
|
|
187
185
|
const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
|
|
188
186
|
const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
|
|
189
|
-
if (mode === 'never') {
|
|
190
|
-
return { built: false, reason: 'mode_never' };
|
|
191
|
-
}
|
|
192
187
|
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
193
188
|
const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
|
|
194
189
|
const gitSig = await computeGitWorktreeSignature(cliDir);
|
|
195
190
|
const prev = await readJsonIfExists(buildStatePath);
|
|
196
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
|
+
|
|
197
201
|
if (mode === 'auto') {
|
|
198
202
|
// If dist doesn't exist, we must build.
|
|
199
203
|
if (!(await pathExists(distEntrypoint))) {
|
|
@@ -211,6 +215,18 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
|
|
|
211
215
|
const pm = await getComponentPm(cliDir);
|
|
212
216
|
await run(pm.cmd, ['build'], { cwd: cliDir });
|
|
213
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
|
+
|
|
214
230
|
// Persist new build state (best-effort).
|
|
215
231
|
const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
|
|
216
232
|
if (nowSig) {
|
|
@@ -258,147 +274,101 @@ HAPPYS="$BIN_DIR/happys"
|
|
|
258
274
|
if [[ -x "$HAPPYS" ]]; then
|
|
259
275
|
exec "$HAPPYS" happy "$@"
|
|
260
276
|
fi
|
|
261
|
-
|
|
277
|
+
|
|
278
|
+
# Fallback: run happy-stacks from runtime install if present.
|
|
279
|
+
HOME_DIR="\${HAPPY_STACKS_HOME_DIR:-\${HAPPY_LOCAL_HOME_DIR:-$HOME/.happy-stacks}}"
|
|
280
|
+
RUNTIME="$HOME_DIR/runtime/node_modules/happy-stacks/bin/happys.mjs"
|
|
281
|
+
if [[ -f "$RUNTIME" ]]; then
|
|
282
|
+
exec node "$RUNTIME" happy "$@"
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
echo "error: cannot find happys shim or runtime install" >&2
|
|
286
|
+
exit 1
|
|
262
287
|
`;
|
|
263
288
|
|
|
264
|
-
|
|
289
|
+
const writeIfChanged = async (path, text) => {
|
|
290
|
+
let existing = '';
|
|
291
|
+
try {
|
|
292
|
+
existing = await readFile(path, 'utf-8');
|
|
293
|
+
} catch {
|
|
294
|
+
existing = '';
|
|
295
|
+
}
|
|
296
|
+
if (existing === text) return false;
|
|
297
|
+
await writeFile(path, text, 'utf-8');
|
|
298
|
+
return true;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
await writeIfChanged(happyShim, shim);
|
|
265
302
|
await chmod(happyShim, 0o755).catch(() => {});
|
|
266
303
|
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
304
|
+
// happys shim: use node + CLI root; if runtime install exists, prefer it.
|
|
305
|
+
const cliRoot = resolveInstalledCliRoot(rootDir);
|
|
306
|
+
const happysShimText = `#!/bin/bash
|
|
307
|
+
set -euo pipefail
|
|
308
|
+
exec node "${resolveInstalledPath(rootDir, 'bin/happys.mjs')}" "$@"
|
|
309
|
+
`;
|
|
310
|
+
await writeIfChanged(happysShim, happysShimText);
|
|
311
|
+
await chmod(happysShim, 0o755).catch(() => {});
|
|
312
|
+
|
|
313
|
+
// If user’s PATH points at a legacy install path, try to make it sane (best-effort).
|
|
314
|
+
const entries = getPathEntries();
|
|
315
|
+
const legacyBin = join(homedir(), '.happy-stacks', 'bin');
|
|
316
|
+
const newBin = join(getDefaultAutostartPaths().baseDir, 'bin');
|
|
317
|
+
if (entries.some((p) => isPathInside(p, legacyBin)) && !entries.some((p) => isPathInside(p, newBin))) {
|
|
270
318
|
// eslint-disable-next-line no-console
|
|
271
|
-
console.log(`[local] note:
|
|
319
|
+
console.log(`[local] note: your PATH includes ${legacyBin}; recommended path is ${newBin}`);
|
|
272
320
|
}
|
|
273
|
-
}
|
|
274
321
|
|
|
275
|
-
|
|
276
|
-
const pm = await getComponentPm(dir);
|
|
277
|
-
if (pm.name === 'yarn') {
|
|
278
|
-
return spawnProc(label, pm.cmd, ['-s', script], env, { ...options, cwd: dir });
|
|
279
|
-
}
|
|
280
|
-
return spawnProc(label, pm.cmd, ['--silent', script], env, { ...options, cwd: dir });
|
|
322
|
+
return { ok: true, cliRoot, binDir, happyShim, happysShim };
|
|
281
323
|
}
|
|
282
324
|
|
|
283
|
-
export async function
|
|
284
|
-
const
|
|
285
|
-
if (pm.name === 'yarn') {
|
|
286
|
-
return spawnProc(label, pm.cmd, [bin, ...args], env, { ...options, cwd: dir });
|
|
287
|
-
}
|
|
288
|
-
return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { ...options, cwd: dir });
|
|
289
|
-
}
|
|
325
|
+
export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
|
|
326
|
+
const usesObjectStyle = typeof dirOrOpts === 'object' && dirOrOpts !== null;
|
|
290
327
|
|
|
291
|
-
|
|
292
|
-
const
|
|
328
|
+
const dir = usesObjectStyle ? dirOrOpts.dir : dirOrOpts;
|
|
329
|
+
const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
|
|
330
|
+
const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
|
|
331
|
+
|
|
332
|
+
const env = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
|
|
333
|
+
const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
|
|
293
334
|
const stdio = quiet ? 'ignore' : 'inherit';
|
|
335
|
+
|
|
336
|
+
const pm = await getComponentPm(dir);
|
|
294
337
|
if (pm.name === 'yarn') {
|
|
295
|
-
await run(pm.cmd, [bin, ...args], {
|
|
338
|
+
await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
|
|
296
339
|
return;
|
|
297
340
|
}
|
|
298
|
-
await run(pm.cmd, ['exec', bin, ...args], {
|
|
341
|
+
await run(pm.cmd, ['exec', bin, ...args], { cwd: dir, env, stdio });
|
|
299
342
|
}
|
|
300
343
|
|
|
301
|
-
export async function
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
344
|
+
export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
|
|
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 ?? {}) : {};
|
|
305
352
|
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
stderrPath,
|
|
310
|
-
plistPath,
|
|
311
|
-
primaryLabel,
|
|
312
|
-
legacyLabel,
|
|
313
|
-
primaryPlistPath,
|
|
314
|
-
legacyPlistPath,
|
|
315
|
-
primaryStdoutPath,
|
|
316
|
-
primaryStderrPath,
|
|
317
|
-
legacyStdoutPath,
|
|
318
|
-
legacyStderrPath,
|
|
319
|
-
} = getDefaultAutostartPaths();
|
|
320
|
-
await mkdir(logsDir, { recursive: true });
|
|
321
|
-
|
|
322
|
-
const nodePath = process.env.HAPPY_STACKS_NODE?.trim()
|
|
323
|
-
? process.env.HAPPY_STACKS_NODE.trim()
|
|
324
|
-
: process.env.HAPPY_LOCAL_NODE?.trim()
|
|
325
|
-
? process.env.HAPPY_LOCAL_NODE.trim()
|
|
326
|
-
: process.execPath;
|
|
327
|
-
const installedRoot = resolveInstalledCliRoot(rootDir);
|
|
328
|
-
const happysEntrypoint = resolveInstalledPath(rootDir, join('bin', 'happys.mjs'));
|
|
329
|
-
const happysShim = join(getHappyStacksHomeDir(), 'bin', 'happys');
|
|
330
|
-
const useShim = existsSync(happysShim);
|
|
331
|
-
|
|
332
|
-
// Ensure we write to the plist path that matches the label we're installing, instead of the
|
|
333
|
-
// "active" plist path (which might be legacy and cause filename/label mismatches).
|
|
334
|
-
const resolvedPlistPath =
|
|
335
|
-
label === primaryLabel ? primaryPlistPath : label === legacyLabel ? legacyPlistPath : join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
336
|
-
const resolvedStdoutPath = label === primaryLabel ? primaryStdoutPath : label === legacyLabel ? legacyStdoutPath : stdoutPath;
|
|
337
|
-
const resolvedStderrPath = label === primaryLabel ? primaryStderrPath : label === legacyLabel ? legacyStderrPath : stderrPath;
|
|
338
|
-
|
|
339
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
340
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
341
|
-
<plist version="1.0">
|
|
342
|
-
<dict>
|
|
343
|
-
<key>Label</key>
|
|
344
|
-
<string>${label}</string>
|
|
345
|
-
<key>ProgramArguments</key>
|
|
346
|
-
<array>
|
|
347
|
-
${useShim ? `<string>${happysShim}</string>` : `<string>${nodePath}</string>\n <string>${happysEntrypoint}</string>`}
|
|
348
|
-
<string>start</string>
|
|
349
|
-
</array>
|
|
350
|
-
<key>WorkingDirectory</key>
|
|
351
|
-
<string>${installedRoot}</string>
|
|
352
|
-
<key>RunAtLoad</key>
|
|
353
|
-
<true/>
|
|
354
|
-
<key>KeepAlive</key>
|
|
355
|
-
<true/>
|
|
356
|
-
<key>StandardOutPath</key>
|
|
357
|
-
<string>${resolvedStdoutPath}</string>
|
|
358
|
-
<key>StandardErrorPath</key>
|
|
359
|
-
<string>${resolvedStderrPath}</string>
|
|
360
|
-
<key>EnvironmentVariables</key>
|
|
361
|
-
<dict>
|
|
362
|
-
${Object.entries(env)
|
|
363
|
-
.map(([k, v]) => ` <key>${k}</key>\n <string>${String(v)}</string>`)
|
|
364
|
-
.join('\n')}
|
|
365
|
-
</dict>
|
|
366
|
-
</dict>
|
|
367
|
-
</plist>
|
|
368
|
-
`;
|
|
369
|
-
|
|
370
|
-
await mkdir(dirname(resolvedPlistPath), { recursive: true });
|
|
371
|
-
await writeFile(resolvedPlistPath, plist, 'utf-8');
|
|
372
|
-
|
|
373
|
-
// Best-effort (works on most macOS setups). If it fails, the plist still exists and can be loaded manually.
|
|
374
|
-
try {
|
|
375
|
-
await run('launchctl', ['unload', '-w', resolvedPlistPath]);
|
|
376
|
-
} catch {
|
|
377
|
-
// ignore
|
|
353
|
+
const pm = await getComponentPm(componentDir);
|
|
354
|
+
if (pm.name === 'yarn') {
|
|
355
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
378
356
|
}
|
|
379
|
-
|
|
357
|
+
return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
380
358
|
}
|
|
381
359
|
|
|
382
|
-
export async function
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
395
|
-
if (uid != null) {
|
|
396
|
-
await run('launchctl', ['bootout', `gui/${uid}/${label}`]);
|
|
397
|
-
}
|
|
398
|
-
} catch {
|
|
399
|
-
// ignore
|
|
400
|
-
}
|
|
360
|
+
export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
|
|
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);
|
|
370
|
+
if (pm.name === 'yarn') {
|
|
371
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
401
372
|
}
|
|
402
|
-
|
|
403
|
-
console.log(`[local] autostart disabled (${label})`);
|
|
373
|
+
return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
|
|
404
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
|
}
|
|
@@ -132,3 +146,64 @@ export async function runCapture(cmd, args, options = {}) {
|
|
|
132
146
|
});
|
|
133
147
|
});
|
|
134
148
|
}
|
|
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
|
+
|