happy-stacks 0.0.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.
Files changed (67) hide show
  1. package/README.md +314 -0
  2. package/bin/happys.mjs +168 -0
  3. package/docs/menubar.md +186 -0
  4. package/docs/mobile-ios.md +134 -0
  5. package/docs/remote-access.md +43 -0
  6. package/docs/server-flavors.md +79 -0
  7. package/docs/stacks.md +218 -0
  8. package/docs/tauri.md +62 -0
  9. package/docs/worktrees-and-forks.md +395 -0
  10. package/extras/swiftbar/auth-login.sh +31 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +218 -0
  12. package/extras/swiftbar/icons/happy-green.png +0 -0
  13. package/extras/swiftbar/icons/happy-orange.png +0 -0
  14. package/extras/swiftbar/icons/happy-red.png +0 -0
  15. package/extras/swiftbar/icons/logo-white.png +0 -0
  16. package/extras/swiftbar/install.sh +191 -0
  17. package/extras/swiftbar/lib/git.sh +330 -0
  18. package/extras/swiftbar/lib/icons.sh +105 -0
  19. package/extras/swiftbar/lib/render.sh +774 -0
  20. package/extras/swiftbar/lib/system.sh +190 -0
  21. package/extras/swiftbar/lib/utils.sh +205 -0
  22. package/extras/swiftbar/pnpm-term.sh +125 -0
  23. package/extras/swiftbar/pnpm.sh +21 -0
  24. package/extras/swiftbar/set-interval.sh +62 -0
  25. package/extras/swiftbar/set-server-flavor.sh +57 -0
  26. package/extras/swiftbar/wt-pr.sh +95 -0
  27. package/package.json +58 -0
  28. package/scripts/auth.mjs +272 -0
  29. package/scripts/build.mjs +204 -0
  30. package/scripts/cli-link.mjs +58 -0
  31. package/scripts/completion.mjs +364 -0
  32. package/scripts/daemon.mjs +349 -0
  33. package/scripts/dev.mjs +181 -0
  34. package/scripts/doctor.mjs +342 -0
  35. package/scripts/happy.mjs +79 -0
  36. package/scripts/init.mjs +232 -0
  37. package/scripts/install.mjs +379 -0
  38. package/scripts/menubar.mjs +107 -0
  39. package/scripts/mobile.mjs +305 -0
  40. package/scripts/run.mjs +236 -0
  41. package/scripts/self.mjs +298 -0
  42. package/scripts/server_flavor.mjs +125 -0
  43. package/scripts/service.mjs +526 -0
  44. package/scripts/stack.mjs +815 -0
  45. package/scripts/tailscale.mjs +278 -0
  46. package/scripts/uninstall.mjs +190 -0
  47. package/scripts/utils/args.mjs +17 -0
  48. package/scripts/utils/cli.mjs +24 -0
  49. package/scripts/utils/cli_registry.mjs +262 -0
  50. package/scripts/utils/config.mjs +40 -0
  51. package/scripts/utils/dotenv.mjs +30 -0
  52. package/scripts/utils/env.mjs +138 -0
  53. package/scripts/utils/env_file.mjs +59 -0
  54. package/scripts/utils/env_local.mjs +25 -0
  55. package/scripts/utils/fs.mjs +11 -0
  56. package/scripts/utils/paths.mjs +184 -0
  57. package/scripts/utils/pm.mjs +294 -0
  58. package/scripts/utils/ports.mjs +66 -0
  59. package/scripts/utils/proc.mjs +66 -0
  60. package/scripts/utils/runtime.mjs +30 -0
  61. package/scripts/utils/server.mjs +41 -0
  62. package/scripts/utils/smoke_help.mjs +45 -0
  63. package/scripts/utils/validate.mjs +47 -0
  64. package/scripts/utils/wizard.mjs +69 -0
  65. package/scripts/utils/worktrees.mjs +78 -0
  66. package/scripts/where.mjs +105 -0
  67. package/scripts/worktrees.mjs +1721 -0
