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.
- package/README.md +164 -89
- package/bin/happys.mjs +70 -10
- package/docs/edison.md +381 -0
- package/docs/happy-development.md +733 -0
- package/docs/menubar.md +54 -0
- package/docs/paths-and-env.md +141 -0
- package/docs/stacks.md +39 -0
- package/extras/swiftbar/auth-login.sh +5 -2
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +131 -81
- package/extras/swiftbar/happys-term.sh +15 -38
- package/extras/swiftbar/happys.sh +15 -32
- package/extras/swiftbar/install.sh +99 -13
- package/extras/swiftbar/lib/git.sh +309 -1
- package/extras/swiftbar/lib/icons.sh +2 -2
- package/extras/swiftbar/lib/render.sh +209 -80
- package/extras/swiftbar/lib/system.sh +27 -4
- package/extras/swiftbar/lib/utils.sh +311 -28
- package/extras/swiftbar/pnpm.sh +2 -1
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +11 -2
- package/extras/swiftbar/wt-pr.sh +9 -2
- package/package.json +2 -1
- package/scripts/auth.mjs +521 -226
- package/scripts/build.mjs +29 -10
- package/scripts/cli-link.mjs +6 -6
- package/scripts/completion.mjs +18 -11
- package/scripts/daemon.mjs +133 -31
- package/scripts/dev.mjs +196 -137
- package/scripts/doctor.mjs +44 -55
- package/scripts/edison.mjs +1853 -0
- package/scripts/happy.mjs +10 -25
- package/scripts/init.mjs +46 -31
- package/scripts/install.mjs +21 -15
- package/scripts/lint.mjs +124 -0
- package/scripts/menubar.mjs +76 -10
- package/scripts/migrate.mjs +35 -35
- package/scripts/mobile.mjs +24 -17
- package/scripts/run.mjs +122 -35
- package/scripts/self.mjs +13 -35
- package/scripts/server_flavor.mjs +7 -7
- package/scripts/service.mjs +31 -28
- package/scripts/setup.mjs +694 -0
- package/scripts/setup_pr.mjs +165 -0
- package/scripts/stack.mjs +1851 -363
- package/scripts/stop.mjs +9 -6
- package/scripts/tailscale.mjs +23 -11
- package/scripts/test.mjs +123 -0
- package/scripts/tui.mjs +526 -0
- package/scripts/typecheck.mjs +10 -31
- package/scripts/ui_gateway.mjs +3 -3
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/auth/files.mjs +56 -0
- package/scripts/utils/auth/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/login_ux.mjs +76 -0
- package/scripts/utils/auth/sources.mjs +12 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +2 -2
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/dev/daemon.mjs +104 -0
- package/scripts/utils/dev/expo_web.mjs +112 -0
- package/scripts/utils/dev/server.mjs +183 -0
- package/scripts/utils/{config.mjs → env/config.mjs} +8 -3
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +64 -13
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +38 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/sandbox.mjs +14 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +7 -11
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +60 -4
- package/scripts/utils/net/dns.mjs +10 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +3 -5
- package/scripts/utils/paths/canonical_home.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +9 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +14 -8
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +4 -4
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/proc/ownership.mjs +135 -0
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/proc/pm.mjs +317 -0
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +30 -2
- package/scripts/utils/proc/watch.mjs +63 -0
- package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +109 -94
- package/scripts/utils/server/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +36 -0
- package/scripts/utils/server/urls.mjs +91 -0
- package/scripts/utils/{validate.mjs → server/validate.mjs} +1 -1
- package/scripts/utils/service/autostart_darwin.mjs +142 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/runtime_state.mjs +87 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/stack/startup.mjs +208 -0
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +85 -42
- package/scripts/utils/ui/browser.mjs +22 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/where.mjs +17 -10
- package/scripts/worktrees.mjs +110 -64
- package/scripts/utils/pm.mjs +0 -303
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /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 '
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
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}
|
|
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
|
|
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 =
|
|
145
|
-
const backendPort =
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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)'}`,
|
package/scripts/worktrees.mjs
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
|
385
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1401
|
+
const cursorPath = await resolveCommandPath('cursor', { cwd: rootDir, env: process.env });
|
|
1402
|
+
const hasCursorCli = Boolean(cursorPath);
|
|
1372
1403
|
if (json) {
|
|
1373
|
-
return {
|
|
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(
|
|
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({
|