happy-stacks 0.1.2 → 0.3.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 (116) hide show
  1. package/README.md +164 -89
  2. package/bin/happys.mjs +70 -10
  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/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +521 -226
  25. package/scripts/build.mjs +29 -10
  26. package/scripts/cli-link.mjs +6 -6
  27. package/scripts/completion.mjs +18 -11
  28. package/scripts/daemon.mjs +133 -31
  29. package/scripts/dev.mjs +196 -137
  30. package/scripts/doctor.mjs +44 -55
  31. package/scripts/edison.mjs +1853 -0
  32. package/scripts/happy.mjs +10 -25
  33. package/scripts/init.mjs +46 -31
  34. package/scripts/install.mjs +21 -15
  35. package/scripts/lint.mjs +124 -0
  36. package/scripts/menubar.mjs +76 -10
  37. package/scripts/migrate.mjs +35 -35
  38. package/scripts/mobile.mjs +24 -17
  39. package/scripts/run.mjs +122 -35
  40. package/scripts/self.mjs +13 -35
  41. package/scripts/server_flavor.mjs +7 -7
  42. package/scripts/service.mjs +31 -28
  43. package/scripts/setup.mjs +694 -0
  44. package/scripts/setup_pr.mjs +165 -0
  45. package/scripts/stack.mjs +1851 -363
  46. package/scripts/stop.mjs +9 -6
  47. package/scripts/tailscale.mjs +23 -11
  48. package/scripts/test.mjs +123 -0
  49. package/scripts/tui.mjs +526 -0
  50. package/scripts/typecheck.mjs +10 -31
  51. package/scripts/ui_gateway.mjs +3 -3
  52. package/scripts/uninstall.mjs +21 -13
  53. package/scripts/utils/auth/dev_key.mjs +163 -0
  54. package/scripts/utils/auth/files.mjs +56 -0
  55. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  56. package/scripts/utils/auth/login_ux.mjs +76 -0
  57. package/scripts/utils/auth/sources.mjs +12 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/cli/flags.mjs +17 -0
  60. package/scripts/utils/cli/normalize.mjs +16 -0
  61. package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
  62. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  63. package/scripts/utils/crypto/tokens.mjs +14 -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/{config.mjs → env/config.mjs} +8 -3
  68. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  69. package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
  70. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
  71. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  72. package/scripts/utils/env/read.mjs +30 -0
  73. package/scripts/utils/env/sandbox.mjs +14 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
  76. package/scripts/utils/fs/json.mjs +25 -0
  77. package/scripts/utils/fs/ops.mjs +29 -0
  78. package/scripts/utils/fs/package_json.mjs +8 -0
  79. package/scripts/utils/fs/tail.mjs +12 -0
  80. package/scripts/utils/git/refs.mjs +26 -0
  81. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
  82. package/scripts/utils/net/dns.mjs +10 -0
  83. package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
  84. package/scripts/utils/paths/canonical_home.mjs +20 -0
  85. package/scripts/utils/paths/localhost_host.mjs +9 -0
  86. package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
  87. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
  88. package/scripts/utils/proc/commands.mjs +34 -0
  89. package/scripts/utils/proc/ownership.mjs +135 -0
  90. package/scripts/utils/proc/package_scripts.mjs +31 -0
  91. package/scripts/utils/proc/pids.mjs +11 -0
  92. package/scripts/utils/proc/pm.mjs +317 -0
  93. package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
  94. package/scripts/utils/proc/watch.mjs +63 -0
  95. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
  96. package/scripts/utils/server/port.mjs +68 -0
  97. package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
  98. package/scripts/utils/server/urls.mjs +91 -0
  99. package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
  100. package/scripts/utils/service/autostart_darwin.mjs +142 -0
  101. package/scripts/utils/stack/context.mjs +23 -0
  102. package/scripts/utils/stack/dirs.mjs +27 -0
  103. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  104. package/scripts/utils/stack/names.mjs +12 -0
  105. package/scripts/utils/stack/runtime_state.mjs +87 -0
  106. package/scripts/utils/stack/stacks.mjs +45 -0
  107. package/scripts/utils/stack/startup.mjs +208 -0
  108. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
  109. package/scripts/utils/ui/browser.mjs +22 -0
  110. package/scripts/utils/ui/text.mjs +16 -0
  111. package/scripts/where.mjs +17 -10
  112. package/scripts/worktrees.mjs +110 -64
  113. package/scripts/utils/pm.mjs +0 -303
  114. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  115. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  116. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
