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.
- package/README.md +130 -74
- package/bin/happys.mjs +140 -9
- 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/server-flavors.md +61 -2
- package/docs/stacks.md +55 -4
- package/extras/swiftbar/auth-login.sh +10 -7
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +175 -83
- package/extras/swiftbar/happys-term.sh +128 -0
- package/extras/swiftbar/happys.sh +35 -0
- 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 +279 -132
- package/extras/swiftbar/lib/system.sh +64 -10
- package/extras/swiftbar/lib/utils.sh +469 -10
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +4 -14
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +19 -10
- package/extras/swiftbar/wt-pr.sh +10 -3
- package/package.json +2 -1
- package/scripts/auth.mjs +833 -14
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +200 -23
- package/scripts/dev.mjs +230 -57
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +275 -46
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +302 -0
- package/scripts/mobile.mjs +59 -21
- package/scripts/run.mjs +222 -43
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +190 -38
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +2273 -92
- package/scripts/stop.mjs +160 -0
- package/scripts/tailscale.mjs +164 -23
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +13 -1
- package/scripts/utils/dev_auth_key.mjs +169 -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/env.mjs +94 -23
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +96 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +484 -0
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +132 -22
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +75 -7
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +61 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +255 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +135 -15
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /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
|
-
|
|
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)'}`,
|
package/scripts/worktrees.mjs
CHANGED
|
@@ -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
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|