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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { getLegacyStorageRoot, getStacksStorageRoot } from './paths/paths.mjs';
|
|
6
|
+
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './env/sandbox.mjs';
|
|
7
|
+
|
|
8
|
+
export async function listAllStackNames() {
|
|
9
|
+
const names = new Set(['main']);
|
|
10
|
+
const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
|
|
11
|
+
const roots = [
|
|
12
|
+
// New layout: ~/.happy/stacks/<name>/env
|
|
13
|
+
getStacksStorageRoot(),
|
|
14
|
+
// Legacy layout: ~/.happy/local/stacks/<name>/env
|
|
15
|
+
...(allowLegacy ? [join(getLegacyStorageRoot(), 'stacks')] : []),
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
for (const root of roots) {
|
|
19
|
+
let entries = [];
|
|
20
|
+
try {
|
|
21
|
+
// eslint-disable-next-line no-await-in-loop
|
|
22
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
23
|
+
} catch {
|
|
24
|
+
entries = [];
|
|
25
|
+
}
|
|
26
|
+
for (const ent of entries) {
|
|
27
|
+
if (!ent.isDirectory()) continue;
|
|
28
|
+
const name = ent.name;
|
|
29
|
+
if (!name || name.startsWith('.')) continue;
|
|
30
|
+
const envPath = join(root, name, 'env');
|
|
31
|
+
if (existsSync(envPath)) {
|
|
32
|
+
names.add(name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Array.from(names).sort();
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import qrcodeTerminal from 'qrcode-terminal';
|
|
2
|
+
|
|
3
|
+
export async function renderQrAscii(text, { small = true } = {}) {
|
|
4
|
+
const qrText = String(text ?? '');
|
|
5
|
+
if (!qrText) return { ok: false, lines: [], error: 'empty QR payload' };
|
|
6
|
+
try {
|
|
7
|
+
const out = await new Promise((resolvePromise) => {
|
|
8
|
+
qrcodeTerminal.generate(qrText, { small: Boolean(small) }, (qr) => resolvePromise(String(qr ?? '')));
|
|
9
|
+
});
|
|
10
|
+
// Important: keep whitespace; scanners rely on quiet-zone padding.
|
|
11
|
+
const lines = String(out ?? '').replace(/\r/g, '').split('\n');
|
|
12
|
+
return { ok: true, lines, error: null };
|
|
13
|
+
} catch (e) {
|
|
14
|
+
return { ok: false, lines: [], error: e instanceof Error ? e.message : String(e) };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
|
+
import { getComponentsDir } from './paths/paths.mjs';
|
|
4
|
+
|
|
5
|
+
function isInside(path, dir) {
|
|
6
|
+
const p = resolve(path);
|
|
7
|
+
const d = resolve(dir);
|
|
8
|
+
return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir }) {
|
|
12
|
+
const componentsDir = getComponentsDir(rootDir);
|
|
13
|
+
|
|
14
|
+
const other = serverComponentName === 'happy-server-light' ? 'happy-server' : serverComponentName === 'happy-server' ? 'happy-server-light' : null;
|
|
15
|
+
if (!other) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const otherRepo = resolve(componentsDir, other);
|
|
20
|
+
const otherWts = resolve(componentsDir, '.worktrees', other);
|
|
21
|
+
|
|
22
|
+
if (isInside(serverDir, otherRepo) || isInside(serverDir, otherWts)) {
|
|
23
|
+
return { expected: serverComponentName, actual: other, serverDir };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir }) {
|
|
30
|
+
const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir });
|
|
31
|
+
if (!mismatch) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hint =
|
|
36
|
+
mismatch.expected === 'happy-server-light'
|
|
37
|
+
? 'Fix: either switch flavor (`happys srv use happy-server`) or switch the active checkout for happy-server-light (`happys wt use happy-server-light default` or a worktree under .worktrees/happy-server-light/).'
|
|
38
|
+
: 'Fix: either switch flavor (`happys srv use happy-server-light`) or switch the active checkout for happy-server (`happys wt use happy-server default` or a worktree under .worktrees/happy-server/).';
|
|
39
|
+
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[server] server component dir mismatch:\n` +
|
|
42
|
+
`- selected flavor: ${mismatch.expected}\n` +
|
|
43
|
+
`- but HAPPY_STACKS_COMPONENT_DIR_* points inside: ${mismatch.actual}\n` +
|
|
44
|
+
`- path: ${mismatch.serverDir}\n` +
|
|
45
|
+
`${hint}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectPrismaProvider(schemaText) {
|
|
50
|
+
// Best-effort parse of:
|
|
51
|
+
// datasource db { provider = "sqlite" ... }
|
|
52
|
+
const m = schemaText.match(/datasource\s+db\s*\{[\s\S]*?\bprovider\s*=\s*\"([a-zA-Z0-9_-]+)\"/m);
|
|
53
|
+
return m?.[1] ?? '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function assertServerPrismaProviderMatches({ serverComponentName, serverDir }) {
|
|
57
|
+
const schemaPath = join(serverDir, 'prisma', 'schema.prisma');
|
|
58
|
+
let schemaText = '';
|
|
59
|
+
try {
|
|
60
|
+
schemaText = readFileSync(schemaPath, 'utf-8');
|
|
61
|
+
} catch {
|
|
62
|
+
// If it doesn't exist, skip validation; not every server component necessarily uses Prisma.
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const provider = detectPrismaProvider(schemaText);
|
|
67
|
+
if (!provider) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (serverComponentName === 'happy-server-light' && provider !== 'sqlite') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[server] happy-server-light expects Prisma datasource provider \"sqlite\", but found \"${provider}\" in:\n` +
|
|
74
|
+
`- ${schemaPath}\n` +
|
|
75
|
+
`This usually means you're pointing happy-server-light at an upstream happy-server checkout/PR (Postgres).\n` +
|
|
76
|
+
`Fix: either switch server flavor to happy-server, or point happy-server-light at a fork checkout that keeps sqlite support.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (serverComponentName === 'happy-server' && provider === 'sqlite') {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[server] happy-server expects Prisma datasource provider \"postgresql\", but found \"sqlite\" in:\n` +
|
|
83
|
+
`- ${schemaPath}\n` +
|
|
84
|
+
`Fix: either switch server flavor to happy-server-light, or point happy-server at the full-server checkout.`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
package/scripts/worktrees.mjs
CHANGED
|
@@ -14,10 +14,13 @@ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
|
|
|
14
14
|
import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
|
|
15
15
|
import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
|
|
16
16
|
import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
|
|
17
|
+
import { isSandboxed } from './utils/env/sandbox.mjs';
|
|
17
18
|
import { existsSync } from 'node:fs';
|
|
18
19
|
import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
|
|
19
20
|
import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
|
|
20
21
|
|
|
22
|
+
const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
23
|
+
|
|
21
24
|
function getActiveStackName() {
|
|
22
25
|
return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
23
26
|
}
|
|
@@ -162,41 +165,100 @@ async function installDependencies({ dir }) {
|
|
|
162
165
|
return { installed: false, reason: 'no package manager detected (no package.json)' };
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
// IMPORTANT:
|
|
169
|
+
// When a caller requests --json, stdout must be reserved for JSON output only.
|
|
170
|
+
// Package managers (especially Yarn) write progress to stdout, which would corrupt JSON parsing
|
|
171
|
+
// in wrappers like `stack pr`.
|
|
172
|
+
const jsonMode = Boolean((process.argv ?? []).includes('--json'));
|
|
173
|
+
const runForJson = async (cmd, args) => {
|
|
174
|
+
try {
|
|
175
|
+
const out = await runCapture(cmd, args, { cwd: dir });
|
|
176
|
+
if (out) process.stderr.write(out);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
const out = String(e?.out ?? '');
|
|
179
|
+
const err = String(e?.err ?? '');
|
|
180
|
+
if (out) process.stderr.write(out);
|
|
181
|
+
if (err) process.stderr.write(err);
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
165
186
|
if (pm.kind === 'pnpm') {
|
|
166
|
-
|
|
187
|
+
if (jsonMode) {
|
|
188
|
+
await runForJson('pnpm', ['install', '--frozen-lockfile']);
|
|
189
|
+
} else {
|
|
190
|
+
await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
|
|
191
|
+
}
|
|
167
192
|
return { installed: true, reason: null };
|
|
168
193
|
}
|
|
169
194
|
if (pm.kind === 'yarn') {
|
|
170
195
|
// Works for yarn classic; yarn berry will ignore/translate flags as needed.
|
|
171
|
-
|
|
196
|
+
if (jsonMode) {
|
|
197
|
+
await runForJson('yarn', ['install', '--frozen-lockfile']);
|
|
198
|
+
} else {
|
|
199
|
+
await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
|
|
200
|
+
}
|
|
172
201
|
return { installed: true, reason: null };
|
|
173
202
|
}
|
|
174
203
|
// npm
|
|
175
204
|
if (pm.lockfile && pm.lockfile !== 'package.json') {
|
|
176
|
-
|
|
205
|
+
if (jsonMode) {
|
|
206
|
+
await runForJson('npm', ['ci']);
|
|
207
|
+
} else {
|
|
208
|
+
await run('npm', ['ci'], { cwd: dir });
|
|
209
|
+
}
|
|
177
210
|
} else {
|
|
178
|
-
|
|
211
|
+
if (jsonMode) {
|
|
212
|
+
await runForJson('npm', ['install']);
|
|
213
|
+
} else {
|
|
214
|
+
await run('npm', ['install'], { cwd: dir });
|
|
215
|
+
}
|
|
179
216
|
}
|
|
180
217
|
return { installed: true, reason: null };
|
|
181
218
|
}
|
|
182
219
|
|
|
183
|
-
|
|
220
|
+
function allowNodeModulesSymlinkForComponent(component) {
|
|
221
|
+
const c = String(component ?? '').trim();
|
|
222
|
+
if (!c) return true;
|
|
223
|
+
// Expo/Metro commonly breaks with symlinked node_modules. Avoid symlinks for the Happy UI worktree by default.
|
|
224
|
+
// Override if you *really* want to experiment:
|
|
225
|
+
// HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK=1
|
|
226
|
+
const allowHappySymlink =
|
|
227
|
+
(process.env.HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK ?? process.env.HAPPY_LOCAL_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK ?? '')
|
|
228
|
+
.toString()
|
|
229
|
+
.trim() === '1';
|
|
230
|
+
if (c === 'happy' && !allowHappySymlink) return false;
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode, component }) {
|
|
184
235
|
if (!depsMode || depsMode === 'none') {
|
|
185
236
|
return { mode: 'none', linked: false, installed: false, message: null };
|
|
186
237
|
}
|
|
187
238
|
|
|
188
239
|
// Prefer explicit baseDir if provided, otherwise link from the primary checkout (repoRoot).
|
|
189
240
|
const linkFrom = baseDir || repoRoot;
|
|
241
|
+
const allowSymlink = allowNodeModulesSymlinkForComponent(component);
|
|
190
242
|
|
|
191
243
|
if (depsMode === 'link' || depsMode === 'link-or-install') {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
244
|
+
if (!allowSymlink) {
|
|
245
|
+
const msg =
|
|
246
|
+
`[wt] refusing to symlink node_modules for ${component} (Expo/Metro is often broken by symlinks).\n` +
|
|
247
|
+
`[wt] Fix: use --deps=install (recommended). To override: set HAPPY_STACKS_WT_ALLOW_HAPPY_NODE_MODULES_SYMLINK=1`;
|
|
248
|
+
if (depsMode === 'link') {
|
|
249
|
+
return { mode: depsMode, linked: false, installed: false, message: msg };
|
|
250
|
+
}
|
|
251
|
+
// link-or-install: fall through to install.
|
|
252
|
+
} else {
|
|
253
|
+
const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
|
|
254
|
+
if (res.linked) {
|
|
255
|
+
return { mode: depsMode, linked: true, installed: false, message: null };
|
|
256
|
+
}
|
|
257
|
+
if (depsMode === 'link') {
|
|
258
|
+
return { mode: depsMode, linked: false, installed: false, message: res.reason };
|
|
259
|
+
}
|
|
260
|
+
// fall through to install
|
|
198
261
|
}
|
|
199
|
-
// fall through to install
|
|
200
262
|
}
|
|
201
263
|
|
|
202
264
|
const inst = await installDependencies({ dir: worktreeDir });
|
|
@@ -430,11 +492,9 @@ async function migrateComponentWorktrees({ rootDir, component }) {
|
|
|
430
492
|
}
|
|
431
493
|
|
|
432
494
|
async function cmdMigrate({ rootDir }) {
|
|
433
|
-
const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
434
|
-
|
|
435
495
|
let totalMoved = 0;
|
|
436
496
|
let totalRenamed = 0;
|
|
437
|
-
for (const component of
|
|
497
|
+
for (const component of DEFAULT_COMPONENTS) {
|
|
438
498
|
const res = await migrateComponentWorktrees({ rootDir, component });
|
|
439
499
|
totalMoved += res.moved;
|
|
440
500
|
totalRenamed += res.renamed;
|
|
@@ -679,7 +739,7 @@ async function cmdNew({ rootDir, argv }) {
|
|
|
679
739
|
}
|
|
680
740
|
|
|
681
741
|
const depsMode = parseDepsMode(kv.get('--deps'));
|
|
682
|
-
const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
|
|
742
|
+
const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode, component });
|
|
683
743
|
|
|
684
744
|
const shouldUse = flags.has('--use');
|
|
685
745
|
const force = flags.has('--force');
|
|
@@ -765,8 +825,14 @@ async function cmdPr({ rootDir, argv }) {
|
|
|
765
825
|
throw new Error(`[wt] unable to parse PR: ${prInput}`);
|
|
766
826
|
}
|
|
767
827
|
|
|
768
|
-
const
|
|
769
|
-
const
|
|
828
|
+
const remoteFromArg = (kv.get('--remote') ?? '').trim();
|
|
829
|
+
const canFetchByUrl = !remoteFromArg && pr.owner && pr.repo;
|
|
830
|
+
const fetchTarget = canFetchByUrl ? `https://github.com/${pr.owner}/${pr.repo}.git` : null;
|
|
831
|
+
|
|
832
|
+
// If we can fetch directly from the PR URL's repo, do it. This avoids any assumptions about local
|
|
833
|
+
// remote names like "origin" vs "upstream" and works even when the repo doesn't have that remote set up.
|
|
834
|
+
const remoteName = canFetchByUrl ? '' : await normalizeRemoteName(repoRoot, remoteFromArg || 'upstream');
|
|
835
|
+
const { owner } = canFetchByUrl ? { owner: pr.owner } : await resolveRemoteOwner(repoRoot, remoteName);
|
|
770
836
|
|
|
771
837
|
const slugExtra = sanitizeSlugPart(kv.get('--slug') ?? '');
|
|
772
838
|
const slug = slugExtra ? `pr/${pr.number}-${slugExtra}` : `pr/${pr.number}`;
|
|
@@ -783,7 +849,9 @@ async function cmdPr({ rootDir, argv }) {
|
|
|
783
849
|
}
|
|
784
850
|
|
|
785
851
|
// Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
|
|
786
|
-
|
|
852
|
+
// In sandbox mode, be more aggressive: the entire workspace is disposable, so it's safe to
|
|
853
|
+
// reset an existing local PR branch to the fetched PR head if needed.
|
|
854
|
+
const force = flags.has('--force') || isSandboxed();
|
|
787
855
|
let oldHead = null;
|
|
788
856
|
const prRef = `refs/pull/${pr.number}/head`;
|
|
789
857
|
if (exists) {
|
|
@@ -799,14 +867,17 @@ async function cmdPr({ rootDir, argv }) {
|
|
|
799
867
|
}
|
|
800
868
|
|
|
801
869
|
oldHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
|
|
802
|
-
await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
|
|
870
|
+
await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
|
|
803
871
|
const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
|
|
804
872
|
|
|
805
873
|
const isAncestor = await gitOk(repoRoot, ['merge-base', '--is-ancestor', oldHead, newTip]);
|
|
806
874
|
if (!isAncestor && !force) {
|
|
875
|
+
const hint = fetchTarget
|
|
876
|
+
? `[wt] re-run with: happys wt pr ${component} ${pr.number} --update --force`
|
|
877
|
+
: `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`;
|
|
807
878
|
throw new Error(
|
|
808
879
|
`[wt] PR update is not a fast-forward (likely force-push) for ${branchName}\n` +
|
|
809
|
-
|
|
880
|
+
hint
|
|
810
881
|
);
|
|
811
882
|
}
|
|
812
883
|
|
|
@@ -837,16 +908,22 @@ async function cmdPr({ rootDir, argv }) {
|
|
|
837
908
|
);
|
|
838
909
|
}
|
|
839
910
|
} else {
|
|
840
|
-
await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
|
|
911
|
+
await git(repoRoot, ['fetch', '--quiet', fetchTarget ?? remoteName, prRef]);
|
|
841
912
|
const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
|
|
842
913
|
|
|
843
914
|
const branchExists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
|
|
844
915
|
if (branchExists) {
|
|
845
916
|
if (!force) {
|
|
846
|
-
|
|
917
|
+
// If the branch already points at the fetched PR tip, we can safely just attach a worktree.
|
|
918
|
+
const branchHead = (await git(repoRoot, ['rev-parse', branchName])).trim();
|
|
919
|
+
if (branchHead !== newTip) {
|
|
920
|
+
throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
|
|
921
|
+
}
|
|
922
|
+
await git(repoRoot, ['worktree', 'add', destPath, branchName]);
|
|
923
|
+
} else {
|
|
924
|
+
await git(repoRoot, ['branch', '-f', branchName, newTip]);
|
|
925
|
+
await git(repoRoot, ['worktree', 'add', destPath, branchName]);
|
|
847
926
|
}
|
|
848
|
-
await git(repoRoot, ['branch', '-f', branchName, newTip]);
|
|
849
|
-
await git(repoRoot, ['worktree', 'add', destPath, branchName]);
|
|
850
927
|
} else {
|
|
851
928
|
// Create worktree at PR head (new local branch).
|
|
852
929
|
await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, newTip]);
|
|
@@ -855,7 +932,7 @@ async function cmdPr({ rootDir, argv }) {
|
|
|
855
932
|
|
|
856
933
|
// Optional deps handling (useful when PR branches add/change dependencies).
|
|
857
934
|
const depsMode = parseDepsMode(kv.get('--deps'));
|
|
858
|
-
const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode });
|
|
935
|
+
const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode, component });
|
|
859
936
|
|
|
860
937
|
const shouldUse = flags.has('--use');
|
|
861
938
|
if (shouldUse) {
|
|
@@ -1422,8 +1499,6 @@ async function cmdCursor({ rootDir, argv }) {
|
|
|
1422
1499
|
throw new Error("[wt] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
|
|
1423
1500
|
}
|
|
1424
1501
|
|
|
1425
|
-
const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
1426
|
-
|
|
1427
1502
|
async function cmdSyncAll({ rootDir, argv }) {
|
|
1428
1503
|
const { flags, kv } = parseArgs(argv);
|
|
1429
1504
|
const json = wantsJson(argv, { flags });
|
|
@@ -1556,43 +1631,48 @@ async function cmdNewInteractive({ rootDir, argv }) {
|
|
|
1556
1631
|
});
|
|
1557
1632
|
}
|
|
1558
1633
|
|
|
1559
|
-
async function
|
|
1560
|
-
const component = args[0];
|
|
1561
|
-
if (!component) {
|
|
1562
|
-
throw new Error('[wt] usage: happys wt list <component>');
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1634
|
+
async function cmdListOne({ rootDir, component }) {
|
|
1565
1635
|
const wtRoot = getWorktreesRoot(rootDir);
|
|
1566
1636
|
const dir = join(wtRoot, component);
|
|
1637
|
+
const key = componentDirEnvKey(component);
|
|
1638
|
+
const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
|
|
1639
|
+
|
|
1567
1640
|
if (!(await pathExists(dir))) {
|
|
1568
|
-
return { component, activeDir:
|
|
1641
|
+
return { component, activeDir: active, worktrees: [] };
|
|
1569
1642
|
}
|
|
1570
1643
|
|
|
1571
|
-
const
|
|
1644
|
+
const worktrees = [];
|
|
1572
1645
|
const walk = async (d) => {
|
|
1646
|
+
// In git worktrees, ".git" is usually a file that points to the shared git dir.
|
|
1647
|
+
// If this is a worktree root, record it and do not descend into it (avoids traversing huge trees like node_modules).
|
|
1648
|
+
if (await pathExists(join(d, '.git'))) {
|
|
1649
|
+
worktrees.push(d);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1573
1652
|
const entries = await readdir(d, { withFileTypes: true });
|
|
1574
1653
|
for (const e of entries) {
|
|
1575
|
-
if (!e.isDirectory())
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
leafs.push(p);
|
|
1580
|
-
await walk(p);
|
|
1654
|
+
if (!e.isDirectory()) continue;
|
|
1655
|
+
if (e.name === 'node_modules') continue;
|
|
1656
|
+
if (e.name.startsWith('.')) continue;
|
|
1657
|
+
await walk(join(d, e.name));
|
|
1581
1658
|
}
|
|
1582
1659
|
};
|
|
1583
1660
|
await walk(dir);
|
|
1584
|
-
|
|
1661
|
+
worktrees.sort();
|
|
1585
1662
|
|
|
1586
|
-
|
|
1587
|
-
|
|
1663
|
+
return { component, activeDir: active, worktrees };
|
|
1664
|
+
}
|
|
1588
1665
|
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1666
|
+
async function cmdList({ rootDir, args }) {
|
|
1667
|
+
const component = args[0];
|
|
1668
|
+
if (!component) {
|
|
1669
|
+
const results = [];
|
|
1670
|
+
for (const c of DEFAULT_COMPONENTS) {
|
|
1671
|
+
results.push(await cmdListOne({ rootDir, component: c }));
|
|
1593
1672
|
}
|
|
1673
|
+
return { components: DEFAULT_COMPONENTS, results };
|
|
1594
1674
|
}
|
|
1595
|
-
return {
|
|
1675
|
+
return await cmdListOne({ rootDir, component });
|
|
1596
1676
|
}
|
|
1597
1677
|
|
|
1598
1678
|
async function main() {
|
|
@@ -1616,7 +1696,7 @@ async function main() {
|
|
|
1616
1696
|
' happys wt migrate [--json]',
|
|
1617
1697
|
' happys wt sync <component> [--remote=<name>] [--json]',
|
|
1618
1698
|
' happys wt sync-all [--remote=<name>] [--json]',
|
|
1619
|
-
' happys wt list
|
|
1699
|
+
' happys wt list [component] [--json]',
|
|
1620
1700
|
' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--force] [--interactive|-i] [--json]',
|
|
1621
1701
|
' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
|
|
1622
1702
|
' happys wt pr <component> <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--stash|--stash-keep] [--force] [--json]',
|
|
@@ -1637,7 +1717,7 @@ async function main() {
|
|
|
1637
1717
|
' "<absolute path>": explicit checkout path',
|
|
1638
1718
|
'',
|
|
1639
1719
|
'components:',
|
|
1640
|
-
'
|
|
1720
|
+
` ${DEFAULT_COMPONENTS.join(' | ')}`,
|
|
1641
1721
|
].join('\n'),
|
|
1642
1722
|
});
|
|
1643
1723
|
return;
|
|
@@ -1796,9 +1876,15 @@ async function main() {
|
|
|
1796
1876
|
if (json) {
|
|
1797
1877
|
printResult({ json, data: res });
|
|
1798
1878
|
} else {
|
|
1799
|
-
const
|
|
1800
|
-
|
|
1801
|
-
|
|
1879
|
+
const results = Array.isArray(res?.results) ? res.results : [res];
|
|
1880
|
+
const lines = [];
|
|
1881
|
+
for (const r of results) {
|
|
1882
|
+
lines.push(`[wt] ${r.component} worktrees:`);
|
|
1883
|
+
lines.push(`- active: ${r.activeDir}`);
|
|
1884
|
+
for (const p of r.worktrees) {
|
|
1885
|
+
lines.push(`- ${p}`);
|
|
1886
|
+
}
|
|
1887
|
+
lines.push('');
|
|
1802
1888
|
}
|
|
1803
1889
|
printResult({ json: false, text: lines.join('\n') });
|
|
1804
1890
|
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { ensureDepsInstalled, pmSpawnBin } from '../proc/pm.mjs';
|
|
2
|
-
import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, wantsExpoClearCache, writePidState } from '../expo/expo.mjs';
|
|
3
|
-
import { pickDevMetroPort, resolveStackUiDevPortStart } from './server.mjs';
|
|
4
|
-
import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
|
|
5
|
-
import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
6
|
-
|
|
7
|
-
export async function startDevExpoWebUi({
|
|
8
|
-
startUi,
|
|
9
|
-
uiDir,
|
|
10
|
-
autostart,
|
|
11
|
-
baseEnv,
|
|
12
|
-
apiServerUrl,
|
|
13
|
-
restart,
|
|
14
|
-
stackMode,
|
|
15
|
-
runtimeStatePath,
|
|
16
|
-
stackName,
|
|
17
|
-
envPath,
|
|
18
|
-
children,
|
|
19
|
-
spawnOptions = {},
|
|
20
|
-
}) {
|
|
21
|
-
if (!startUi) return { ok: true, skipped: true, reason: 'disabled' };
|
|
22
|
-
|
|
23
|
-
await ensureDepsInstalled(uiDir, 'happy');
|
|
24
|
-
const uiEnv = { ...baseEnv };
|
|
25
|
-
delete uiEnv.CI;
|
|
26
|
-
uiEnv.EXPO_PUBLIC_HAPPY_SERVER_URL = apiServerUrl;
|
|
27
|
-
uiEnv.EXPO_PUBLIC_DEBUG = uiEnv.EXPO_PUBLIC_DEBUG ?? '1';
|
|
28
|
-
|
|
29
|
-
// We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
|
|
30
|
-
uiEnv.EXPO_NO_BROWSER = '1';
|
|
31
|
-
uiEnv.BROWSER = 'none';
|
|
32
|
-
|
|
33
|
-
const uiPaths = getExpoStatePaths({
|
|
34
|
-
baseDir: autostart.baseDir,
|
|
35
|
-
kind: 'ui-dev',
|
|
36
|
-
projectDir: uiDir,
|
|
37
|
-
stateFileName: 'ui.state.json',
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
await ensureExpoIsolationEnv({
|
|
41
|
-
env: uiEnv,
|
|
42
|
-
stateDir: uiPaths.stateDir,
|
|
43
|
-
expoHomeDir: uiPaths.expoHomeDir,
|
|
44
|
-
tmpDir: uiPaths.tmpDir,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
const uiRunning = await isStateProcessRunning(uiPaths.statePath);
|
|
48
|
-
const uiAlreadyRunning = Boolean(uiRunning.running);
|
|
49
|
-
|
|
50
|
-
if (uiAlreadyRunning && !restart) {
|
|
51
|
-
const pid = Number(uiRunning.state?.pid);
|
|
52
|
-
const port = Number(uiRunning.state?.port);
|
|
53
|
-
if (stackMode && runtimeStatePath && Number.isFinite(pid) && pid > 1) {
|
|
54
|
-
await recordStackRuntimeUpdate(runtimeStatePath, {
|
|
55
|
-
processes: { expoWebPid: pid },
|
|
56
|
-
expo: { webPort: Number.isFinite(port) && port > 0 ? port : null },
|
|
57
|
-
}).catch(() => {});
|
|
58
|
-
}
|
|
59
|
-
return {
|
|
60
|
-
ok: true,
|
|
61
|
-
skipped: true,
|
|
62
|
-
reason: 'already_running',
|
|
63
|
-
pid: Number.isFinite(pid) ? pid : null,
|
|
64
|
-
port: Number.isFinite(port) ? port : null,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const strategy =
|
|
69
|
-
(baseEnv.HAPPY_STACKS_UI_DEV_PORT_STRATEGY ?? baseEnv.HAPPY_LOCAL_UI_DEV_PORT_STRATEGY ?? 'ephemeral').toString().trim() ||
|
|
70
|
-
'ephemeral';
|
|
71
|
-
const stable = strategy === 'stable';
|
|
72
|
-
const startPort = stackMode && stable ? resolveStackUiDevPortStart({ env: baseEnv, stackName }) : 8081;
|
|
73
|
-
const metroPort = await pickDevMetroPort({ startPort });
|
|
74
|
-
uiEnv.RCT_METRO_PORT = String(metroPort);
|
|
75
|
-
|
|
76
|
-
const uiArgs = ['start', '--web', '--port', String(metroPort)];
|
|
77
|
-
if (wantsExpoClearCache({ env: baseEnv })) {
|
|
78
|
-
uiArgs.push('--clear');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (restart && uiRunning.state?.pid) {
|
|
82
|
-
const prevPid = Number(uiRunning.state.pid);
|
|
83
|
-
const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-web', json: true });
|
|
84
|
-
if (!res.killed) {
|
|
85
|
-
// eslint-disable-next-line no-console
|
|
86
|
-
console.warn(
|
|
87
|
-
`[local] ui: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
|
|
88
|
-
`[local] ui: continuing by starting a new Expo process on a free port.`
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// eslint-disable-next-line no-console
|
|
94
|
-
console.log(`[local] ui: starting Expo web (metro port=${metroPort})`);
|
|
95
|
-
const ui = await pmSpawnBin({ label: 'ui', dir: uiDir, bin: 'expo', args: uiArgs, env: uiEnv, options: spawnOptions });
|
|
96
|
-
children.push(ui);
|
|
97
|
-
|
|
98
|
-
if (stackMode && runtimeStatePath) {
|
|
99
|
-
await recordStackRuntimeUpdate(runtimeStatePath, {
|
|
100
|
-
processes: { expoWebPid: ui.pid },
|
|
101
|
-
expo: { webPort: metroPort },
|
|
102
|
-
}).catch(() => {});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
await writePidState(uiPaths.statePath, { pid: ui.pid, port: metroPort, uiDir, startedAt: new Date().toISOString() });
|
|
107
|
-
} catch {
|
|
108
|
-
// ignore
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { ok: true, skipped: false, pid: ui.pid, port: metroPort, proc: ui };
|
|
112
|
-
}
|