@@ -2,18 +2,13 @@ import { existsSync } from 'node:fs';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
5
- import { getComponentDir } from './paths.mjs';
6
- import { killPortListeners } from './ports.mjs';
7
- import { isPidAlive, killPid, readPidState } from './expo.mjs';
8
- import { stopLocalDaemon } from '../daemon.mjs';
9
- import { stopHappyServerManagedInfra } from './happy_server_infra.mjs';
10
-
11
- function parseIntOrNull(raw) {
12
- const s = String(raw ?? '').trim();
13
- if (!s) return null;
14
- const n = Number(s);
15
- return Number.isFinite(n) && n > 0 ? n : null;
16
- }
5
+ import { getComponentDir } from '../paths/paths.mjs';
6
+ import { isPidAlive, readPidState } from '../expo/expo.mjs';
7
+ import { stopLocalDaemon } from '../../daemon.mjs';
8
+ import { stopHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
9
+ import { deleteStackRuntimeStateFile, getStackRuntimeStatePath, readStackRuntimeStateFile } from './runtime_state.mjs';
10
+ import { killPidOwnedByStack, killProcessGroupOwnedByStack, listPidsWithEnvNeedle } from '../proc/ownership.mjs';
11
+ import { coercePort } from '../server/port.mjs';
17
12
 
18
13
  function resolveServerComponentFromStackEnv(env) {
19
14
  const v =
@@ -89,7 +84,7 @@ async function stopDaemonTrackedSessions({ cliHomeDir, json }) {
89
84
  return { ok: true, skipped: false, stoppedSessionIds };
90
85
  }
91
86
 
92
- async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, json }) {
87
+ async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, envPath, json }) {
93
88
  const root = join(baseDir, kind);
94
89
  let entries = [];
95
90
  try {
@@ -106,31 +101,28 @@ async function stopExpoStateDir({ stackName, baseDir, kind, stateFileName, json
106
101
  const state = await readPidState(statePath);
107
102
  if (!state) continue;
108
103
  const pid = Number(state.pid);
109
- const port = parseIntOrNull(state.port);
110
104
 
111
105
  if (!Number.isFinite(pid) || pid <= 1) continue;
112
106
  if (!isPidAlive(pid)) continue;
113
107
 
114
108
  if (!json) {
115
109
  // eslint-disable-next-line no-console
116
- console.log(`[stack] stopping ${kind} (pid=${pid}${port ? ` port=${port}` : ''}) for ${stackName}`);
117
- }
118
- if (port) {
119
- // eslint-disable-next-line no-await-in-loop
120
- await killPortListeners(port, { label: `${stackName} ${kind}` });
110
+ console.log(`[stack] stopping ${kind} (pid=${pid}) for ${stackName}`);
121
111
  }
122
112
  // eslint-disable-next-line no-await-in-loop
123
- await killPid(pid);
124
- killed.push({ pid, port, statePath });
113
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: kind, json });
114
+ killed.push({ pid, port: null, statePath });
125
115
  }
126
116
  return killed;
127
117
  }
128
118
 
