happy-stacks 0.1.0 → 0.2.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 (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -0
  64. package/scripts/utils/dev_daemon.mjs +104 -0
  65. package/scripts/utils/dev_expo_web.mjs +112 -0
  66. package/scripts/utils/dev_server.mjs +183 -0
  67. package/scripts/utils/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -0,0 +1,63 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ function safeWatch(path, handler) {
4
+ try {
5
+ // Node supports recursive watching on macOS and Windows. On Linux this may throw; we fail closed by returning null.
6
+ return watch(path, { recursive: true }, handler);
7
+ } catch {
8
+ try {
9
+ return watch(path, {}, handler);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Very small, dependency-free debounced watcher.
18
+ * Intended for dev ergonomics (rebuild/restart), not for correctness-critical logic.
19
+ */
20
+ export function watchDebounced({ paths, debounceMs = 500, onChange } = {}) {
21
+ const list = Array.isArray(paths) ? paths.filter(Boolean) : [];
22
+ if (!list.length) return null;
23
+ if (typeof onChange !== 'function') return null;
24
+
25
+ let closed = false;
26
+ let t = null;
27
+ const watchers = [];
28
+
29
+ const trigger = (eventType, filename) => {
30
+ if (closed) return;
31
+ if (t) clearTimeout(t);
32
+ t = setTimeout(() => {
33
+ t = null;
34
+ try {
35
+ onChange({ eventType, filename });
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }, debounceMs);
40
+ };
41
+
42
+ for (const p of list) {
43
+ const w = safeWatch(p, trigger);
44
+ if (w) watchers.push(w);
45
+ }
46
+
47
+ if (!watchers.length) return null;
48
+
49
+ return {
50
+ close() {
51
+ closed = true;
52
+ if (t) clearTimeout(t);
53
+ for (const w of watchers) {
54
+ try {
55
+ w.close();
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }
60
+ },
61
+ };
62
+ }
63
+
@@ -1,5 +1,5 @@
1
1
  import { readdir } from 'node:fs/promises';
2
- import { isAbsolute, join } from 'node:path';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
3
  import { getComponentsDir } from './paths.mjs';
4
4
  import { pathExists } from './fs.mjs';
5
5
  import { run, runCapture } from './proc.mjs';
@@ -23,6 +23,62 @@ export function componentRepoDir(rootDir, component) {
23
23
  return join(getComponentsDir(rootDir), component);
24
24
  }
25
25
 
26
+ export function isComponentWorktreePath({ rootDir, component, dir }) {
27
+ const raw = String(dir ?? '').trim();
28
+ if (!raw) return false;
29
+ const abs = resolve(raw);
30
+ const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
31
+ return abs.startsWith(root);
32
+ }
33
+
34
+ export function worktreeSpecFromDir({ rootDir, component, dir }) {
35
+ const raw = String(dir ?? '').trim();
36
+ if (!raw) return null;
37
+ if (!isComponentWorktreePath({ rootDir, component, dir: raw })) return null;
38
+ const abs = resolve(raw);
39
+ const root = resolve(join(getWorktreesRoot(rootDir), component)) + '/';
40
+ const rel = abs.slice(root.length).split('/').filter(Boolean);
41
+ if (rel.length < 2) return null;
42
+ // rel = [owner, ...branchParts]
43
+ return rel.join('/');
44
+ }
45
+
46
+ export async function inferRemoteNameForOwner({ repoDir, owner }) {
47
+ const want = String(owner ?? '').trim();
48
+ if (!want) return 'upstream';
49
+
50
+ const candidates = ['upstream', 'origin', 'fork'];
51
+ for (const remoteName of candidates) {
52
+ try {
53
+ const url = (await runCapture('git', ['remote', 'get-url', remoteName], { cwd: repoDir })).trim();
54
+ const o = parseGithubOwner(url);
55
+ if (o && o === want) {
56
+ return remoteName;
57
+ }
58
+ } catch {
59
+ // ignore missing remote
60
+ }
61
+ }
62
+ return 'upstream';
63
+ }
64
+
65
+ export async function createWorktreeFromBaseWorktree({
66
+ rootDir,
67
+ component,
68
+ slug,
69
+ baseWorktreeSpec,
70
+ remoteName = 'upstream',
71
+ depsMode = '',
72
+ }) {
73
+ const args = ['wt', 'new', component, slug, `--remote=${remoteName}`, `--base-worktree=${baseWorktreeSpec}`];
74
+ if (depsMode) args.push(`--deps=${depsMode}`);
75
+ await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), ...args], { cwd: rootDir });
76
+
77
+ const repoDir = componentRepoDir(rootDir, component);
78
+ const owner = await getRemoteOwner({ repoDir, remoteName });
79
+ return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
80
+ }
81
+
26
82
  export function resolveComponentSpecToDir({ rootDir, component, spec }) {
27
83
  const raw = (spec ?? '').trim();
28
84
  if (!raw || raw === 'default') {
package/scripts/where.mjs CHANGED
@@ -1,17 +1,15 @@
1
1
  import './utils/env.mjs';
2
2
 
3
3
  import { existsSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
4
  import { join } from 'node:path';
6
5
 
7
- import { parseArgs } from './utils/args.mjs';
6
+ import { parseArgs } from './utils/cli/args.mjs';
7
+ import { expandHome } from './utils/canonical_home.mjs';
8
8
  import { getComponentsDir, getComponentDir, getHappyStacksHomeDir, getRootDir, getStackLabel, getStackName, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
9
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
10
  import { getRuntimeDir } from './utils/runtime.mjs';
11
-
12
- function expandHome(p) {
13
- return p.replace(/^~(?=\/)/, homedir());
14
- }
11
+ import { getCanonicalHomeDir, getCanonicalHomeEnvPath } from './utils/config.mjs';
12
+ import { getSandboxDir } from './utils/sandbox.mjs';
15
13
 
16
14
  function getHomeEnvPaths() {
17
15
  const homeDir = getHappyStacksHomeDir();
@@ -36,6 +34,9 @@ async function main() {
36
34
 
37
35
  const rootDir = getRootDir(import.meta.url);
38
36
  const homeDir = getHappyStacksHomeDir();
37
+ const canonicalHomeDir = getCanonicalHomeDir();
38
+ const canonicalEnv = getCanonicalHomeEnvPath();
39
+ const sandboxDir = getSandboxDir();
39
40
  const runtimeDir = getRuntimeDir();
40
41
  const workspaceDir = getWorkspaceDir(rootDir);
41
42
  const componentsDir = getComponentsDir(rootDir);
@@ -60,12 +61,15 @@ async function main() {
60
61
  data: {
61
62
  ok: true,
62
63
  rootDir,
64
+ sandbox: sandboxDir ? { enabled: true, dir: sandboxDir } : { enabled: false },
63
65
  homeDir,
66
+ canonicalHomeDir,
64
67
  runtimeDir,
65
68
  workspaceDir,
66
69
  componentsDir,
67
70
  stack: { name: stackName, label: stackLabel },
68
71
  envFiles: {
72
+ canonical: { path: canonicalEnv, exists: existsSync(canonicalEnv) },
69
73
  homeEnv: { path: homeEnv, exists: existsSync(homeEnv) },
70
74
  homeLocal: { path: homeLocal, exists: existsSync(homeLocal) },
71
75
  active: resolvedActiveEnv ? { path: resolvedActiveEnv.envPath, exists: existsSync(resolvedActiveEnv.envPath) } : null,
@@ -80,12 +84,15 @@ async function main() {
80
84
  },
81
85
  text: [
82
86
  `[where] root: ${rootDir}`,
87
+ sandboxDir ? `[where] sandbox: ${sandboxDir}` : null,
88
+ `[where] canonical: ${canonicalHomeDir}`,
83
89
  `[where] home: ${homeDir}`,
84
90
  `[where] runtime: ${runtimeDir}`,
85
91
  `[where] workspace: ${workspaceDir}`,
86
92
  `[where] components:${componentsDir}`,
87
93
  '',
88
94
  `[where] stack: ${stackName} (${stackLabel})`,
95
+ `[where] env (canonical pointer): ${existsSync(canonicalEnv) ? canonicalEnv : `${canonicalEnv} (missing)`}`,
89
96
  `[where] env (home defaults): ${existsSync(homeEnv) ? homeEnv : `${homeEnv} (missing)`}`,
90
97
  `[where] env (home overrides): ${existsSync(homeLocal) ? homeLocal : `${homeLocal} (missing)`}`,
91
98
  `[where] env (active): ${resolvedActiveEnv?.envPath ? resolvedActiveEnv.envPath : '(none)'}`,
@@ -1,19 +1,27 @@
1
1
  import './utils/env.mjs';
2
2
  import { mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
- import { parseArgs } from './utils/args.mjs';
4
+ import { parseArgs } from './utils/cli/args.mjs';
5
5
  import { pathExists } from './utils/fs.mjs';
6
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';
7
+ import { componentDirEnvKey, getComponentDir, getComponentsDir, getHappyStacksHomeDir, getRootDir, getWorkspaceDir } from './utils/paths.mjs';
8
+ import { inferRemoteNameForOwner, parseGithubOwner } from './utils/worktrees.mjs';
9
+ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
10
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
11
11
  import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
12
12
  import { ensureEnvFileUpdated } from './utils/env_file.mjs';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/config.mjs';
15
15
  import { detectServerComponentDirMismatch } from './utils/validate.mjs';
16
16
 
17
+ function getActiveStackName() {
18
+ return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
19
+ }
20
+
21
+ function isMainStack() {
22
+ return getActiveStackName() === 'main';
23
+ }
24
+
17
25
  function getWorktreesRoot(rootDir) {
18
26
  return join(getComponentsDir(rootDir), '.worktrees');
19
27
  }
@@ -142,6 +150,7 @@ async function ensureWorktreeExclude(worktreeDir, patterns) {
142
150
  const want = patterns.map((p) => p.trim()).filter(Boolean).filter((p) => !existingLines.has(p));
143
151
  if (!want.length) return;
144
152
  const next = (existing ? existing.replace(/\s*$/, '') + '\n' : '') + want.join('\n') + '\n';
153
+ await mkdir(dirname(excludePath), { recursive: true });
145
154
  await writeFile(excludePath, next, 'utf-8');
146
155
  }
147
156
 
@@ -360,6 +369,21 @@ async function migrateComponentWorktrees({ rootDir, component }) {
360
369
  let renamed = 0;
361
370
 
362
371
  const componentsDir = getComponentsDir(rootDir);
372
+ // NOTE: getWorkspaceDir() is influenced by HAPPY_STACKS_WORKSPACE_DIR, which for this repo
373
+ // points at the current workspace. For migration we specifically want to consider the
374
+ // historical home workspace at: <home>/workspace/components
375
+ const legacyHomeWorkspaceComponentsDir = join(getHappyStacksHomeDir(), 'workspace', 'components');
376
+ const allowedComponentRoots = [componentsDir];
377
+ try {
378
+ if (
379
+ existsSync(legacyHomeWorkspaceComponentsDir) &&
380
+ resolve(legacyHomeWorkspaceComponentsDir) !== resolve(componentsDir)
381
+ ) {
382
+ allowedComponentRoots.push(legacyHomeWorkspaceComponentsDir);
383
+ }
384
+ } catch {
385
+ // ignore
386
+ }
363
387
 
364
388
  for (const wt of worktrees) {
365
389
  const wtPath = wt.path;
@@ -372,8 +396,14 @@ async function migrateComponentWorktrees({ rootDir, component }) {
372
396
  continue;
373
397
  }
374
398
 
375
- // Only migrate worktrees living under this happy-stacks components folder.
376
- if (!resolve(wtPath).startsWith(resolve(componentsDir) + '/')) {
399
+ // Only migrate worktrees living under either:
400
+ // - current workspace components folder, or
401
+ // - legacy home workspace components folder (~/.happy-stacks/workspace/components)
402
+ // This is necessary when users switch HAPPY_STACKS_WORKSPACE_DIR, otherwise git will keep
403
+ // worktrees "stuck" in the old workspace and branches can't be re-used in the new workspace.
404
+ const resolvedWt = resolve(wtPath);
405
+ const okRoot = allowedComponentRoots.some((d) => resolvedWt.startsWith(resolve(d) + '/'));
406
+ if (!okRoot) {
377
407
  continue;
378
408
  }
379
409
 
@@ -475,13 +505,33 @@ async function cmdMigrate({ rootDir }) {
475
505
  return { moved: totalMoved, branchesRenamed: totalRenamed };
476
506
  }
477
507
 
478
- async function cmdUse({ rootDir, args }) {
508
+ async function cmdUse({ rootDir, args, flags }) {
479
509
  const component = args[0];
480
510
  const spec = args[1];
481
511
  if (!component || !spec) {
482
512
  throw new Error('[wt] usage: happys wt use <component> <owner/branch|path|default>');
483
513
  }
484
514
 
515
+ // Safety: main stack should not be repointed to arbitrary worktrees by default.
516
+ // This is the most common “oops, the main stack now runs my PR checkout” footgun (especially for agents).
517
+ const force = Boolean(flags?.has('--force'));
518
+ if (!force && isMainStack() && spec !== 'default' && spec !== 'main') {
519
+ throw new Error(
520
+ `[wt] refusing to change main stack component override by default.\n` +
521
+ `- stack: main\n` +
522
+ `- component: ${component}\n` +
523
+ `- requested: ${spec}\n` +
524
+ `\n` +
525
+ `Recommendation:\n` +
526
+ `- Create a new isolated stack and switch that stack instead:\n` +
527
+ ` happys stack new exp1 --interactive\n` +
528
+ ` happys stack wt exp1 -- use ${component} ${spec}\n` +
529
+ `\n` +
530
+ `If you really intend to repoint the main stack, re-run with --force:\n` +
531
+ ` happys wt use ${component} ${spec} --force\n`
532
+ );
533
+ }
534
+
485
535
  const key = componentDirEnvKey(component);
486
536
  const worktreesRoot = getWorktreesRoot(rootDir);
487
537
  const envPath = process.env.HAPPY_STACKS_ENV_FILE?.trim()
@@ -577,10 +627,10 @@ async function cmdUseInteractive({ rootDir }) {
577
627
  options: specs.map((s) => ({ label: s, value: s })),
578
628
  defaultIndex: 0,
579
629
  });
580
- await cmdUse({ rootDir, args: [component, picked] });
630
+ await cmdUse({ rootDir, args: [component, picked], flags: new Set(['--force']) });
581
631
  return;
582
632
  }
583
- await cmdUse({ rootDir, args: [component, 'default'] });
633
+ await cmdUse({ rootDir, args: [component, 'default'], flags: new Set(['--force']) });
584
634
  });
585
635
  }
586
636
 
@@ -646,19 +696,79 @@ async function cmdNew({ rootDir, argv }) {
646
696
  await git(repoRoot, ['branch', '--set-upstream-to', `${remoteName}/${defaultBranch}`, mirrorBranch]).catch(() => {});
647
697
  }
648
698
 
649
- await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
699
+ // If the branch already exists (common when migrating between workspaces),
700
+ // attach a new worktree to that branch instead of failing.
701
+ if (await gitOk(repoRoot, ['show-ref', '--verify', `refs/heads/${branchName}`])) {
702
+ await git(repoRoot, ['worktree', 'add', destPath, branchName]);
703
+ } else {
704
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
705
+ }
650
706
 
651
707
  const depsMode = parseDepsMode(kv.get('--deps'));
652
708
  const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
653
709
 
654
710
  const shouldUse = flags.has('--use');
711
+ const force = flags.has('--force');
655
712
  if (shouldUse) {
713
+ if (isMainStack() && !force) {
714
+ throw new Error(
715
+ `[wt] refusing to set main stack component override via --use by default.\n` +
716
+ `- stack: main\n` +
717
+ `- component: ${component}\n` +
718
+ `- new worktree: ${destPath}\n` +
719
+ `\n` +
720
+ `Recommendation:\n` +
721
+ `- Use an isolated stack instead:\n` +
722
+ ` happys stack new exp1 --interactive\n` +
723
+ ` happys stack wt exp1 -- use ${component} ${owner}/${slug}\n` +
724
+ `\n` +
725
+ `If you really intend to repoint the main stack, re-run with --force:\n` +
726
+ ` happys wt new ${component} ${slug} --use --force\n`
727
+ );
728
+ }
656
729
  const key = componentDirEnvKey(component);
657
730
  await ensureEnvLocalUpdated({ rootDir, updates: [{ key, value: destPath }] });
658
731
  }
659
732
  return { component, branch: branchName, path: destPath, base, used: shouldUse, deps };
660
733
  }
661
734
 
735
+ async function cmdDuplicate({ rootDir, argv }) {
736
+ const { flags, kv } = parseArgs(argv);
737
+ const json = wantsJson(argv, { flags });
738
+
739
+ const positionals = argv.filter((a) => !a.startsWith('--'));
740
+ const component = positionals[1];
741
+ const fromSpec = positionals[2];
742
+ const slug = positionals[3];
743
+ if (!component || !fromSpec || !slug) {
744
+ throw new Error(
745
+ '[wt] usage: happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]'
746
+ );
747
+ }
748
+
749
+ // Prefer inferring the remote from the source spec's owner when possible (owner/<branch...>).
750
+ const remoteOverride = (kv.get('--remote') ?? '').trim();
751
+ let remoteName = remoteOverride;
752
+ if (!remoteName && !isAbsolute(fromSpec)) {
753
+ const owner = String(fromSpec).trim().split('/')[0];
754
+ if (owner && owner !== 'active' && owner !== 'default' && owner !== 'main') {
755
+ const repoRoot = getComponentRepoRoot(rootDir, component);
756
+ remoteName = await normalizeRemoteName(repoRoot, await inferRemoteNameForOwner({ repoDir: repoRoot, owner }));
757
+ }
758
+ }
759
+
760
+ const depsMode = (kv.get('--deps') ?? '').trim();
761
+ const forwarded = ['new', component, slug, `--base-worktree=${fromSpec}`];
762
+ if (remoteName) forwarded.push(`--remote=${remoteName}`);
763
+ if (depsMode) forwarded.push(`--deps=${depsMode}`);
764
+ if (flags.has('--use')) forwarded.push('--use');
765
+ if (flags.has('--force')) forwarded.push('--force');
766
+ if (json) forwarded.push('--json');
767
+
768
+ // Delegate to cmdNew for the actual implementation (single source of truth).
769
+ return await cmdNew({ rootDir, argv: forwarded });
770
+ }
771
+
662
772
  async function cmdPr({ rootDir, argv }) {
663
773
  const { flags, kv } = parseArgs(argv);
664
774
  const json = wantsJson(argv, { flags });
@@ -776,7 +886,7 @@ async function cmdPr({ rootDir, argv }) {
776
886
  const shouldUse = flags.has('--use');
777
887
  if (shouldUse) {
778
888
  // Reuse cmdUse so it writes to env.local or stack env file depending on context.
779
- await cmdUse({ rootDir, args: [component, destPath] });
889
+ await cmdUse({ rootDir, args: [component, destPath], flags });
780
890
  }
781
891
 
782
892
  const newHead = (await git(destPath, ['rev-parse', 'HEAD'])).trim();
@@ -1535,9 +1645,10 @@ async function main() {
1535
1645
  ' happys wt sync <component> [--remote=<name>] [--json]',
1536
1646
  ' happys wt sync-all [--remote=<name>] [--json]',
1537
1647
  ' 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]',
1648
+ ' 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]',
1649
+ ' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
1539
1650
  ' 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]',
1651
+ ' happys wt use <component> <owner/branch|path|default|main> [--force] [--interactive|-i] [--json]',
1541
1652
  ' happys wt status <component> [worktreeSpec|default|path] [--json]',
1542
1653
  ' happys wt update <component> [worktreeSpec|default|path] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
1543
1654
  ' happys wt update-all [component] [--remote=upstream] [--base=<ref>] [--rebase|--merge] [--dry-run] [--stash|--stash-keep] [--force] [--json]',
@@ -1569,7 +1680,7 @@ async function main() {
1569
1680
  if (interactive && isTty()) {
1570
1681
  await cmdUseInteractive({ rootDir });
1571
1682
  } else {
1572
- const res = await cmdUse({ rootDir, args: positionals.slice(1) });
1683
+ const res = await cmdUse({ rootDir, args: positionals.slice(1), flags });
1573
1684
  printResult({ json, data: res, text: `[wt] ${res.component}: active dir -> ${res.activeDir}` });
1574
1685
  }
1575
1686
  return;
@@ -1587,6 +1698,15 @@ async function main() {
1587
1698
  }
1588
1699
  return;
1589
1700
  }
1701
+ if (cmd === 'duplicate') {
1702
+ const res = await cmdDuplicate({ rootDir, argv });
1703
+ printResult({
1704
+ json,
1705
+ data: res,
1706
+ text: `[wt] duplicated ${res.component} worktree: ${res.path} (${res.branch} based on ${res.base})`,
1707
+ });
1708
+ return;
1709
+ }
1590
1710
  if (cmd === 'pr') {
1591
1711
  const res = await cmdPr({ rootDir, argv });
1592
1712
  printResult({
File without changes
File without changes