@@ -0,0 +1,1721 @@
1
+ import './utils/env.mjs';
2
+ import { mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { parseArgs } from './utils/args.mjs';
5
+ import { pathExists } from './utils/fs.mjs';
6
+ import { run, runCapture } from './utils/proc.mjs';
7
+ import { componentDirEnvKey, getComponentDir, getComponentsDir, getRootDir, getWorkspaceDir } from './utils/paths.mjs';
8
+ import { parseGithubOwner } from './utils/worktrees.mjs';
9
+ import { isTty, prompt, promptSelect, withRl } from './utils/wizard.mjs';
10
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
11
+ import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
12
+ import { ensureEnvFileUpdated } from './utils/env_file.mjs';
13
+ import { existsSync } from 'node:fs';
14
+ import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/config.mjs';
15
+ import { detectServerComponentDirMismatch } from './utils/validate.mjs';
16
+
17
+ function getWorktreesRoot(rootDir) {
18
+ return join(getComponentsDir(rootDir), '.worktrees');
19
+ }
20
+
21
+ function resolveComponentWorktreeDir({ rootDir, component, spec }) {
22
+ const worktreesRoot = getWorktreesRoot(rootDir);
23
+ const raw = (spec ?? '').trim();
24
+
25
+ if (!raw) {
26
+ // Default: use currently active dir for this component (env override if present, otherwise components/<component>).
27
+ return getComponentDir(rootDir, component);
28
+ }
29
+
30
+ if (raw === 'default' || raw === 'main') {
31
+ return join(getComponentsDir(rootDir), component);
32
+ }
33
+
34
+ if (raw === 'active') {
35
+ return getComponentDir(rootDir, component);
36
+ }
37
+
38
+ if (isAbsolute(raw)) {
39
+ return raw;
40
+ }
41
+
42
+ // Interpret as <owner>/<rest...> under components/.worktrees/<component>/.
43
+ return join(worktreesRoot, component, ...raw.split('/'));
44
+ }
45
+
46
+ function parseGithubPullRequest(input) {
47
+ const raw = (input ?? '').trim();
48
+ if (!raw) return null;
49
+ if (/^\d+$/.test(raw)) {
50
+ return { number: Number(raw), owner: null, repo: null };
51
+ }
52
+ // https://github.com/<owner>/<repo>/pull/<num>
53
+ const m = raw.match(/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<num>\d+)/);
54
+ if (!m?.groups?.num) return null;
55
+ return {
56
+ number: Number(m.groups.num),
57
+ owner: m.groups.owner ?? null,
58
+ repo: m.groups.repo ?? null,
59
+ };
60
+ }
61
+
62
+ function sanitizeSlugPart(s) {
63
+ return (s ?? '')
64
+ .toString()
65
+ .trim()
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9._/-]+/g, '-')
68
+ .replace(/-+/g, '-')
69
+ .replace(/^-+|-+$/g, '');
70
+ }
71
+
72
+ async function isWorktreeClean(dir) {
73
+ const dirty = (await git(dir, ['status', '--porcelain'])).trim();
74
+ return !dirty;
75
+ }
76
+
77
+ async function maybeStash({ dir, enabled, keep, message }) {
78
+ if (!enabled && !keep) {
79
+ return { stashed: false, kept: false };
80
+ }
81
+ const clean = await isWorktreeClean(dir);
82
+ if (clean) {
83
+ return { stashed: false, kept: false };
84
+ }
85
+ const msg = message || `happy-stacks auto-stash (${new Date().toISOString()})`;
86
+ // Include untracked files (-u). If stash applies cleanly later, we'll pop.
87
+ await git(dir, ['stash', 'push', '-u', '-m', msg]);
88
+ return { stashed: true, kept: Boolean(keep) };
89
+ }
90
+
91
+ async function maybePopStash({ dir, stashed, keep }) {
92
+ if (!stashed || keep) {
93
+ return { popped: false, popError: null };
94
+ }
95
+ try {
96
+ await git(dir, ['stash', 'pop']);
97
+ return { popped: true, popError: null };
98
+ } catch (e) {
99
+ // On conflicts, `git stash pop` keeps the stash entry.
100
+ return { popped: false, popError: String(e?.message ?? e) };
101
+ }
102
+ }
103
+
104
+ async function hardReset({ dir, target }) {
105
+ await git(dir, ['reset', '--hard', target]);
106
+ }
107
+
108
+ async function git(root, args) {
109
+ return await runCapture('git', args, { cwd: root });
110
+ }
111
+
112
+ async function gitOk(root, args) {
113
+ try {
114
+ await runCapture('git', args, { cwd: root });
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function parseDepsMode(raw) {
122
+ const v = (raw ?? '').trim().toLowerCase();
123
+ if (!v) return 'none';
124
+ if (v === 'none') return 'none';
125
+ if (v === 'link' || v === 'symlink') return 'link';
126
+ if (v === 'install') return 'install';
127
+ if (v === 'link-or-install' || v === 'linkorinstall') return 'link-or-install';
128
+ throw new Error(`[wt] invalid --deps value: ${raw}. Expected one of: none | link | install | link-or-install`);
129
+ }
130
+
131
+ async function getWorktreeGitDir(worktreeDir) {
132
+ const gitDir = (await git(worktreeDir, ['rev-parse', '--git-dir'])).trim();
133
+ // rev-parse may return a relative path.
134
+ return isAbsolute(gitDir) ? gitDir : resolve(worktreeDir, gitDir);
135
+ }
136
+
137
+ async function ensureWorktreeExclude(worktreeDir, patterns) {
138
+ const gitDir = await getWorktreeGitDir(worktreeDir);
139
+ const excludePath = join(gitDir, 'info', 'exclude');
140
+ const existing = (await readFile(excludePath, 'utf-8').catch(() => '')).toString();
141
+ const existingLines = new Set(existing.split('\n').map((l) => l.trim()).filter(Boolean));
142
+ const want = patterns.map((p) => p.trim()).filter(Boolean).filter((p) => !existingLines.has(p));
143
+ if (!want.length) return;
144
+ const next = (existing ? existing.replace(/\s*$/, '') + '\n' : '') + want.join('\n') + '\n';
145
+ await writeFile(excludePath, next, 'utf-8');
146
+ }
147
+
148
+ async function detectPackageManager(dir) {
149
+ // Order matters: pnpm > yarn > npm.
150
+ if (await pathExists(join(dir, 'pnpm-lock.yaml'))) return { kind: 'pnpm', lockfile: 'pnpm-lock.yaml' };
151
+ if (await pathExists(join(dir, 'yarn.lock'))) return { kind: 'yarn', lockfile: 'yarn.lock' };
152
+ if (await pathExists(join(dir, 'package-lock.json'))) return { kind: 'npm', lockfile: 'package-lock.json' };
153
+ if (await pathExists(join(dir, 'npm-shrinkwrap.json'))) return { kind: 'npm', lockfile: 'npm-shrinkwrap.json' };
154
+ // Fallback: if package.json exists, assume npm.
155
+ if (await pathExists(join(dir, 'package.json'))) return { kind: 'npm', lockfile: null };
156
+ return { kind: null, lockfile: null };
157
+ }
158
+
159
+ async function linkNodeModules({ fromDir, toDir }) {
160
+ const src = join(fromDir, 'node_modules');
161
+ const dest = join(toDir, 'node_modules');
162
+
163
+ if (!(await pathExists(src))) {
164
+ return { linked: false, reason: `source node_modules missing: ${src}` };
165
+ }
166
+ if (await pathExists(dest)) {
167
+ return { linked: false, reason: `dest node_modules already exists: ${dest}` };
168
+ }
169
+
170
+ await symlink(src, dest);
171
+ // Worktrees sometimes treat node_modules symlinks oddly; ensure it's excluded even if .gitignore misses it.
172
+ await ensureWorktreeExclude(toDir, ['node_modules']);
173
+ return { linked: true, reason: null };
174
+ }
175
+
176
+ async function installDependencies({ dir }) {
177
+ const pm = await detectPackageManager(dir);
178
+ if (!pm.kind) {
179
+ return { installed: false, reason: 'no package manager detected (no package.json)' };
180
+ }
181
+
182
+ if (pm.kind === 'pnpm') {
183
+ await run('pnpm', ['install', '--frozen-lockfile'], { cwd: dir });
184
+ return { installed: true, reason: null };
185
+ }
186
+ if (pm.kind === 'yarn') {
187
+ // Works for yarn classic; yarn berry will ignore/translate flags as needed.
188
+ await run('yarn', ['install', '--frozen-lockfile'], { cwd: dir });
189
+ return { installed: true, reason: null };
190
+ }
191
+ // npm
192
+ if (pm.lockfile && pm.lockfile !== 'package.json') {
193
+ await run('npm', ['ci'], { cwd: dir });
194
+ } else {
195
+ await run('npm', ['install'], { cwd: dir });
196
+ }
197
+ return { installed: true, reason: null };
198
+ }
199
+
200
+ async function maybeSetupDeps({ repoRoot, baseDir, worktreeDir, depsMode }) {
201
+ if (!depsMode || depsMode === 'none') {
202
+ return { mode: 'none', linked: false, installed: false, message: null };
203
+ }
204
+
205
+ // Prefer explicit baseDir if provided, otherwise link from the primary checkout (repoRoot).
206
+ const linkFrom = baseDir || repoRoot;
207
+
208
+ if (depsMode === 'link' || depsMode === 'link-or-install') {
209
+ const res = await linkNodeModules({ fromDir: linkFrom, toDir: worktreeDir });
210
+ if (res.linked) {
211
+ return { mode: depsMode, linked: true, installed: false, message: null };
212
+ }
213
+ if (depsMode === 'link') {
214
+ return { mode: depsMode, linked: false, installed: false, message: res.reason };
215
+ }
216
+ // fall through to install
217
+ }
218
+
219
+ const inst = await installDependencies({ dir: worktreeDir });
220
+ return { mode: depsMode, linked: false, installed: Boolean(inst.installed), message: inst.reason };
221
+ }
222
+
223
+ async function normalizeRemoteName(repoRoot, remoteName) {
224
+ const want = (remoteName ?? '').trim();
225
+ if (!want) return want;
226
+
227
+ // happy-local historically used `origin`, but some checkouts use `fork` instead.
228
+ // Treat them as interchangeable if one is missing.
229
+ if (await gitOk(repoRoot, ['remote', 'get-url', want])) {
230
+ return want;
231
+ }
232
+ if (want === 'origin' && (await gitOk(repoRoot, ['remote', 'get-url', 'fork']))) {
233
+ return 'fork';
234
+ }
235
+ if (want === 'fork' && (await gitOk(repoRoot, ['remote', 'get-url', 'origin']))) {
236
+ return 'origin';
237
+ }
238
+ return want;
239
+ }
240
+
241
+ function parseWorktreeListPorcelain(out) {
242
+ const blocks = out
243
+ .split('\n\n')
244
+ .map((b) => b.trim())
245
+ .filter(Boolean);
246
+
247
+ return blocks
248
+ .map((block) => {
249
+ const lines = block
250
+ .split('\n')
251
+ .map((l) => l.trim())
252
+ .filter(Boolean);
253
+ const wt = { path: null, head: null, branchRef: null, detached: false };
254
+ for (const line of lines) {
255
+ if (line.startsWith('worktree ')) {
256
+ wt.path = line.slice('worktree '.length).trim();
257
+ } else if (line.startsWith('HEAD ')) {
258
+ wt.head = line.slice('HEAD '.length).trim();
259
+ } else if (line.startsWith('branch ')) {
260
+ wt.branchRef = line.slice('branch '.length).trim();
261
+ } else if (line === 'detached') {
262
+ wt.detached = true;
263
+ }
264
+ }
265
+ if (!wt.path) {
266
+ return null;
267
+ }
268
+ return wt;
269
+ })
270
+ .filter(Boolean);
271
+ }
272
+
273
+ function getComponentRepoRoot(rootDir, component) {
274
+ // Respect component dir overrides so repos can live outside components/ (e.g. an existing checkout at ../happy-server).
275
+ return getComponentDir(rootDir, component);
276
+ }
277
+
278
+ async function resolveOwners(repoRoot) {
279
+ const originRemote = await normalizeRemoteName(repoRoot, 'origin') || 'origin';
280
+ const originUrl = (await git(repoRoot, ['remote', 'get-url', originRemote])).trim();
281
+ const upstreamUrl = (await git(repoRoot, ['remote', 'get-url', 'upstream']).catch(() => '')).trim();
282
+
283
+ const originOwner = parseGithubOwner(originUrl);
284
+ const upstreamOwner = parseGithubOwner(upstreamUrl);
285
+
286
+ if (!originOwner) {
287
+ throw new Error(`[wt] unable to parse origin owner for ${repoRoot} (${originRemote} -> ${originUrl})`);
288
+ }
289
+
290
+ return { originOwner, upstreamOwner: upstreamOwner ?? originOwner };
291
+ }
292
+
293
+ async function resolveRemoteOwner(repoRoot, remoteName) {
294
+ const resolvedRemoteName = await normalizeRemoteName(repoRoot, remoteName);
295
+ const remoteUrl = (await git(repoRoot, ['remote', 'get-url', resolvedRemoteName])).trim();
296
+ const owner = parseGithubOwner(remoteUrl);
297
+ if (!owner) {
298
+ throw new Error(`[wt] unable to parse owner for remote '${resolvedRemoteName}' in ${repoRoot} (${remoteUrl})`);
299
+ }
300
+ return { owner, remoteUrl, remoteName: resolvedRemoteName };
301
+ }
302
+
303
+ async function resolveRemoteDefaultBranchName(repoRoot, remoteName, { component } = {}) {
304
+ // Happy-local components sometimes use non-`main` distribution branches on origin
305
+ // (e.g. `happy-server-light`, `happy-server`). Prefer a branch that matches the component name
306
+ // if it exists on that remote, otherwise fall back to the remote's HEAD branch, then `main`.
307
+ if (component) {
308
+ const ref = `refs/remotes/${remoteName}/${component}`;
309
+ if (await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', ref])) {
310
+ return component;
311
+ }
312
+ }
313
+
314
+ const remoteHead = (await git(repoRoot, ['symbolic-ref', '-q', '--short', `refs/remotes/${remoteName}/HEAD`]).catch(() => '')).trim();
315
+ if (remoteHead.startsWith(`${remoteName}/`)) {
316
+ return remoteHead.slice(remoteName.length + 1);
317
+ }
318
+
319
+ return 'main';
320
+ }
321
+
322
+ function inferTargetOwner({ branchName, branchRemote, originOwner, upstreamOwner }) {
323
+ const lower = branchName.toLowerCase();
324
+ if (branchName.startsWith(`${originOwner}/`)) {
325
+ return originOwner;
326
+ }
327
+ if (branchName.startsWith(`${upstreamOwner}/`)) {
328
+ return upstreamOwner;
329
+ }
330
+
331
+ if (branchRemote === 'upstream' || lower.includes('upstream')) {
332
+ return upstreamOwner;
333
+ }
334
+
335
+ return originOwner;
336
+ }
337
+
338
+ function branchRest({ branchName, owner }) {
339
+ return branchName.startsWith(`${owner}/`) ? branchName.slice(owner.length + 1) : branchName;
340
+ }
341
+
342
+ async function migrateComponentWorktrees({ rootDir, component }) {
343
+ const repoRoot = getComponentRepoRoot(rootDir, component);
344
+ if (!(await pathExists(repoRoot))) {
345
+ return { moved: 0, renamed: 0 };
346
+ }
347
+
348
+ // Ensure it looks like a git repo.
349
+ if (!(await gitOk(repoRoot, ['rev-parse', '--is-inside-work-tree']))) {
350
+ return { moved: 0, renamed: 0 };
351
+ }
352
+
353
+ const { originOwner, upstreamOwner } = await resolveOwners(repoRoot);
354
+ const wtRoot = getWorktreesRoot(rootDir);
355
+
356
+ const worktreesRaw = await git(repoRoot, ['worktree', 'list', '--porcelain']);
357
+ const worktrees = parseWorktreeListPorcelain(worktreesRaw);
358
+
359
+ let moved = 0;
360
+ let renamed = 0;
361
+
362
+ const componentsDir = getComponentsDir(rootDir);
363
+
364
+ for (const wt of worktrees) {
365
+ const wtPath = wt.path;
366
+ if (!wtPath) {
367
+ continue;
368
+ }
369
+
370
+ // Skip the primary checkout (repo root).
371
+ if (resolve(wtPath) === resolve(repoRoot)) {
372
+ continue;
373
+ }
374
+
375
+ // Only migrate worktrees living under this happy-stacks components folder.
376
+ if (!resolve(wtPath).startsWith(resolve(componentsDir) + '/')) {
377
+ continue;
378
+ }
379
+
380
+ const branchName = (await git(wtPath, ['branch', '--show-current'])).trim();
381
+ if (!branchName) {
382
+ // Detached HEAD (skip).
383
+ continue;
384
+ }
385
+
386
+ const branchRemote = (await git(repoRoot, ['config', '--get', `branch.${branchName}.remote`]).catch(() => '')).trim();
387
+ const owner = inferTargetOwner({ branchName, branchRemote, originOwner, upstreamOwner });
388
+ const desiredBranchName = branchName.startsWith(`${owner}/`) ? branchName : `${owner}/${branchName}`;
389
+ const rest = branchRest({ branchName: desiredBranchName, owner });
390
+
391
+ // Rename branch (in the worktree where it is checked out).
392
+ if (desiredBranchName !== branchName) {
393
+ await run('git', ['branch', '-m', desiredBranchName], { cwd: wtPath });
394
+ renamed += 1;
395
+ }
396
+
397
+ const destPath = join(wtRoot, component, owner, ...rest.split('/'));
398
+ await mkdir(dirname(destPath), { recursive: true });
399
+
400
+ if (resolve(destPath) !== resolve(wtPath)) {
401
+ await run('git', ['worktree', 'move', wtPath, destPath], { cwd: repoRoot });
402
+ moved += 1;
403
+ }
404
+ }
405
+
406
+ // Best-effort cleanup of old worktree folders under components/.
407
+ const legacyDirs = [
408
+ join(componentsDir, `${component}-worktrees`),
409
+ join(componentsDir, `${component}-resume-upstream-clean`),
410
+ ];
411
+ for (const d of legacyDirs) {
412
+ if (!(await pathExists(d))) {
413
+ continue;
414
+ }
415
+ try {
416
+ const entries = await readdir(d);
417
+ if (!entries.length) {
418
+ await rm(d, { recursive: true, force: true });
419
+ }
420
+ } catch {
421
+ // ignore
422
+ }
423
+ }
424
+
425
+ return { moved, renamed };
426
+ }
427
+
428
+ async function cmdMigrate({ rootDir }) {
429
+ const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
430
+
431
+ let totalMoved = 0;
432
+ let totalRenamed = 0;
433
+ for (const component of components) {
434
+ const res = await migrateComponentWorktrees({ rootDir, component });
435
+ totalMoved += res.moved;
436
+ totalRenamed += res.renamed;
437
+ }
438
+
439
+ // If the persisted config pins any component dir to a legacy location, attempt to rewrite it.
440
+ const envUpdates = [];
441
+
442
+ // Keep in sync with scripts/utils/env_local.mjs selection logic.
443
+ const explicitEnv = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
444
+ const hasHomeConfig = existsSync(getHomeEnvPath()) || existsSync(getHomeEnvLocalPath());
445
+ const envPath = explicitEnv ? explicitEnv : hasHomeConfig ? resolveUserConfigEnvPath({ cliRootDir: rootDir }) : join(rootDir, 'env.local');
446
+
447
+ if (await pathExists(envPath)) {
448
+ const raw = await readFile(envPath, 'utf-8');
449
+ const rewrite = (v) => {
450
+ if (!v.includes('/components/')) {
451
+ return v;
452
+ }
453
+ return v
454
+ .replace('/components/happy-worktrees/', '/components/.worktrees/happy/')
455
+ .replace('/components/happy-cli-worktrees/', '/components/.worktrees/happy-cli/')
456
+ .replace('/components/happy-resume-upstream-clean', '/components/.worktrees/happy/')
457
+ .replace('/components/happy-cli-resume-upstream-clean', '/components/.worktrees/happy-cli/');
458
+ };
459
+
460
+ for (const component of ['happy', 'happy-cli', 'happy-server-light', 'happy-server']) {
461
+ const key = componentDirEnvKey(component);
462
+ const m = raw.match(new RegExp(`^\\s*${key}=(.*)$`, 'm'));
463
+ if (m?.[1]) {
464
+ const current = m[1].trim();
465
+ const next = rewrite(current);
466
+ if (next !== current) {
467
+ envUpdates.push({ key, value: next });
468
+ }
469
+ }
470
+ }
471
+ }
472
+ // Write to the same file we inspected.
473
+ await ensureEnvFileUpdated({ envPath, updates: envUpdates });
474
+
475
+ return { moved: totalMoved, branchesRenamed: totalRenamed };
476
+ }
477
+
478
+ async function cmdUse({ rootDir, args }) {
479
+ const component = args[0];
480
+ const spec = args[1];
481
+ if (!component || !spec) {
482
+ throw new Error('[wt] usage: happys wt use <component> <owner/branch|path|default>');
483
+ }
484
+
485
+ const key = componentDirEnvKey(component);
486
+ const worktreesRoot = getWorktreesRoot(rootDir);
487
+ const envPath = process.env.HAPPY_STACKS_ENV_FILE?.trim()
488
+ ? process.env.HAPPY_STACKS_ENV_FILE.trim()
489
+ : process.env.HAPPY_LOCAL_ENV_FILE?.trim()
490
+ ? process.env.HAPPY_LOCAL_ENV_FILE.trim()
491
+ : null;
492
+
493
+ if (spec === 'default' || spec === 'main') {
494
+ // Clear override by setting it to empty (env.local keeps a record of last use, but override becomes inactive).
495
+ await (envPath
496
+ ? ensureEnvFileUpdated({ envPath, updates: [{ key, value: '' }] })
497
+ : ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: '' }] }));
498
+ return { component, activeDir: join(getComponentsDir(rootDir), component), mode: 'default' };
499
+ }
500
+
501
+ let dir = spec;
502
+ if (!isAbsolute(dir)) {
503
+ // Allow passing a repo-relative path (e.g. "components/happy-cli") as an escape hatch.
504
+ const rel = resolve(getWorkspaceDir(rootDir), dir);
505
+ if (await pathExists(rel)) {
506
+ dir = rel;
507
+ } else {
508
+ // Interpret as <owner>/<rest...> under components/.worktrees/<component>/.
509
+ dir = join(worktreesRoot, component, ...spec.split('/'));
510
+ }
511
+ } else {
512
+ dir = resolve(dir);
513
+ }
514
+
515
+ if (!(await pathExists(dir))) {
516
+ throw new Error(`[wt] target does not exist: ${dir}`);
517
+ }
518
+
519
+ if (component === 'happy-server-light' || component === 'happy-server') {
520
+ const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName: component, serverDir: dir });
521
+ if (mismatch) {
522
+ throw new Error(
523
+ `[wt] invalid target for ${component}:\n` +
524
+ `- expected a checkout of: ${mismatch.expected}\n` +
525
+ `- but the path points inside: ${mismatch.actual}\n` +
526
+ `- path: ${mismatch.serverDir}\n` +
527
+ `Fix: pick a worktree under components/.worktrees/${mismatch.expected}/ (or run: happys wt use ${mismatch.actual} <spec>).`
528
+ );
529
+ }
530
+ }
531
+
532
+ await (envPath
533
+ ? ensureEnvFileUpdated({ envPath, updates: [{ key, value: dir }] })
534
+ : ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: dir }] }));
535
+ return { component, activeDir: dir, mode: 'override' };
536
+ }
537
+
538
+ async function cmdUseInteractive({ rootDir }) {
539
+ await withRl(async (rl) => {
540
+ const component = await prompt(rl, 'Component [happy|happy-cli|happy-server-light|happy-server]: ', { defaultValue: '' });
541
+ if (!component) {
542
+ throw new Error('[wt] component is required');
543
+ }
544
+
545
+ const wtRoot = getWorktreesRoot(rootDir);
546
+ const base = join(wtRoot, component);
547
+ const specs = [];
548
+ const walk = async (d, prefix) => {
549
+ const entries = await readdir(d, { withFileTypes: true });
550
+ for (const e of entries) {
551
+ if (!e.isDirectory()) continue;
552
+ const p = join(d, e.name);
553
+ const nextPrefix = prefix ? `${prefix}/${e.name}` : e.name;
554
+ if (await pathExists(join(p, '.git'))) {
555
+ specs.push(nextPrefix);
556
+ }
557
+ await walk(p, nextPrefix);
558
+ }
559
+ };
560
+ if (await pathExists(base)) {
561
+ await walk(base, '');
562
+ }
563
+ specs.sort();
564
+
565
+ const kindOptions = [{ label: 'default', value: 'default' }];
566
+ if (specs.length) {
567
+ kindOptions.push({ label: 'pick existing worktree', value: 'pick' });
568
+ }
569
+ const choice = await promptSelect(rl, {
570
+ title: `Active choices for ${component}:`,
571
+ options: kindOptions,
572
+ defaultIndex: 0,
573
+ });
574
+ if (choice === 'pick') {
575
+ const picked = await promptSelect(rl, {
576
+ title: `Available ${component} worktrees:`,
577
+ options: specs.map((s) => ({ label: s, value: s })),
578
+ defaultIndex: 0,
579
+ });
580
+ await cmdUse({ rootDir, args: [component, picked] });
581
+ return;
582
+ }
583
+ await cmdUse({ rootDir, args: [component, 'default'] });
584
+ });
585
+ }
586
+
587
+ async function cmdNew({ rootDir, argv }) {
588
+ const positionals = argv.filter((a) => !a.startsWith('--'));
589
+ const component = positionals[1];
590
+ const slug = positionals[2];
591
+ if (!component || !slug) {
592
+ throw new Error(
593
+ '[wt] usage: happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use]'
594
+ );
595
+ }
596
+
597
+ const { flags, kv } = parseArgs(argv.slice(1));
598
+ const repoRoot = getComponentRepoRoot(rootDir, component);
599
+ if (!(await pathExists(repoRoot))) {
600
+ throw new Error(`[wt] missing component repo at ${repoRoot}`);
601
+ }
602
+
603
+ const remoteOverride = (kv.get('--remote') ?? '').trim();
604
+ const from = (kv.get('--from') ?? '').trim().toLowerCase() || 'upstream';
605
+ let remoteName = remoteOverride || (from === 'origin' ? 'origin' : 'upstream');
606
+
607
+ const remote = await resolveRemoteOwner(repoRoot, remoteName);
608
+ remoteName = remote.remoteName;
609
+ const { owner } = remote;
610
+ const defaultBranch = await resolveRemoteDefaultBranchName(repoRoot, remoteName, { component });
611
+
612
+ const baseOverride = (kv.get('--base') ?? '').trim();
613
+ const baseWorktreeSpec = (kv.get('--base-worktree') ?? kv.get('--from-worktree') ?? '').trim();
614
+ let baseFromWorktree = '';
615
+ let baseWorktreeDir = '';
616
+ if (!baseOverride && baseWorktreeSpec) {
617
+ baseWorktreeDir = resolveComponentWorktreeDir({ rootDir, component, spec: baseWorktreeSpec });
618
+ if (!(await pathExists(baseWorktreeDir))) {
619
+ throw new Error(`[wt] --base-worktree does not exist: ${baseWorktreeDir}`);
620
+ }
621
+ const branch = (await git(baseWorktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
622
+ if (branch && branch !== 'HEAD') {
623
+ baseFromWorktree = branch;
624
+ } else {
625
+ baseFromWorktree = (await git(baseWorktreeDir, ['rev-parse', 'HEAD'])).trim();
626
+ }
627
+ }
628
+
629
+ // Default: base worktrees on a local mirror branch like `slopus/main` (or `leeroybrun/happy-server-light`).
630
+ // This scales to multiple upstream remotes without relying on a generic "upstream-main".
631
+ const mirrorBranch = `${owner}/${defaultBranch}`;
632
+ const base = baseOverride || baseFromWorktree || mirrorBranch;
633
+ const branchName = `${owner}/${slug}`;
634
+
635
+ const worktreesRoot = getWorktreesRoot(rootDir);
636
+ const destPath = join(worktreesRoot, component, owner, ...slug.split('/'));
637
+ await mkdir(dirname(destPath), { recursive: true });
638
+
639
+ // Ensure remotes are present.
640
+ await git(repoRoot, ['fetch', '--all', '--prune', '--quiet']);
641
+
642
+ // Keep the mirror branch up to date when using the default base.
643
+ if (!baseOverride && !baseFromWorktree) {
644
+ await git(repoRoot, ['fetch', '--quiet', remoteName, defaultBranch]);
645
+ await git(repoRoot, ['branch', '-f', mirrorBranch, `${remoteName}/${defaultBranch}`]);
646
+ await git(repoRoot, ['branch', '--set-upstream-to', `${remoteName}/${defaultBranch}`, mirrorBranch]).catch(() => {});
647
+ }
648
+
649
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
650
+
651
+ const depsMode = parseDepsMode(kv.get('--deps'));
652
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
653
+
654
+ const shouldUse = flags.has('--use');
655
+ if (shouldUse) {
656
+ const key = componentDirEnvKey(component);
657
+ await ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: destPath }] });
658
+ }
659
+ return { component, branch: branchName, path: destPath, base, used: shouldUse, deps };
660
+ }
661
+
662
+ async function cmdPr({ rootDir, argv }) {
663
+ const { flags, kv } = parseArgs(argv);
664
+ const json = wantsJson(argv, { flags });
665
+ const positionals = argv.filter((a) => !a.startsWith('--'));
666
+ const component = positionals[1];
667
+ const prInput = positionals[2];
668
+ if (!component || !prInput) {
669
+ throw new Error(
670
+ '[wt] usage: happys wt pr <component> <pr-url|number> [--remote=upstream] [--slug=<name>] [--deps=none|link|install|link-or-install] [--use] [--update] [--force] [--json]'
671
+ );
672
+ }
673
+
674
+ const repoRoot = getComponentRepoRoot(rootDir, component);
675
+ if (!(await pathExists(repoRoot))) {
676
+ throw new Error(`[wt] missing component repo at ${repoRoot}`);
677
+ }
678
+
679
+ const pr = parseGithubPullRequest(prInput);
680
+ if (!pr?.number || !Number.isFinite(pr.number)) {
681
+ throw new Error(`[wt] unable to parse PR: ${prInput}`);
682
+ }
683
+
684
+ const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
685
+ const { owner } = await resolveRemoteOwner(repoRoot, remoteName);
686
+
687
+ const slugExtra = sanitizeSlugPart(kv.get('--slug') ?? '');
688
+ const slug = slugExtra ? `pr/${pr.number}-${slugExtra}` : `pr/${pr.number}`;
689
+ const branchName = `${owner}/${slug}`;
690
+
691
+ const worktreesRoot = getWorktreesRoot(rootDir);
692
+ const destPath = join(worktreesRoot, component, owner, ...slug.split('/'));
693
+ await mkdir(dirname(destPath), { recursive: true });
694
+
695
+ const exists = await pathExists(destPath);
696
+ const doUpdate = flags.has('--update');
697
+ if (exists && !doUpdate) {
698
+ throw new Error(`[wt] destination already exists: ${destPath}\n[wt] re-run with --update to refresh it`);
699
+ }
700
+
701
+ // Fetch PR head ref (GitHub convention). Use + to allow force-updated PR branches when --force is set.
702
+ const force = flags.has('--force');
703
+ let oldHead = null;
704
+ const prRef = `refs/pull/${pr.number}/head`;
705
+ if (exists) {
706
+ // Update existing worktree.
707
+ const stash = await maybeStash({
708
+ dir: destPath,
709
+ enabled: flags.has('--stash'),
710
+ keep: flags.has('--stash-keep'),
711
+ message: `[happy-stacks] wt pr ${component} ${pr.number}`,
712
+ });
713
+ if (!(await isWorktreeClean(destPath)) && !stash.stashed) {
714
+ throw new Error(`[wt] worktree is not clean (${destPath}). Re-run with --stash to auto-stash changes.`);
715
+ }
716
+
717
+ oldHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
718
+ await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
719
+ const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
720
+
721
+ const isAncestor = await gitOk(repoRoot, ['merge-base', '--is-ancestor', oldHead, newTip]);
722
+ if (!isAncestor && !force) {
723
+ throw new Error(
724
+ `[wt] PR update is not a fast-forward (likely force-push) for ${branchName}\n` +
725
+ `[wt] re-run with: happys wt pr ${component} ${pr.number} --remote=${remoteName} --update --force`
726
+ );
727
+ }
728
+
729
+ // Update working tree to the fetched tip.
730
+ if (isAncestor) {
731
+ await git(destPath, ['merge', '--ff-only', newTip]);
732
+ } else {
733
+ await git(destPath, ['reset', '--hard', newTip]);
734
+ }
735
+
736
+ // Only attempt to restore stash if update succeeded without forcing a conflict state.
737
+ const stashPop = await maybePopStash({ dir: destPath, stashed: stash.stashed, keep: stash.kept });
738
+ if (stashPop.popError) {
739
+ if (!force && oldHead) {
740
+ await hardReset({ dir: destPath, target: oldHead });
741
+ throw new Error(
742
+ `[wt] PR updated, but restoring stashed changes conflicted.\n` +
743
+ `[wt] Reverted update to keep your working tree clean.\n` +
744
+ `[wt] Worktree: ${destPath}\n` +
745
+ `[wt] Re-run with --update --stash --force to keep the conflict state for manual resolution.`
746
+ );
747
+ }
748
+ // Keep conflict state in place (or if we can't revert).
749
+ throw new Error(
750
+ `[wt] PR updated, but restoring stashed changes conflicted.\n` +
751
+ `[wt] Worktree: ${destPath}\n` +
752
+ `[wt] Conflicts are left in place for manual resolution (--force).`
753
+ );
754
+ }
755
+ } else {
756
+ await git(repoRoot, ['fetch', '--quiet', remoteName, prRef]);
757
+ const newTip = (await git(repoRoot, ['rev-parse', 'FETCH_HEAD'])).trim();
758
+
759
+ const branchExists = await gitOk(repoRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
760
+ if (branchExists) {
761
+ if (!force) {
762
+ throw new Error(`[wt] branch already exists: ${branchName}\n[wt] re-run with --force to reset it to the PR head`);
763
+ }
764
+ await git(repoRoot, ['branch', '-f', branchName, newTip]);
765
+ await git(repoRoot, ['worktree', 'add', destPath, branchName]);
766
+ } else {
767
+ // Create worktree at PR head (new local branch).
768
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, newTip]);
769
+ }
770
+ }
771
+
772
+ // Optional deps handling (useful when PR branches add/change dependencies).
773
+ const depsMode = parseDepsMode(kv.get('--deps'));
774
+ const deps = await maybeSetupDeps({ repoRoot, baseDir: repoRoot, worktreeDir: destPath, depsMode });
775
+
776
+ const shouldUse = flags.has('--use');
777
+ if (shouldUse) {
778
+ // Reuse cmdUse so it writes to env.local or stack env file depending on context.
779
+ await cmdUse({ rootDir, args: [component, destPath] });
780
+ }
781
+
782
+ const newHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
783
+ const res = {
784
+ component,
785
+ pr: pr.number,
786
+ remote: remoteName,
787
+ branch: branchName,
788
+ path: destPath,
789
+ used: shouldUse,
790
+ updated: exists,
791
+ oldHead,
792
+ newHead,
793
+ deps,
794
+ };
795
+ if (json) {
796
+ return res;
797
+ }
798
+ return res;
799
+ }
800
+
801
+ async function cmdStatus({ rootDir, argv }) {
802
+ const positionals = argv.filter((a) => !a.startsWith('--'));
803
+ const component = positionals[1];
804
+ const spec = positionals[2] ?? '';
805
+ if (!component) {
806
+ throw new Error('[wt] usage: happys wt status <component> [worktreeSpec|default|path]');
807
+ }
808
+
809
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
810
+ if (!(await pathExists(dir))) {
811
+ throw new Error(`[wt] target does not exist: ${dir}`);
812
+ }
813
+
814
+ const branch = (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
815
+ const head = (await git(dir, ['rev-parse', 'HEAD'])).trim();
816
+ const dirty = (await git(dir, ['status', '--porcelain'])).trim();
817
+ const isClean = !dirty;
818
+
819
+ let upstream = null;
820
+ try {
821
+ upstream = (await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim();
822
+ } catch {
823
+ upstream = null;
824
+ }
825
+
826
+ let ahead = null;
827
+ let behind = null;
828
+ if (upstream) {
829
+ try {
830
+ const counts = (await git(dir, ['rev-list', '--left-right', '--count', `${upstream}...HEAD`])).trim();
831
+ const [left, right] = counts.split(/\s+/g).map((n) => Number(n));
832
+ behind = Number.isFinite(left) ? left : null;
833
+ ahead = Number.isFinite(right) ? right : null;
834
+ } catch {
835
+ ahead = null;
836
+ behind = null;
837
+ }
838
+ }
839
+
840
+ const conflicts = (await git(dir, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).trim().split('\n').filter(Boolean);
841
+
842
+ return { component, dir, branch, head, upstream, ahead, behind, isClean, conflicts };
843
+ }
844
+
845
+ async function cmdPush({ rootDir, argv }) {
846
+ const { flags, kv } = parseArgs(argv);
847
+ const positionals = argv.filter((a) => !a.startsWith('--'));
848
+ const component = positionals[1];
849
+ const spec = positionals[2] ?? '';
850
+ if (!component) {
851
+ throw new Error('[wt] usage: happys wt push <component> [worktreeSpec|default|path] [--remote=origin] [--dry-run]');
852
+ }
853
+
854
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
855
+ if (!(await pathExists(dir))) {
856
+ throw new Error(`[wt] target does not exist: ${dir}`);
857
+ }
858
+
859
+ const branch = (await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
860
+ if (!branch || branch === 'HEAD') {
861
+ throw new Error('[wt] cannot push detached HEAD (checkout a branch first)');
862
+ }
863
+
864
+ let remote = (kv.get('--remote') ?? '').trim() || 'origin';
865
+ remote = (await normalizeRemoteName(dir, remote)) || remote;
866
+ const args = ['push', '-u', remote, 'HEAD'];
867
+ if (flags.has('--dry-run')) {
868
+ args.push('--dry-run');
869
+ }
870
+ await git(dir, args);
871
+ return { component, dir, remote, branch, dryRun: flags.has('--dry-run') };
872
+ }
873
+
874
+ async function cmdUpdate({ rootDir, argv }) {
875
+ const { flags, kv } = parseArgs(argv);
876
+ const positionals = argv.filter((a) => !a.startsWith('--'));
877
+ const component = positionals[1];
878
+ const spec = positionals[2] ?? '';
879
+ if (!component) {
880
+ throw new Error(
881
+ '[wt] usage: happys wt update <component> [worktreeSpec|default|path] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--force]'
882
+ );
883
+ }
884
+
885
+ const repoRoot = getComponentRepoRoot(rootDir, component);
886
+ if (!(await pathExists(repoRoot))) {
887
+ throw new Error(`[wt] missing component repo at ${repoRoot}`);
888
+ }
889
+
890
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
891
+ if (!(await pathExists(dir))) {
892
+ throw new Error(`[wt] target does not exist: ${dir}`);
893
+ }
894
+
895
+ const statusBefore = await cmdStatus({ rootDir, argv: ['status', component, dir] });
896
+ if (!statusBefore.isClean && !flags.has('--stash') && !flags.has('--stash-keep')) {
897
+ throw new Error(`[wt] working tree is not clean (${dir}). Re-run with --stash to auto-stash changes.`);
898
+ }
899
+
900
+ let remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
901
+ const remote = await resolveRemoteOwner(repoRoot, remoteName);
902
+ remoteName = remote.remoteName;
903
+ const { owner } = remote;
904
+ const defaultBranch = await resolveRemoteDefaultBranchName(repoRoot, remoteName, { component });
905
+ const mirrorBranch = `${owner}/${defaultBranch}`;
906
+
907
+ const baseOverride = (kv.get('--base') ?? '').trim();
908
+ const base = baseOverride || mirrorBranch;
909
+
910
+ // Keep the mirror branch updated when using the default base.
911
+ if (!baseOverride) {
912
+ await cmdSync({ rootDir, argv: ['sync', component, `--remote=${remoteName}`] });
913
+ }
914
+
915
+ const mode = flags.has('--merge') ? 'merge' : 'rebase';
916
+ const dryRun = flags.has('--dry-run');
917
+ const force = flags.has('--force');
918
+ const stashRequested = flags.has('--stash') || flags.has('--stash-keep');
919
+ const stashKeep = flags.has('--stash-keep');
920
+
921
+ if (dryRun && stashRequested) {
922
+ throw new Error('[wt] --dry-run cannot be combined with --stash/--stash-keep (it would modify your working tree)');
923
+ }
924
+
925
+ const conflictFiles = async () => {
926
+ const out = (await git(dir, ['diff', '--name-only', '--diff-filter=U']).catch(() => '')).trim();
927
+ return out ? out.split('\n').filter(Boolean) : [];
928
+ };
929
+
930
+ const abortMerge = async () => {
931
+ await git(dir, ['merge', '--abort']).catch(() => {});
932
+ };
933
+ const abortRebase = async () => {
934
+ await git(dir, ['rebase', '--abort']).catch(() => {});
935
+ };
936
+
937
+ // Dry-run: try a merge and abort to see if it would conflict.
938
+ if (dryRun) {
939
+ const status = await cmdStatus({ rootDir, argv: ['status', component, dir] });
940
+ if (!status.isClean) {
941
+ throw new Error(`[wt] working tree is not clean (${dir}). Commit/stash first.`);
942
+ }
943
+ let ok = true;
944
+ let conflicts = [];
945
+ try {
946
+ await git(dir, ['merge', '--no-commit', '--no-ff', '--no-stat', base]);
947
+ conflicts = await conflictFiles();
948
+ ok = conflicts.length === 0;
949
+ } catch {
950
+ conflicts = await conflictFiles();
951
+ ok = conflicts.length === 0 ? false : false;
952
+ } finally {
953
+ await abortMerge();
954
+ }
955
+ return { component, dir, mode, base, dryRun: true, ok, conflicts };
956
+ }
957
+
958
+ // Optionally stash before applying.
959
+ const oldHead = (await git(dir, ['rev-parse', 'HEAD'])).trim();
960
+ const stash = await maybeStash({
961
+ dir,
962
+ enabled: flags.has('--stash'),
963
+ keep: stashKeep,
964
+ message: `[happy-stacks] wt update ${component}`,
965
+ });
966
+ if (!(await isWorktreeClean(dir)) && !stash.stashed) {
967
+ throw new Error(`[wt] working tree is not clean (${dir}). Re-run with --stash to auto-stash changes.`);
968
+ }
969
+
970
+ // Apply update.
971
+ if (mode === 'merge') {
972
+ try {
973
+ await git(dir, ['merge', '--no-edit', base]);
974
+ const stashPop = await maybePopStash({ dir, stashed: stash.stashed, keep: stash.kept });
975
+ if (stashPop.popError) {
976
+ if (!force) {
977
+ await hardReset({ dir, target: oldHead });
978
+ return {
979
+ component,
980
+ dir,
981
+ mode,
982
+ base,
983
+ ok: false,
984
+ conflicts: [],
985
+ error: 'stash-pop-conflict',
986
+ message:
987
+ `[wt] update succeeded, but restoring stashed changes conflicted.\n` +
988
+ `[wt] Reverted update. Worktree: ${dir}\n` +
989
+ `[wt] Re-run with --stash --force to keep the conflict state for manual resolution.`,
990
+ stash,
991
+ stashPop,
992
+ };
993
+ }
994
+ return {
995
+ component,
996
+ dir,
997
+ mode,
998
+ base,
999
+ ok: false,
1000
+ conflicts: await conflictFiles(),
1001
+ forceApplied: true,
1002
+ error: 'stash-pop-conflict',
1003
+ message: `[wt] update succeeded, but restoring stashed changes conflicted (kept for manual resolution). Worktree: ${dir}`,
1004
+ stash,
1005
+ stashPop,
1006
+ };
1007
+ }
1008
+ return { component, dir, mode, base, ok: true, conflicts: [], stash, stashPop };
1009
+ } catch {
1010
+ const conflicts = await conflictFiles();
1011
+ if (!force) {
1012
+ await abortMerge();
1013
+ }
1014
+ return { component, dir, mode, base, ok: false, conflicts, forceApplied: force, stash, stashPop: { popped: false } };
1015
+ }
1016
+ }
1017
+
1018
+ // Default: rebase (preferred for clean PR branches).
1019
+ try {
1020
+ await git(dir, ['rebase', base]);
1021
+ const stashPop = await maybePopStash({ dir, stashed: stash.stashed, keep: stash.kept });
1022
+ if (stashPop.popError) {
1023
+ if (!force) {
1024
+ await hardReset({ dir, target: oldHead });
1025
+ return {
1026
+ component,
1027
+ dir,
1028
+ mode,
1029
+ base,
1030
+ ok: false,
1031
+ conflicts: [],
1032
+ error: 'stash-pop-conflict',
1033
+ message:
1034
+ `[wt] update succeeded, but restoring stashed changes conflicted.\n` +
1035
+ `[wt] Reverted update. Worktree: ${dir}\n` +
1036
+ `[wt] Re-run with --stash --force to keep the conflict state for manual resolution.`,
1037
+ stash,
1038
+ stashPop,
1039
+ };
1040
+ }
1041
+ return {
1042
+ component,
1043
+ dir,
1044
+ mode,
1045
+ base,
1046
+ ok: false,
1047
+ conflicts: await conflictFiles(),
1048
+ forceApplied: true,
1049
+ error: 'stash-pop-conflict',
1050
+ message: `[wt] update succeeded, but restoring stashed changes conflicted (kept for manual resolution). Worktree: ${dir}`,
1051
+ stash,
1052
+ stashPop,
1053
+ };
1054
+ }
1055
+ return { component, dir, mode, base, ok: true, conflicts: [], stash, stashPop };
1056
+ } catch {
1057
+ const conflicts = await conflictFiles();
1058
+ if (!force) {
1059
+ await abortRebase();
1060
+ }
1061
+ return { component, dir, mode, base, ok: false, conflicts, forceApplied: force, stash, stashPop: { popped: false } };
1062
+ }
1063
+ }
1064
+
1065
+ function splitDoubleDash(argv) {
1066
+ const idx = argv.indexOf('--');
1067
+ if (idx < 0) {
1068
+ return { before: argv, after: [] };
1069
+ }
1070
+ return { before: argv.slice(0, idx), after: argv.slice(idx + 1) };
1071
+ }
1072
+
1073
+ async function cmdGit({ rootDir, argv }) {
1074
+ const { before, after } = splitDoubleDash(argv);
1075
+ const { flags, kv } = parseArgs(before);
1076
+ const json = wantsJson(before, { flags });
1077
+
1078
+ const positionals = before.filter((a) => !a.startsWith('--'));
1079
+ const component = positionals[1];
1080
+ const spec = positionals[2] ?? '';
1081
+ if (!component) {
1082
+ throw new Error('[wt] usage: happys wt git <component> [worktreeSpec|active|main|default|path] -- <git args...>');
1083
+ }
1084
+ if (!after.length) {
1085
+ throw new Error('[wt] git requires args after `--` (example: happys wt git happy main -- status)');
1086
+ }
1087
+
1088
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1089
+ if (!(await pathExists(dir))) {
1090
+ throw new Error(`[wt] target does not exist: ${dir}`);
1091
+ }
1092
+
1093
+ const remote = (kv.get('--remote') ?? '').trim();
1094
+ // Convenience: allow `--remote=<name>` to imply `git fetch <name> ...` etc by user choice.
1095
+ const args = [...after];
1096
+ if (remote && (args[0] === 'fetch' || args[0] === 'pull' || args[0] === 'push') && !args.includes(remote)) {
1097
+ // leave untouched; user should pass remote explicitly for correctness
1098
+ }
1099
+
1100
+ if (json) {
1101
+ const stdout = await git(dir, args);
1102
+ return { component, dir, args, stdout };
1103
+ }
1104
+
1105
+ await run('git', args, { cwd: dir });
1106
+ return { component, dir, args };
1107
+ }
1108
+
1109
+ async function cmdSync({ rootDir, argv }) {
1110
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1111
+ const component = positionals[1];
1112
+ if (!component) {
1113
+ throw new Error('[wt] usage: happys wt sync <component> [--remote=<name>]');
1114
+ }
1115
+
1116
+ const { kv } = parseArgs(argv);
1117
+ const repoRoot = getComponentRepoRoot(rootDir, component);
1118
+ if (!(await pathExists(repoRoot))) {
1119
+ throw new Error(`[wt] missing component repo at ${repoRoot}`);
1120
+ }
1121
+
1122
+ let remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
1123
+ const remote = await resolveRemoteOwner(repoRoot, remoteName);
1124
+ remoteName = remote.remoteName;
1125
+ const { owner } = remote;
1126
+ const defaultBranch = await resolveRemoteDefaultBranchName(repoRoot, remoteName, { component });
1127
+
1128
+ await git(repoRoot, ['fetch', '--quiet', remoteName, defaultBranch]);
1129
+
1130
+ const mirrorBranch = `${owner}/${defaultBranch}`;
1131
+ await git(repoRoot, ['branch', '-f', mirrorBranch, `${remoteName}/${defaultBranch}`]);
1132
+ // Best-effort: set upstream (works even if already set).
1133
+ await git(repoRoot, ['branch', '--set-upstream-to', `${remoteName}/${defaultBranch}`, mirrorBranch]).catch(() => {});
1134
+
1135
+ return { component, remote: remoteName, mirrorBranch, upstreamRef: `${remoteName}/${defaultBranch}` };
1136
+ }
1137
+
1138
+ async function commandExists(cmd) {
1139
+ try {
1140
+ const out = (await runCapture('sh', ['-lc', `command -v ${cmd} >/dev/null 2>&1 && echo yes || echo no`])).trim();
1141
+ return out === 'yes';
1142
+ } catch {
1143
+ return false;
1144
+ }
1145
+ }
1146
+
1147
+ async function fileExists(path) {
1148
+ try {
1149
+ return await pathExists(path);
1150
+ } catch {
1151
+ return false;
1152
+ }
1153
+ }
1154
+
1155
+ async function pickBestShell({ kv, prefer = null } = {}) {
1156
+ const fromFlag = (kv?.get('--shell') ?? '').trim();
1157
+ const fromEnv = (process.env.HAPPY_LOCAL_WT_SHELL ?? '').trim();
1158
+ const fromShellEnv = (process.env.SHELL ?? '').trim();
1159
+ const want = (fromFlag || fromEnv || prefer || fromShellEnv).trim();
1160
+ if (want) {
1161
+ return want;
1162
+ }
1163
+
1164
+ const candidates =
1165
+ process.platform === 'win32'
1166
+ ? []
1167
+ : ['/bin/zsh', '/usr/bin/zsh', '/bin/bash', '/usr/bin/bash', '/bin/sh', '/usr/bin/sh'];
1168
+ for (const c of candidates) {
1169
+ // eslint-disable-next-line no-await-in-loop
1170
+ if (await fileExists(c)) {
1171
+ return c;
1172
+ }
1173
+ }
1174
+ return process.env.SHELL || '/bin/sh';
1175
+ }
1176
+
1177
+ function escapeForShellDoubleQuotes(s) {
1178
+ return (s ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1179
+ }
1180
+
1181
+ async function openTerminalAuto({ dir, shell }) {
1182
+ const termPref = (process.env.HAPPY_LOCAL_WT_TERMINAL ?? '').trim().toLowerCase();
1183
+ const order = termPref ? [termPref] : ['ghostty', 'iterm', 'terminal', 'current'];
1184
+
1185
+ for (const t of order) {
1186
+ if (t === 'current') {
1187
+ return { kind: 'current' };
1188
+ }
1189
+
1190
+ if (t === 'ghostty') {
1191
+ if (await commandExists('ghostty')) {
1192
+ try {
1193
+ // Best-effort. Ghostty supports --working-directory on recent builds.
1194
+ await run('ghostty', ['--working-directory', dir], { cwd: dir, env: process.env, stdio: 'inherit' });
1195
+ return { kind: 'ghostty' };
1196
+ } catch {
1197
+ // fall through
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ if (t === 'iterm') {
1203
+ if (process.platform === 'darwin') {
1204
+ try {
1205
+ const cmd = `cd "${escapeForShellDoubleQuotes(dir)}"; exec "${escapeForShellDoubleQuotes(shell)}" -i`;
1206
+ // Create a new iTerm window and cd into the directory.
1207
+ await run('osascript', [
1208
+ '-e',
1209
+ 'tell application "iTerm" to activate',
1210
+ '-e',
1211
+ 'tell application "iTerm" to create window with default profile',
1212
+ '-e',
1213
+ `tell application "iTerm" to tell current session of current window to write text "${cmd}"`,
1214
+ ]);
1215
+ return { kind: 'iterm' };
1216
+ } catch {
1217
+ // fall through
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ if (t === 'terminal') {
1223
+ if (process.platform === 'darwin') {
1224
+ try {
1225
+ // Terminal.app: `open -a Terminal <dir>` opens a window in that dir.
1226
+ await run('open', ['-a', 'Terminal', dir], { cwd: dir, env: process.env, stdio: 'inherit' });
1227
+ return { kind: 'terminal' };
1228
+ } catch {
1229
+ // fall through
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ return { kind: 'current' };
1236
+ }
1237
+
1238
+ async function cmdShell({ rootDir, argv }) {
1239
+ const { flags, kv } = parseArgs(argv);
1240
+ const json = wantsJson(argv, { flags });
1241
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1242
+ const component = positionals[1];
1243
+ const spec = positionals[2] ?? '';
1244
+ if (!component) {
1245
+ throw new Error(
1246
+ '[wt] usage: happys wt shell <component> [worktreeSpec|active|default|main|path] [--shell=/bin/zsh] [--terminal=auto|current|ghostty|iterm|terminal] [--new-window] [--json]'
1247
+ );
1248
+ }
1249
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1250
+ if (!(await pathExists(dir))) {
1251
+ throw new Error(`[wt] target does not exist: ${dir}`);
1252
+ }
1253
+
1254
+ const shell = await pickBestShell({ kv });
1255
+ const args = ['-i'];
1256
+ const terminalFlag = (kv.get('--terminal') ?? '').trim().toLowerCase();
1257
+ const newWindow = flags.has('--new-window');
1258
+ const wantTerminal = terminalFlag || (newWindow ? 'auto' : 'current');
1259
+
1260
+ if (json) {
1261
+ return { component, dir, shell, args, terminal: wantTerminal };
1262
+ }
1263
+
1264
+ // This launches a new interactive shell with cwd=dir. It can't change the parent shell, but this is a "real" cd.
1265
+ if (wantTerminal === 'current') {
1266
+ await run(shell, args, { cwd: dir, env: process.env, stdio: 'inherit' });
1267
+ return { component, dir, shell, args, terminal: 'current' };
1268
+ }
1269
+
1270
+ if (wantTerminal === 'auto') {
1271
+ const chosen = await openTerminalAuto({ dir, shell });
1272
+ if (chosen.kind === 'current') {
1273
+ await run(shell, args, { cwd: dir, env: process.env, stdio: 'inherit' });
1274
+ }
1275
+ return { component, dir, shell, args, terminal: chosen.kind };
1276
+ }
1277
+
1278
+ // Explicit terminal selection (best-effort).
1279
+ process.env.HAPPY_LOCAL_WT_TERMINAL = wantTerminal;
1280
+ const chosen = await openTerminalAuto({ dir, shell });
1281
+ if (chosen.kind === 'current') {
1282
+ await run(shell, args, { cwd: dir, env: process.env, stdio: 'inherit' });
1283
+ }
1284
+ return { component, dir, shell, args, terminal: chosen.kind };
1285
+ return { component, dir, shell, args };
1286
+ }
1287
+
1288
+ async function cmdCode({ rootDir, argv }) {
1289
+ const { flags } = parseArgs(argv);
1290
+ const json = wantsJson(argv, { flags });
1291
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1292
+ const component = positionals[1];
1293
+ const spec = positionals[2] ?? '';
1294
+ if (!component) {
1295
+ throw new Error('[wt] usage: happys wt code <component> [worktreeSpec|active|default|main|path] [--json]');
1296
+ }
1297
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1298
+ if (!(await pathExists(dir))) {
1299
+ throw new Error(`[wt] target does not exist: ${dir}`);
1300
+ }
1301
+ if (!(await commandExists('code'))) {
1302
+ throw new Error("[wt] VS Code CLI 'code' not found on PATH. In VS Code: Cmd+Shift+P → 'Shell Command: Install code command in PATH'.");
1303
+ }
1304
+ if (json) {
1305
+ return { component, dir, cmd: 'code' };
1306
+ }
1307
+ await run('code', [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1308
+ return { component, dir, cmd: 'code' };
1309
+ }
1310
+
1311
+ async function cmdCursor({ rootDir, argv }) {
1312
+ const { flags } = parseArgs(argv);
1313
+ const json = wantsJson(argv, { flags });
1314
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1315
+ const component = positionals[1];
1316
+ const spec = positionals[2] ?? '';
1317
+ if (!component) {
1318
+ throw new Error('[wt] usage: happys wt cursor <component> [worktreeSpec|active|default|main|path] [--json]');
1319
+ }
1320
+ const dir = resolveComponentWorktreeDir({ rootDir, component, spec });
1321
+ if (!(await pathExists(dir))) {
1322
+ throw new Error(`[wt] target does not exist: ${dir}`);
1323
+ }
1324
+
1325
+ const hasCursorCli = await commandExists('cursor');
1326
+ if (json) {
1327
+ return { component, dir, cmd: hasCursorCli ? 'cursor' : process.platform === 'darwin' ? 'open -a Cursor' : null };
1328
+ }
1329
+
1330
+ if (hasCursorCli) {
1331
+ await run('cursor', [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1332
+ return { component, dir, cmd: 'cursor' };
1333
+ }
1334
+
1335
+ if (process.platform === 'darwin') {
1336
+ await run('open', ['-a', 'Cursor', dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1337
+ return { component, dir, cmd: 'open -a Cursor' };
1338
+ }
1339
+
1340
+ throw new Error("[wt] Cursor CLI 'cursor' not found on PATH (and non-macOS fallback is unavailable).");
1341
+ }
1342
+
1343
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
1344
+
1345
+ async function cmdSyncAll({ rootDir, argv }) {
1346
+ const { flags, kv } = parseArgs(argv);
1347
+ const json = wantsJson(argv, { flags });
1348
+
1349
+ const remote = (kv.get('--remote') ?? '').trim();
1350
+ const components = DEFAULT_COMPONENTS;
1351
+
1352
+ const results = [];
1353
+ for (const component of components) {
1354
+ try {
1355
+ const res = await cmdSync({
1356
+ rootDir,
1357
+ argv: remote ? ['sync', component, `--remote=${remote}`] : ['sync', component],
1358
+ });
1359
+ results.push({ component, ok: true, ...res });
1360
+ } catch (e) {
1361
+ results.push({ component, ok: false, error: String(e?.message ?? e) });
1362
+ }
1363
+ }
1364
+
1365
+ const ok = results.every((r) => r.ok);
1366
+ if (json) {
1367
+ return { ok, results };
1368
+ }
1369
+
1370
+ const lines = ['[wt] sync-all:'];
1371
+ for (const r of results) {
1372
+ if (r.ok) {
1373
+ lines.push(`- ✅ ${r.component}: ${r.mirrorBranch} -> ${r.upstreamRef}`);
1374
+ } else {
1375
+ lines.push(`- ❌ ${r.component}: ${r.error}`);
1376
+ }
1377
+ }
1378
+ return { ok, results, text: lines.join('\n') };
1379
+ }
1380
+
1381
+ async function listComponentWorktreePaths({ rootDir, component }) {
1382
+ const repoRoot = getComponentRepoRoot(rootDir, component);
1383
+ if (!(await pathExists(repoRoot))) {
1384
+ return [];
1385
+ }
1386
+ const out = await git(repoRoot, ['worktree', 'list', '--porcelain']);
1387
+ const wts = parseWorktreeListPorcelain(out);
1388
+ return wts.map((w) => w.path).filter(Boolean);
1389
+ }
1390
+
1391
+ async function cmdUpdateAll({ rootDir, argv }) {
1392
+ const { flags, kv } = parseArgs(argv);
1393
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1394
+ const maybeComponent = positionals[1]?.trim() ? positionals[1].trim() : '';
1395
+ const components = maybeComponent ? [maybeComponent] : DEFAULT_COMPONENTS;
1396
+
1397
+ const json = wantsJson(argv, { flags });
1398
+
1399
+ const remote = (kv.get('--remote') ?? '').trim();
1400
+ const base = (kv.get('--base') ?? '').trim();
1401
+ const mode = flags.has('--merge') ? 'merge' : 'rebase';
1402
+ const dryRun = flags.has('--dry-run');
1403
+ const force = flags.has('--force');
1404
+ const stash = flags.has('--stash');
1405
+ const stashKeep = flags.has('--stash-keep');
1406
+
1407
+ const results = [];
1408
+ for (const component of components) {
1409
+ const paths = await listComponentWorktreePaths({ rootDir, component });
1410
+ for (const dir of paths) {
1411
+ try {
1412
+ const args = ['update', component, dir];
1413
+ if (remote) args.push(`--remote=${remote}`);
1414
+ if (base) args.push(`--base=${base}`);
1415
+ if (mode === 'merge') args.push('--merge');
1416
+ if (dryRun) args.push('--dry-run');
1417
+ if (stash) args.push('--stash');
1418
+ if (stashKeep) args.push('--stash-keep');
1419
+ if (force) args.push('--force');
1420
+ const res = await cmdUpdate({ rootDir, argv: args });
1421
+ results.push({ component, dir, ...res });
1422
+ } catch (e) {
1423
+ results.push({ component, dir, ok: false, error: String(e?.message ?? e) });
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ const ok = results.every((r) => r.ok);
1429
+ if (json) {
1430
+ return { ok, mode, dryRun, force, base: base || '(mirror)', remote: remote || '(default)', results };
1431
+ }
1432
+
1433
+ const lines = [
1434
+ `[wt] update-all (${mode}${dryRun ? ', dry-run' : ''}${force ? ', force' : ''})`,
1435
+ base ? `- base: ${base}` : '- base: <mirror owner/<default-branch>>',
1436
+ remote ? `- remote: ${remote}` : '- remote: upstream',
1437
+ ];
1438
+ for (const r of results) {
1439
+ if (r.ok) {
1440
+ lines.push(`- ✅ ${r.component}: ${r.dir}`);
1441
+ } else if (r.conflicts?.length) {
1442
+ lines.push(`- ⚠️ ${r.component}: conflicts (${r.dir})`);
1443
+ for (const f of r.conflicts) lines.push(` - ${f}`);
1444
+ } else {
1445
+ lines.push(`- ❌ ${r.component}: ${r.error} (${r.dir})`);
1446
+ }
1447
+ }
1448
+ return { ok, results, text: lines.join('\n') };
1449
+ }
1450
+
1451
+ async function cmdNewInteractive({ rootDir, argv }) {
1452
+ const { flags, kv } = parseArgs(argv);
1453
+ await withRl(async (rl) => {
1454
+ const component = await prompt(rl, 'Component [happy|happy-cli|happy-server-light|happy-server]: ', { defaultValue: '' });
1455
+ if (!component) {
1456
+ throw new Error('[wt] component is required');
1457
+ }
1458
+ const slug = await prompt(rl, 'Branch slug (example: pr/my-feature): ', { defaultValue: '' });
1459
+ if (!slug) {
1460
+ throw new Error('[wt] slug is required');
1461
+ }
1462
+
1463
+ // Default remote is upstream; allow override.
1464
+ const remote = await prompt(rl, 'Remote name (default: upstream): ', { defaultValue: 'upstream' });
1465
+
1466
+ const args = ['new', component, slug, `--remote=${remote}`];
1467
+ if (kv.get('--base')?.trim()) {
1468
+ args.push(`--base=${kv.get('--base').trim()}`);
1469
+ }
1470
+ if (flags.has('--use')) {
1471
+ args.push('--use');
1472
+ }
1473
+ await cmdNew({ rootDir, argv: args });
1474
+ });
1475
+ }
1476
+
1477
+ async function cmdList({ rootDir, args }) {
1478
+ const component = args[0];
1479
+ if (!component) {
1480
+ throw new Error('[wt] usage: happys wt list <component>');
1481
+ }
1482
+
1483
+ const wtRoot = getWorktreesRoot(rootDir);
1484
+ const dir = join(wtRoot, component);
1485
+ if (!(await pathExists(dir))) {
1486
+ return { component, activeDir: (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component), worktrees: [] };
1487
+ }
1488
+
1489
+ const leafs = [];
1490
+ const walk = async (d) => {
1491
+ const entries = await readdir(d, { withFileTypes: true });
1492
+ for (const e of entries) {
1493
+ if (!e.isDirectory()) {
1494
+ continue;
1495
+ }
1496
+ const p = join(d, e.name);
1497
+ leafs.push(p);
1498
+ await walk(p);
1499
+ }
1500
+ };
1501
+ await walk(dir);
1502
+ leafs.sort();
1503
+
1504
+ const key = componentDirEnvKey(component);
1505
+ const active = (process.env[key] ?? '').trim() || join(getComponentsDir(rootDir), component);
1506
+
1507
+ const worktrees = [];
1508
+ for (const p of leafs) {
1509
+ if (await pathExists(join(p, '.git'))) {
1510
+ worktrees.push(p);
1511
+ }
1512
+ }
1513
+ return { component, activeDir: active, worktrees };
1514
+ }
1515
+
1516
+ async function main() {
1517
+ const rootDir = getRootDir(import.meta.url);
1518
+ const argv = process.argv.slice(2);
1519
+ const { flags } = parseArgs(argv);
1520
+ const positionals = argv.filter((a) => !a.startsWith('--'));
1521
+ const cmd = positionals[0] ?? 'help';
1522
+ const interactive = argv.includes('--interactive') || argv.includes('-i');
1523
+ const json = wantsJson(argv, { flags });
1524
+
1525
+ if (wantsHelp(argv, { flags }) || cmd === 'help') {
1526
+ printResult({
1527
+ json,
1528
+ data: {
1529
+ commands: ['migrate', 'sync', 'sync-all', 'list', 'new', 'pr', 'use', 'status', 'update', 'update-all', 'push', 'git', 'shell', 'code', 'cursor'],
1530
+ interactive: ['new', 'use'],
1531
+ },
1532
+ text: [
1533
+ '[wt] usage:',
1534
+ ' happys wt migrate [--json]',
1535
+ ' happys wt sync <component> [--remote=<name>] [--json]',
1536
+ ' happys wt sync-all [--remote=<name>] [--json]',
1537
+ ' happys wt list <component> [--json]',
1538
+ ' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--interactive|-i] [--json]',
1539
+ ' 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]',
1540
+ ' happys wt use <component> <owner/branch|path|default|main> [--interactive|-i] [--json]',
1541
+ ' happys wt status <component> [worktreeSpec|default|path] [--json]',
1542
+ ' happys wt update <component> [worktreeSpec|default|path] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
1543
+ ' happys wt update-all [component] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
1544
+ ' happys wt push <component> [worktreeSpec|default|path] [--remote=origin] [--dry-run] [--json]',
1545
+ ' happys wt git <component> [worktreeSpec|active|default|main|path] -- <git args...> [--json]',
1546
+ ' happys wt shell <component> [worktreeSpec|active|default|main|path] [--shell=/bin/zsh] [--json]',
1547
+ ' happys wt code <component> [worktreeSpec|active|default|main|path] [--json]',
1548
+ ' happys wt cursor <component> [worktreeSpec|active|default|main|path] [--json]',
1549
+ '',
1550
+ 'selectors:',
1551
+ ' (omitted) or "active": current active checkout (env override if set; else components/<component>)',
1552
+ ' "default" or "main": components/<component>',
1553
+ ' "<owner>/<branch...>": components/.worktrees/<component>/<owner>/<branch...>',
1554
+ ' "<absolute path>": explicit checkout path',
1555
+ '',
1556
+ 'components:',
1557
+ ' happy | happy-cli | happy-server-light | happy-server',
1558
+ ].join('\n'),
1559
+ });
1560
+ return;
1561
+ }
1562
+
1563
+ if (cmd === 'migrate') {
1564
+ const res = await cmdMigrate({ rootDir });
1565
+ printResult({ json, data: res, text: `[wt] migrate complete (moved=${res.moved}, branchesRenamed=${res.branchesRenamed})` });
1566
+ return;
1567
+ }
1568
+ if (cmd === 'use') {
1569
+ if (interactive && isTty()) {
1570
+ await cmdUseInteractive({ rootDir });
1571
+ } else {
1572
+ const res = await cmdUse({ rootDir, args: positionals.slice(1) });
1573
+ printResult({ json, data: res, text: `[wt] ${res.component}: active dir -> ${res.activeDir}` });
1574
+ }
1575
+ return;
1576
+ }
1577
+ if (cmd === 'new') {
1578
+ if (interactive && isTty()) {
1579
+ await cmdNewInteractive({ rootDir, argv: argv.slice(1) });
1580
+ } else {
1581
+ const res = await cmdNew({ rootDir, argv });
1582
+ printResult({
1583
+ json,
1584
+ data: res,
1585
+ text: `[wt] created ${res.component} worktree: ${res.path} (${res.branch} based on ${res.base})`,
1586
+ });
1587
+ }
1588
+ return;
1589
+ }
1590
+ if (cmd === 'pr') {
1591
+ const res = await cmdPr({ rootDir, argv });
1592
+ printResult({
1593
+ json,
1594
+ data: res,
1595
+ text: `[wt] created PR worktree for ${res.component}: ${res.path} (${res.branch})`,
1596
+ });
1597
+ return;
1598
+ }
1599
+ if (cmd === 'sync') {
1600
+ const res = await cmdSync({ rootDir, argv });
1601
+ printResult({ json, data: res, text: `[wt] ${res.component}: synced ${res.mirrorBranch} -> ${res.upstreamRef}` });
1602
+ return;
1603
+ }
1604
+ if (cmd === 'sync-all') {
1605
+ const res = await cmdSyncAll({ rootDir, argv });
1606
+ if (json) {
1607
+ printResult({ json, data: res });
1608
+ } else {
1609
+ printResult({ json: false, text: res.text });
1610
+ }
1611
+ return;
1612
+ }
1613
+ if (cmd === 'status') {
1614
+ const res = await cmdStatus({ rootDir, argv });
1615
+ if (json) {
1616
+ printResult({ json, data: res });
1617
+ } else {
1618
+ const lines = [
1619
+ `[wt] ${res.component}: ${res.dir}`,
1620
+ `- branch: ${res.branch}`,
1621
+ `- upstream: ${res.upstream ?? '(none)'}`,
1622
+ `- ahead/behind: ${res.ahead ?? '?'} / ${res.behind ?? '?'}`,
1623
+ `- clean: ${res.isClean ? 'yes' : 'no'}`,
1624
+ `- conflicts: ${res.conflicts.length ? res.conflicts.join(', ') : '(none)'}`,
1625
+ ];
1626
+ printResult({ json: false, text: lines.join('\n') });
1627
+ }
1628
+ return;
1629
+ }
1630
+ if (cmd === 'update') {
1631
+ const res = await cmdUpdate({ rootDir, argv });
1632
+ if (json) {
1633
+ printResult({ json, data: res });
1634
+ } else if (res.ok) {
1635
+ printResult({ json: false, text: `[wt] ${res.component}: updated (${res.mode}) from ${res.base}` });
1636
+ } else {
1637
+ if (res.message) {
1638
+ printResult({ json: false, text: res.message });
1639
+ return;
1640
+ }
1641
+ const text =
1642
+ `[wt] ${res.component}: update had conflicts (${res.mode}) from ${res.base}\n` +
1643
+ `worktree: ${res.dir}\n` +
1644
+ `conflicts:\n` +
1645
+ (res.conflicts.length ? res.conflicts.map((f) => `- ${f}`).join('\n') : '- (unknown)') +
1646
+ `\n` +
1647
+ (res.forceApplied
1648
+ ? '[wt] conflicts left in place for manual resolution (--force)'
1649
+ : '[wt] update aborted; re-run with --force to keep conflict state for manual resolution');
1650
+ printResult({ json: false, text });
1651
+ }
1652
+ return;
1653
+ }
1654
+ if (cmd === 'update-all') {
1655
+ const res = await cmdUpdateAll({ rootDir, argv });
1656
+ if (json) {
1657
+ printResult({ json, data: res });
1658
+ } else {
1659
+ printResult({ json: false, text: res.text });
1660
+ }
1661
+ return;
1662
+ }
1663
+ if (cmd === 'push') {
1664
+ const res = await cmdPush({ rootDir, argv });
1665
+ printResult({
1666
+ json,
1667
+ data: res,
1668
+ text: res.dryRun
1669
+ ? `[wt] ${res.component}: would push ${res.branch} -> ${res.remote} (dry-run)`
1670
+ : `[wt] ${res.component}: pushed ${res.branch} -> ${res.remote}`,
1671
+ });
1672
+ return;
1673
+ }
1674
+ if (cmd === 'git') {
1675
+ const res = await cmdGit({ rootDir, argv });
1676
+ if (json) {
1677
+ printResult({ json, data: res });
1678
+ }
1679
+ return;
1680
+ }
1681
+ if (cmd === 'shell') {
1682
+ const res = await cmdShell({ rootDir, argv });
1683
+ if (json) {
1684
+ printResult({ json, data: res });
1685
+ }
1686
+ return;
1687
+ }
1688
+ if (cmd === 'code') {
1689
+ const res = await cmdCode({ rootDir, argv });
1690
+ if (json) {
1691
+ printResult({ json, data: res });
1692
+ }
1693
+ return;
1694
+ }
1695
+ if (cmd === 'cursor') {
1696
+ const res = await cmdCursor({ rootDir, argv });
1697
+ if (json) {
1698
+ printResult({ json, data: res });
1699
+ }
1700
+ return;
1701
+ }
1702
+ if (cmd === 'list') {
1703
+ const res = await cmdList({ rootDir, args: positionals.slice(1) });
1704
+ if (json) {
1705
+ printResult({ json, data: res });
1706
+ } else {
1707
+ const lines = [`[wt] ${res.component} worktrees:`, `- active: ${res.activeDir}`];
1708
+ for (const p of res.worktrees) {
1709
+ lines.push(`- ${p}`);
1710
+ }
1711
+ printResult({ json: false, text: lines.join('\n') });
1712
+ }
1713
+ return;
1714
+ }
1715
+ throw new Error(`[wt] unknown command: ${cmd}`);
1716
+ }
1717
+
1718
+ main().catch((err) => {
1719
+ console.error('[wt] failed:', err);
1720
+ process.exit(1);
1721
+ });