129
- export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker = false, aggressive = false }) {
119
+ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json, noDocker = false, aggressive = false, sweepOwned = false }) {
130
120
  const actions = {
131
121
  stackName,
132
122
  baseDir,
133
123
  aggressive,
124
+ sweepOwned,
125
+ runner: null,
134
126
  daemonSessionsStopped: null,
135
127
  daemonStopped: false,
136
128
  killedPorts: [],
@@ -141,10 +133,34 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
141
133
  };
142
134
 
143
135
  const serverComponent = resolveServerComponentFromStackEnv(env);
144
- const port = parseIntOrNull(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
145
- const backendPort = parseIntOrNull(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
136
+ const port = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
137
+ const backendPort = coercePort(env.HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT ?? env.HAPPY_LOCAL_HAPPY_SERVER_BACKEND_PORT);
146
138
  const cliHomeDir = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? join(baseDir, 'cli')).toString();
147
139
  const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
140
+ const envPath = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
141
+
142
+ // Preferred: stop stack-started processes (by PID) recorded in stack.runtime.json.
143
+ // This is safer than killing whatever happens to listen on a port, and doesn't rely on the runner's shutdown handler.
144
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
145
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
146
+ const runnerPid = Number(runtimeState?.ownerPid);
147
+ const processes = runtimeState?.processes && typeof runtimeState.processes === 'object' ? runtimeState.processes : {};
148
+
149
+ // Kill known child processes first (process groups), then stop daemon, then stop runner.
150
+ const killedProcessPids = [];
151
+ for (const [key, rawPid] of Object.entries(processes)) {
152
+ const pid = Number(rawPid);
153
+ if (!Number.isFinite(pid) || pid <= 1) continue;
154
+ if (!isPidAlive(pid)) continue;
155
+ // eslint-disable-next-line no-await-in-loop
156
+ const res = await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: key, json });
157
+ if (res.killed) {
158
+ killedProcessPids.push({ key, pid, reason: res.reason, pgid: res.pgid ?? null });
159
+ }
160
+ }
161
+ actions.runner = { stopped: false, pid: Number.isFinite(runnerPid) ? runnerPid : null, reason: runtimeState ? 'not_running_or_not_owned' : 'missing_state' };
162
+ actions.killedPorts = actions.killedPorts ?? [];
163
+ actions.processes = { killed: killedProcessPids };
148
164
 
149
165
  if (aggressive) {
150
166
  try {
@@ -162,33 +178,36 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
162
178
  actions.errors.push({ step: 'daemon', error: e instanceof Error ? e.message : String(e) });
163
179
  }
164
180
 
181
+ // Now stop the runner PID last (if it exists). This should clean up any remaining state files it owns.
182
+ if (Number.isFinite(runnerPid) && runnerPid > 1 && isPidAlive(runnerPid)) {
183
+ if (!json) {
184
+ // eslint-disable-next-line no-console
185
+ console.log(`[stack] stopping runner (pid=${runnerPid}) for ${stackName}`);
186
+ }
187
+ const res = await killPidOwnedByStack(runnerPid, { stackName, envPath, cliHomeDir, label: 'runner', json });
188
+ actions.runner = { stopped: res.killed, pid: runnerPid, reason: res.reason };
189
+ }
190
+
191
+ // Only delete runtime state if the runner is confirmed stopped (or not running).
192
+ if (!isPidAlive(runnerPid)) {
193
+ await deleteStackRuntimeStateFile(runtimeStatePath);
194
+ }
195
+
165
196
  try {
166
- actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', json });
197
+ actions.uiDev = await stopExpoStateDir({ stackName, baseDir, kind: 'ui-dev', stateFileName: 'ui.state.json', envPath, json });
167
198
  } catch (e) {
168
199
  actions.errors.push({ step: 'expo-ui', error: e instanceof Error ? e.message : String(e) });
169
200
  }
170
201
  try {
171
- actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', json });
202
+ actions.mobile = await stopExpoStateDir({ stackName, baseDir, kind: 'mobile', stateFileName: 'expo.state.json', envPath, json });
172
203
  } catch (e) {
173
204
  actions.errors.push({ step: 'expo-mobile', error: e instanceof Error ? e.message : String(e) });
174
205
  }
175
206
 
176
- if (backendPort) {
177
- try {
178
- const pids = await killPortListeners(backendPort, { label: `${stackName} happy-server-backend` });
179
- actions.killedPorts.push({ port: backendPort, pids, label: 'happy-server-backend' });
180
- } catch (e) {
181
- actions.errors.push({ step: 'backend-port', error: e instanceof Error ? e.message : String(e) });
182
- }
183
- }
184
- if (port) {
185
- try {
186
- const pids = await killPortListeners(port, { label: `${stackName} server` });
187
- actions.killedPorts.push({ port, pids, label: 'server' });
188
- } catch (e) {
189
- actions.errors.push({ step: 'server-port', error: e instanceof Error ? e.message : String(e) });
190
- }
191
- }
207
+ // IMPORTANT:
208
+ // Never kill "whatever is listening on a port" in stack mode.
209
+ void backendPort;
210
+ void port;
192
211
 
193
212
  const managed = (env.HAPPY_STACKS_MANAGED_INFRA ?? env.HAPPY_LOCAL_MANAGED_INFRA ?? '1').toString().trim() !== '0';
194
213
  if (!noDocker && serverComponent === 'happy-server' && managed) {
@@ -201,6 +220,30 @@ export async function stopStackWithEnv({ rootDir, stackName, baseDir, env, json,
201
220
  actions.infra = { ok: true, skipped: true, reason: noDocker ? 'no_docker' : 'not_managed_or_not_happy_server' };
202
221
  }
203
222
 
223
+ // Last resort: sweep any remaining processes that still carry this stack env file in their environment.
224
+ // This is still safe because envPath is unique per stack; we also exclude our own PID.
225
+ if (sweepOwned && envPath) {
226
+ const needle1 = `HAPPY_STACKS_ENV_FILE=${envPath}`;
227
+ const needle2 = `HAPPY_LOCAL_ENV_FILE=${envPath}`;
228
+ const pids = [
229
+ ...(await listPidsWithEnvNeedle(needle1)),
230
+ ...(await listPidsWithEnvNeedle(needle2)),
231
+ ]
232
+ .filter((pid) => pid !== process.pid)
233
+ .filter((pid) => Number.isFinite(pid) && pid > 1);
234
+
235
+ const swept = [];
236
+ for (const pid of Array.from(new Set(pids))) {
237
+ if (!isPidAlive(pid)) continue;
238
+ // eslint-disable-next-line no-await-in-loop
239
+ const res = await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: 'sweep', json });
240
+ if (res.killed) {
241
+ swept.push({ pid, reason: res.reason, pgid: res.pgid ?? null });
242
+ }
243
+ }
244
+ actions.sweep = { pids: swept };
245
+ }
246
+
204
247
  return actions;
205
248
  }
206
249
 
@@ -0,0 +1,22 @@
1
+ import { runCapture } from '../proc/proc.mjs';
2
+
3
+ export async function openUrlInBrowser(url, { timeoutMs = 5_000 } = {}) {
4
+ const u = String(url ?? '').trim();
5
+ if (!u) return { ok: false, error: 'missing_url' };
6
+
7
+ try {
8
+ if (process.platform === 'darwin') {
9
+ await runCapture('open', [u], { timeoutMs });
10
+ return { ok: true, method: 'open' };
11
+ }
12
+ if (process.platform === 'win32') {
13
+ // `start` is a cmd built-in; the empty title ("") is required so URLs with :// don't get treated as a title.
14
+ await runCapture('cmd', ['/c', 'start', '""', u], { timeoutMs });
15
+ return { ok: true, method: 'cmd-start' };
16
+ }
17
+ await runCapture('xdg-open', [u], { timeoutMs });
18
+ return { ok: true, method: 'xdg-open' };
19
+ } catch (e) {
20
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
21
+ }
22
+ }
@@ -0,0 +1,16 @@
1
+ export function stripAnsi(s) {
2
+ // eslint-disable-next-line no-control-regex
3
+ return String(s ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
4
+ }
5
+
6
+ export function padRight(s, n) {
7
+ const str = String(s ?? '');
8
+ if (str.length >= n) return str.slice(0, n);
9
+ return str + ' '.repeat(n - str.length);
10
+ }
11
+
12
+ export function parsePrefixedLabel(line) {
13
+ const m = String(line ?? '').match(/^\[([^\]]+)\]\s*/);
14
+ return m ? m[1] : null;
15
+ }
16
+
package/scripts/where.mjs CHANGED
@@ -1,17 +1,15 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/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';
8
- import { getComponentsDir, getComponentDir, getHappyStacksHomeDir, getRootDir, getStackLabel, getStackName, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
9
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
10
- import { getRuntimeDir } from './utils/runtime.mjs';
11
-
12
- function expandHome(p) {
13
- return p.replace(/^~(?=\/)/, homedir());
14
- }
6
+ import { parseArgs } from './utils/cli/args.mjs';
7
+ import { expandHome } from './utils/paths/canonical_home.mjs';
8
+ import { getComponentsDir, getComponentDir, getHappyStacksHomeDir, getRootDir, getStackLabel, getStackName, getWorkspaceDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { getRuntimeDir } from './utils/paths/runtime.mjs';
11
+ import { getCanonicalHomeDir, getCanonicalHomeEnvPath } from './utils/env/config.mjs';
12
+ import { getSandboxDir } from './utils/env/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,18 +1,22 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/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';
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';
4
+ import { parseArgs } from './utils/cli/args.mjs';
5
+ import { pathExists } from './utils/fs/fs.mjs';
6
+ import { run, runCapture } from './utils/proc/proc.mjs';
7
+ import { commandExists, resolveCommandPath } from './utils/proc/commands.mjs';
8
+ import { componentDirEnvKey, getComponentDir, getComponentsDir, getHappyStacksHomeDir, getRootDir, getWorkspaceDir } from './utils/paths/paths.mjs';
9
+ import { inferRemoteNameForOwner, parseGithubOwner } from './utils/git/worktrees.mjs';
10
+ import { getWorktreesRoot } from './utils/git/worktrees.mjs';
11
+ import { parseGithubPullRequest, sanitizeSlugPart } from './utils/git/refs.mjs';
12
+ import { readTextIfExists } from './utils/fs/ops.mjs';
13
+ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
14
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
+ import { ensureEnvLocalUpdated } from './utils/env/env_local.mjs';
16
+ import { ensureEnvFileUpdated } from './utils/env/env_file.mjs';
13
17
  import { existsSync } from 'node:fs';
14
- import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/config.mjs';
15
- import { detectServerComponentDirMismatch } from './utils/validate.mjs';
18
+ import { getHomeEnvLocalPath, getHomeEnvPath, resolveUserConfigEnvPath } from './utils/env/config.mjs';
19
+ import { detectServerComponentDirMismatch } from './utils/server/validate.mjs';
16
20
 
17
21
  function getActiveStackName() {
18
22
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
@@ -22,10 +26,6 @@ function isMainStack() {
22
26
  return getActiveStackName() === 'main';
23
27
  }
24
28
 
25
- function getWorktreesRoot(rootDir) {
26
- return join(getComponentsDir(rootDir), '.worktrees');
27
- }
28
-
29
29
  function resolveComponentWorktreeDir({ rootDir, component, spec }) {
30
30
  const worktreesRoot = getWorktreesRoot(rootDir);
31
31
  const raw = (spec ?? '').trim();
@@ -51,32 +51,6 @@ function resolveComponentWorktreeDir({ rootDir, component, spec }) {
51
51
  return join(worktreesRoot, component, ...raw.split('/'));
52
52
  }
53
53
 
54
- function parseGithubPullRequest(input) {
55
- const raw = (input ?? '').trim();
56
- if (!raw) return null;
57
- if (/^\d+$/.test(raw)) {
58
- return { number: Number(raw), owner: null, repo: null };
59
- }
60
- // https://github.com/<owner>/<repo>/pull/<num>
61
- const m = raw.match(/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+)\/pull\/(?<num>\d+)/);
62
- if (!m?.groups?.num) return null;
63
- return {
64
- number: Number(m.groups.num),
65
- owner: m.groups.owner ?? null,
66
- repo: m.groups.repo ?? null,
67
- };
68
- }
69
-
70
- function sanitizeSlugPart(s) {
71
- return (s ?? '')
72
- .toString()
73
- .trim()
74
- .toLowerCase()
75
- .replace(/[^a-z0-9._/-]+/g, '-')
76
- .replace(/-+/g, '-')
77
- .replace(/^-+|-+$/g, '');
78
- }
79
-
80
54
  async function isWorktreeClean(dir) {
81
55
  const dirty = (await git(dir, ['status', '--porcelain'])).trim();
82
56
  return !dirty;
@@ -369,6 +343,21 @@ async function migrateComponentWorktrees({ rootDir, component }) {
369
343
  let renamed = 0;
370
344
 
371
345
  const componentsDir = getComponentsDir(rootDir);
346
+ // NOTE: getWorkspaceDir() is influenced by HAPPY_STACKS_WORKSPACE_DIR, which for this repo
347
+ // points at the current workspace. For migration we specifically want to consider the
348
+ // historical home workspace at: <home>/workspace/components
349
+ const legacyHomeWorkspaceComponentsDir = join(getHappyStacksHomeDir(), 'workspace', 'components');
350
+ const allowedComponentRoots = [componentsDir];
351
+ try {
352
+ if (
353
+ existsSync(legacyHomeWorkspaceComponentsDir) &&
354
+ resolve(legacyHomeWorkspaceComponentsDir) !== resolve(componentsDir)
355
+ ) {
356
+ allowedComponentRoots.push(legacyHomeWorkspaceComponentsDir);
357
+ }
358
+ } catch {
359
+ // ignore
360
+ }
372
361
 
373
362
  for (const wt of worktrees) {
374
363
  const wtPath = wt.path;
@@ -381,8 +370,14 @@ async function migrateComponentWorktrees({ rootDir, component }) {
381
370
  continue;
382
371
  }
383
372
 
384
- // Only migrate worktrees living under this happy-stacks components folder.
385
- if (!resolve(wtPath).startsWith(resolve(componentsDir) + '/')) {
373
+ // Only migrate worktrees living under either:
374
+ // - current workspace components folder, or
375
+ // - legacy home workspace components folder (~/.happy-stacks/workspace/components)
376
+ // This is necessary when users switch HAPPY_STACKS_WORKSPACE_DIR, otherwise git will keep
377
+ // worktrees "stuck" in the old workspace and branches can't be re-used in the new workspace.
378
+ const resolvedWt = resolve(wtPath);
379
+ const okRoot = allowedComponentRoots.some((d) => resolvedWt.startsWith(resolve(d) + '/'));
380
+ if (!okRoot) {
386
381
  continue;
387
382
  }
388
383
 
@@ -448,13 +443,13 @@ async function cmdMigrate({ rootDir }) {
448
443
  // If the persisted config pins any component dir to a legacy location, attempt to rewrite it.
449
444
  const envUpdates = [];
450
445
 
451
- // Keep in sync with scripts/utils/env_local.mjs selection logic.
446
+ // Keep in sync with scripts/utils/env/env_local.mjs selection logic.
452
447
  const explicitEnv = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').trim();
453
448
  const hasHomeConfig = existsSync(getHomeEnvPath()) || existsSync(getHomeEnvLocalPath());
454
449
  const envPath = explicitEnv ? explicitEnv : hasHomeConfig ? resolveUserConfigEnvPath({ cliRootDir: rootDir }) : join(rootDir, 'env.local');
455
450
 
456
451
  if (await pathExists(envPath)) {
457
- const raw = await readFile(envPath, 'utf-8');
452
+ const raw = (await readTextIfExists(envPath)) ?? '';
458
453
  const rewrite = (v) => {
459
454
  if (!v.includes('/components/')) {
460
455
  return v;
@@ -675,7 +670,13 @@ async function cmdNew({ rootDir, argv }) {
675
670
  await git(repoRoot, ['branch', '--set-upstream-to', `${remoteName}/${defaultBranch}`, mirrorBranch]).catch(() => {});
676
671
  }
677
672
 
678
- await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
673
+ // If the branch already exists (common when migrating between workspaces),
674
+ // attach a new worktree to that branch instead of failing.
675
+ if (await gitOk(repoRoot, ['show-ref', '--verify', `refs/heads/${branchName}`])) {
676
+ await git(repoRoot, ['worktree', 'add', destPath, branchName]);
677
+ } else {
678
+ await git(repoRoot, ['worktree', 'add', '-b', branchName, destPath, base]);
679
+ }
679
680
 
680
681
  const depsMode = parseDepsMode(kv.get('--deps'));
681
682
  const deps = await maybeSetupDeps({ repoRoot, baseDir: baseWorktreeDir || '', worktreeDir: destPath, depsMode });
@@ -705,6 +706,43 @@ async function cmdNew({ rootDir, argv }) {
705
706
  return { component, branch: branchName, path: destPath, base, used: shouldUse, deps };
706
707
  }
707
708
 
709
+ async function cmdDuplicate({ rootDir, argv }) {
710
+ const { flags, kv } = parseArgs(argv);
711
+ const json = wantsJson(argv, { flags });
712
+
713
+ const positionals = argv.filter((a) => !a.startsWith('--'));
714
+ const component = positionals[1];
715
+ const fromSpec = positionals[2];
716
+ const slug = positionals[3];
717
+ if (!component || !fromSpec || !slug) {
718
+ throw new Error(
719
+ '[wt] usage: happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]'
720
+ );
721
+ }
722
+
723
+ // Prefer inferring the remote from the source spec's owner when possible (owner/<branch...>).
724
+ const remoteOverride = (kv.get('--remote') ?? '').trim();
725
+ let remoteName = remoteOverride;
726
+ if (!remoteName && !isAbsolute(fromSpec)) {
727
+ const owner = String(fromSpec).trim().split('/')[0];
728
+ if (owner && owner !== 'active' && owner !== 'default' && owner !== 'main') {
729
+ const repoRoot = getComponentRepoRoot(rootDir, component);
730
+ remoteName = await normalizeRemoteName(repoRoot, await inferRemoteNameForOwner({ repoDir: repoRoot, owner }));
731
+ }
732
+ }
733
+
734
+ const depsMode = (kv.get('--deps') ?? '').trim();
735
+ const forwarded = ['new', component, slug, `--base-worktree=${fromSpec}`];
736
+ if (remoteName) forwarded.push(`--remote=${remoteName}`);
737
+ if (depsMode) forwarded.push(`--deps=${depsMode}`);
738
+ if (flags.has('--use')) forwarded.push('--use');
739
+ if (flags.has('--force')) forwarded.push('--force');
740
+ if (json) forwarded.push('--json');
741
+
742
+ // Delegate to cmdNew for the actual implementation (single source of truth).
743
+ return await cmdNew({ rootDir, argv: forwarded });
744
+ }
745
+
708
746
  async function cmdPr({ rootDir, argv }) {
709
747
  const { flags, kv } = parseArgs(argv);
710
748
  const json = wantsJson(argv, { flags });
@@ -1181,15 +1219,6 @@ async function cmdSync({ rootDir, argv }) {
1181
1219
  return { component, remote: remoteName, mirrorBranch, upstreamRef: `${remoteName}/${defaultBranch}` };
1182
1220
  }
1183
1221
 
1184
- async function commandExists(cmd) {
1185
- try {
1186
- const out = (await runCapture('sh', ['-lc', `command -v ${cmd} >/dev/null 2>&1 && echo yes || echo no`])).trim();
1187
- return out === 'yes';
1188
- } catch {
1189
- return false;
1190
- }
1191
- }
1192
-
1193
1222
  async function fileExists(path) {
1194
1223
  try {
1195
1224
  return await pathExists(path);
@@ -1344,14 +1373,15 @@ async function cmdCode({ rootDir, argv }) {
1344
1373
  if (!(await pathExists(dir))) {
1345
1374
  throw new Error(`[wt] target does not exist: ${dir}`);
1346
1375
  }
1347
- if (!(await commandExists('code'))) {
1376
+ const codePath = await resolveCommandPath('code', { cwd: rootDir, env: process.env });
1377
+ if (!codePath) {
1348
1378
  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'.");
1349
1379
  }
1350
1380
  if (json) {
1351
- return { component, dir, cmd: 'code' };
1381
+ return { component, dir, cmd: 'code', resolvedCmd: codePath };
1352
1382
  }
1353
- await run('code', [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1354
- return { component, dir, cmd: 'code' };
1383
+ await run(codePath, [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1384
+ return { component, dir, cmd: 'code', resolvedCmd: codePath };
1355
1385
  }
1356
1386
 
1357
1387
  async function cmdCursor({ rootDir, argv }) {
@@ -1368,14 +1398,20 @@ async function cmdCursor({ rootDir, argv }) {
1368
1398
  throw new Error(`[wt] target does not exist: ${dir}`);
1369
1399
  }
1370
1400
 
1371
- const hasCursorCli = await commandExists('cursor');
1401
+ const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
1402
+ const hasCursorCli = Boolean(cursorPath);
1372
1403
  if (json) {
1373
- return { component, dir, cmd: hasCursorCli ? 'cursor' : process.platform === 'darwin' ? 'open -a Cursor' : null };
1404
+ return {
1405
+ component,
1406
+ dir,
1407
+ cmd: hasCursorCli ? 'cursor' : process.platform === 'darwin' ? 'open -a Cursor' : null,
1408
+ resolvedCmd: cursorPath || null,
1409
+ };
1374
1410
  }
1375
1411
 
1376
1412
  if (hasCursorCli) {
1377
- await run('cursor', [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1378
- return { component, dir, cmd: 'cursor' };
1413
+ await run(cursorPath, [dir], { cwd: rootDir, env: process.env, stdio: 'inherit' });
1414
+ return { component, dir, cmd: 'cursor', resolvedCmd: cursorPath };
1379
1415
  }
1380
1416
 
1381
1417
  if (process.platform === 'darwin') {
@@ -1582,6 +1618,7 @@ async function main() {
1582
1618
  ' happys wt sync-all [--remote=<name>] [--json]',
1583
1619
  ' happys wt list <component> [--json]',
1584
1620
  ' happys wt new <component> <slug> [--from=upstream|origin] [--remote=<name>] [--base=<ref>|--base-worktree=<spec>] [--deps=none|link|install|link-or-install] [--use] [--force] [--interactive|-i] [--json]',
1621
+ ' happys wt duplicate <component> <fromWorktreeSpec|path|active|default> <newSlug> [--remote=<name>] [--deps=none|link|install|link-or-install] [--use] [--json]',
1585
1622
  ' 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]',
1586
1623
  ' happys wt use <component> <owner/branch|path|default|main> [--force] [--interactive|-i] [--json]',
1587
1624
  ' happys wt status <component> [worktreeSpec|default|path] [--json]',
@@ -1633,6 +1670,15 @@ async function main() {
1633
1670
  }
1634
1671
  return;
1635
1672
  }
1673
+ if (cmd === 'duplicate') {
1674
+ const res = await cmdDuplicate({ rootDir, argv });
1675
+ printResult({
1676
+ json,
1677
+ data: res,
1678
+ text: `[wt] duplicated ${res.component} worktree: ${res.path} (${res.branch} based on ${res.base})`,
1679
+ });
1680
+ return;
1681
+ }
1636
1682
  if (cmd === 'pr') {
1637
1683
  const res = await cmdPr({ rootDir, argv });
1638
1684
  printResult({