happy-stacks 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +314 -0
  2. package/bin/happys.mjs +168 -0
  3. package/docs/menubar.md +186 -0
  4. package/docs/mobile-ios.md +134 -0
  5. package/docs/remote-access.md +43 -0
  6. package/docs/server-flavors.md +79 -0
  7. package/docs/stacks.md +218 -0
  8. package/docs/tauri.md +62 -0
  9. package/docs/worktrees-and-forks.md +395 -0
  10. package/extras/swiftbar/auth-login.sh +31 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +218 -0
  12. package/extras/swiftbar/icons/happy-green.png +0 -0
  13. package/extras/swiftbar/icons/happy-orange.png +0 -0
  14. package/extras/swiftbar/icons/happy-red.png +0 -0
  15. package/extras/swiftbar/icons/logo-white.png +0 -0
  16. package/extras/swiftbar/install.sh +191 -0
  17. package/extras/swiftbar/lib/git.sh +330 -0
  18. package/extras/swiftbar/lib/icons.sh +105 -0
  19. package/extras/swiftbar/lib/render.sh +774 -0
  20. package/extras/swiftbar/lib/system.sh +190 -0
  21. package/extras/swiftbar/lib/utils.sh +205 -0
  22. package/extras/swiftbar/pnpm-term.sh +125 -0
  23. package/extras/swiftbar/pnpm.sh +21 -0
  24. package/extras/swiftbar/set-interval.sh +62 -0
  25. package/extras/swiftbar/set-server-flavor.sh +57 -0
  26. package/extras/swiftbar/wt-pr.sh +95 -0
  27. package/package.json +58 -0
  28. package/scripts/auth.mjs +272 -0
  29. package/scripts/build.mjs +204 -0
  30. package/scripts/cli-link.mjs +58 -0
  31. package/scripts/completion.mjs +364 -0
  32. package/scripts/daemon.mjs +349 -0
  33. package/scripts/dev.mjs +181 -0
  34. package/scripts/doctor.mjs +342 -0
  35. package/scripts/happy.mjs +79 -0
  36. package/scripts/init.mjs +232 -0
  37. package/scripts/install.mjs +379 -0
  38. package/scripts/menubar.mjs +107 -0
  39. package/scripts/mobile.mjs +305 -0
  40. package/scripts/run.mjs +236 -0
  41. package/scripts/self.mjs +298 -0
  42. package/scripts/server_flavor.mjs +125 -0
  43. package/scripts/service.mjs +526 -0
  44. package/scripts/stack.mjs +815 -0
  45. package/scripts/tailscale.mjs +278 -0
  46. package/scripts/uninstall.mjs +190 -0
  47. package/scripts/utils/args.mjs +17 -0
  48. package/scripts/utils/cli.mjs +24 -0
  49. package/scripts/utils/cli_registry.mjs +262 -0
  50. package/scripts/utils/config.mjs +40 -0
  51. package/scripts/utils/dotenv.mjs +30 -0
  52. package/scripts/utils/env.mjs +138 -0
  53. package/scripts/utils/env_file.mjs +59 -0
  54. package/scripts/utils/env_local.mjs +25 -0
  55. package/scripts/utils/fs.mjs +11 -0
  56. package/scripts/utils/paths.mjs +184 -0
  57. package/scripts/utils/pm.mjs +294 -0
  58. package/scripts/utils/ports.mjs +66 -0
  59. package/scripts/utils/proc.mjs +66 -0
  60. package/scripts/utils/runtime.mjs +30 -0
  61. package/scripts/utils/server.mjs +41 -0
  62. package/scripts/utils/smoke_help.mjs +45 -0
  63. package/scripts/utils/validate.mjs +47 -0
  64. package/scripts/utils/wizard.mjs +69 -0
  65. package/scripts/utils/worktrees.mjs +78 -0
  66. package/scripts/where.mjs +105 -0
  67. package/scripts/worktrees.mjs +1721 -0
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname } from 'node:path';
5
+
6
+ import { getHappysRegistry } from './cli_registry.mjs';
7
+
8
+ function cliRootDir() {
9
+ // scripts/utils/* -> scripts -> repo root
10
+ return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
11
+ }
12
+
13
+ function runOrThrow(label, args) {
14
+ const root = cliRootDir();
15
+ const bin = join(root, 'bin', 'happys.mjs');
16
+ const res = spawnSync(process.execPath, [bin, ...args], { stdio: 'inherit', cwd: root, env: process.env });
17
+ if (res.status !== 0) {
18
+ throw new Error(`[smoke_help] failed (${label}): node bin/happys.mjs ${args.join(' ')}`);
19
+ }
20
+ }
21
+
22
+ function visibleCommands() {
23
+ const { commands } = getHappysRegistry();
24
+ return commands.filter((c) => !c.hidden).map((c) => c.name);
25
+ }
26
+
27
+ async function main() {
28
+ const cmds = visibleCommands();
29
+ for (const c of cmds) {
30
+ runOrThrow(`${c} --help`, [c, '--help']);
31
+ }
32
+
33
+ // Also validate delegation path for a few key groups.
34
+ for (const c of ['wt', 'stack', 'srv', 'service', 'tailscale', 'self', 'menubar', 'completion', 'where', 'init', 'uninstall']) {
35
+ runOrThrow(`help ${c}`, ['help', c]);
36
+ }
37
+
38
+ process.stdout.write('[smoke_help] ok\n');
39
+ }
40
+
41
+ main().catch((err) => {
42
+ process.stderr.write(String(err?.message ?? err) + '\n');
43
+ process.exit(1);
44
+ });
45
+
@@ -0,0 +1,47 @@
1
+ import { resolve, sep } from 'node:path';
2
+ import { getComponentsDir } from './paths.mjs';
3
+
4
+ function isInside(path, dir) {
5
+ const p = resolve(path);
6
+ const d = resolve(dir);
7
+ return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
8
+ }
9
+
10
+ export function detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir }) {
11
+ const componentsDir = getComponentsDir(rootDir);
12
+
13
+ const other = serverComponentName === 'happy-server-light' ? 'happy-server' : serverComponentName === 'happy-server' ? 'happy-server-light' : null;
14
+ if (!other) {
15
+ return null;
16
+ }
17
+
18
+ const otherRepo = resolve(componentsDir, other);
19
+ const otherWts = resolve(componentsDir, '.worktrees', other);
20
+
21
+ if (isInside(serverDir, otherRepo) || isInside(serverDir, otherWts)) {
22
+ return { expected: serverComponentName, actual: other, serverDir };
23
+ }
24
+
25
+ return null;
26
+ }
27
+
28
+ export function assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir }) {
29
+ const mismatch = detectServerComponentDirMismatch({ rootDir, serverComponentName, serverDir });
30
+ if (!mismatch) {
31
+ return;
32
+ }
33
+
34
+ const hint =
35
+ mismatch.expected === 'happy-server-light'
36
+ ? 'Fix: either switch flavor (`happys srv use happy-server`) or switch the active checkout for happy-server-light (`happys wt use happy-server-light default` or a worktree under .worktrees/happy-server-light/).'
37
+ : 'Fix: either switch flavor (`happys srv use happy-server-light`) or switch the active checkout for happy-server (`happys wt use happy-server default` or a worktree under .worktrees/happy-server/).';
38
+
39
+ throw new Error(
40
+ `[server] server component dir mismatch:\n` +
41
+ `- selected flavor: ${mismatch.expected}\n` +
42
+ `- but HAPPY_STACKS_COMPONENT_DIR_* points inside: ${mismatch.actual}\n` +
43
+ `- path: ${mismatch.serverDir}\n` +
44
+ `${hint}`
45
+ );
46
+ }
47
+
@@ -0,0 +1,69 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { listWorktreeSpecs } from './worktrees.mjs';
3
+
4
+ export function isTty() {
5
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
6
+ }
7
+
8
+ export async function withRl(fn) {
9
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
10
+ try {
11
+ return await fn(rl);
12
+ } finally {
13
+ rl.close();
14
+ }
15
+ }
16
+
17
+ export async function prompt(rl, question, { defaultValue = '' } = {}) {
18
+ const raw = (await rl.question(question)).trim();
19
+ return raw || defaultValue;
20
+ }
21
+
22
+ export async function promptSelect(rl, { title, options, defaultIndex = 0 }) {
23
+ if (!options.length) {
24
+ throw new Error('[wizard] no options to select from');
25
+ }
26
+ // eslint-disable-next-line no-console
27
+ console.log(title);
28
+ for (let i = 0; i < options.length; i++) {
29
+ // eslint-disable-next-line no-console
30
+ console.log(` ${i + 1}) ${options[i].label}`);
31
+ }
32
+ const answer = (await rl.question(`Pick [1-${options.length}] (default: ${defaultIndex + 1}): `)).trim();
33
+ const n = answer ? Number(answer) : defaultIndex + 1;
34
+ const idx = Math.max(1, Math.min(options.length, Number.isFinite(n) ? n : defaultIndex + 1)) - 1;
35
+ return options[idx].value;
36
+ }
37
+
38
+ export async function promptWorktreeSource({ rl, rootDir, component, stackName, createRemote = 'upstream' }) {
39
+ const specs = await listWorktreeSpecs({ rootDir, component });
40
+
41
+ const baseOptions = [{ label: `default (components/${component})`, value: 'default' }];
42
+ if (specs.length) {
43
+ baseOptions.push({ label: 'pick existing worktree', value: 'pick' });
44
+ }
45
+ baseOptions.push({ label: `create new worktree (${createRemote})`, value: 'create' });
46
+
47
+ const kind = await promptSelect(rl, { title: `Select ${component}:`, options: baseOptions, defaultIndex: 0 });
48
+
49
+ if (kind === 'default') {
50
+ return 'default';
51
+ }
52
+ if (kind === 'pick') {
53
+ const picked = await promptSelect(rl, {
54
+ title: `Available ${component} worktrees:`,
55
+ options: specs.map((s) => ({ label: s, value: s })),
56
+ defaultIndex: 0,
57
+ });
58
+ return picked;
59
+ }
60
+
61
+ const slug = await prompt(rl, `New worktree slug for ${component} (example: pr/${stackName}/${component}): `, {
62
+ defaultValue: '',
63
+ });
64
+ if (!slug) {
65
+ return 'default';
66
+ }
67
+ return { create: true, slug, remote: createRemote };
68
+ }
69
+
@@ -0,0 +1,78 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { getComponentsDir } from './paths.mjs';
4
+ import { pathExists } from './fs.mjs';
5
+ import { run, runCapture } from './proc.mjs';
6
+
7
+ export function parseGithubOwner(remoteUrl) {
8
+ const raw = (remoteUrl ?? '').trim();
9
+ if (!raw) {
10
+ return null;
11
+ }
12
+ // https://github.com/<owner>/<repo>.git
13
+ // git@github.com:<owner>/<repo>.git
14
+ const m = raw.match(/github\.com[:/](?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/);
15
+ return m?.groups?.owner ?? null;
16
+ }
17
+
18
+ export function getWorktreesRoot(rootDir) {
19
+ return join(getComponentsDir(rootDir), '.worktrees');
20
+ }
21
+
22
+ export function componentRepoDir(rootDir, component) {
23
+ return join(getComponentsDir(rootDir), component);
24
+ }
25
+
26
+ export function resolveComponentSpecToDir({ rootDir, component, spec }) {
27
+ const raw = (spec ?? '').trim();
28
+ if (!raw || raw === 'default') {
29
+ return null;
30
+ }
31
+ if (isAbsolute(raw)) {
32
+ return raw;
33
+ }
34
+ // Treat as <owner>/<branch...> under components/.worktrees/<component>/...
35
+ return join(getWorktreesRoot(rootDir), component, ...raw.split('/'));
36
+ }
37
+
38
+ export async function listWorktreeSpecs({ rootDir, component }) {
39
+ const dir = join(getWorktreesRoot(rootDir), component);
40
+ const specs = [];
41
+ try {
42
+ const walk = async (d, prefixParts) => {
43
+ const entries = await readdir(d, { withFileTypes: true });
44
+ for (const e of entries) {
45
+ if (!e.isDirectory()) continue;
46
+ const p = join(d, e.name);
47
+ const nextPrefix = [...prefixParts, e.name];
48
+ if (await pathExists(join(p, '.git'))) {
49
+ specs.push(nextPrefix.join('/'));
50
+ }
51
+ await walk(p, nextPrefix);
52
+ }
53
+ };
54
+ if (await pathExists(dir)) {
55
+ await walk(dir, []);
56
+ }
57
+ } catch {
58
+ // ignore
59
+ }
60
+ return specs.sort();
61
+ }
62
+
63
+ export async function getRemoteOwner({ repoDir, remoteName = 'upstream' }) {
64
+ const url = (await runCapture('git', ['remote', 'get-url', remoteName], { cwd: repoDir })).trim();
65
+ const owner = parseGithubOwner(url);
66
+ if (!owner) {
67
+ throw new Error(`[worktrees] unable to parse owner for ${repoDir} remote ${remoteName} (${url})`);
68
+ }
69
+ return owner;
70
+ }
71
+
72
+ export async function createWorktree({ rootDir, component, slug, remoteName = 'upstream' }) {
73
+ // Create without modifying env.local (unless caller passes --use elsewhere).
74
+ await run(process.execPath, [join(rootDir, 'bin', 'happys.mjs'), 'wt', 'new', component, slug, `--remote=${remoteName}`], { cwd: rootDir });
75
+ const repoDir = componentRepoDir(rootDir, component);
76
+ const owner = await getRemoteOwner({ repoDir, remoteName });
77
+ return join(getWorktreesRoot(rootDir), component, owner, ...slug.split('/'));
78
+ }
@@ -0,0 +1,105 @@
1
+ import './utils/env.mjs';
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
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
+ }
15
+
16
+ function getHomeEnvPaths() {
17
+ const homeDir = getHappyStacksHomeDir();
18
+ return {
19
+ homeEnv: join(homeDir, '.env'),
20
+ homeLocal: join(homeDir, 'env.local'),
21
+ };
22
+ }
23
+
24
+ async function main() {
25
+ const argv = process.argv.slice(2);
26
+ const { flags } = parseArgs(argv);
27
+ const json = wantsJson(argv, { flags });
28
+ if (wantsHelp(argv, { flags }) || argv.includes('help')) {
29
+ printResult({
30
+ json,
31
+ data: { flags: ['--json'], commands: ['where', 'env'] },
32
+ text: ['[where] usage:', ' happys where [--json]', ' happys env [--json]'].join('\n'),
33
+ });
34
+ return;
35
+ }
36
+
37
+ const rootDir = getRootDir(import.meta.url);
38
+ const homeDir = getHappyStacksHomeDir();
39
+ const runtimeDir = getRuntimeDir();
40
+ const workspaceDir = getWorkspaceDir(rootDir);
41
+ const componentsDir = getComponentsDir(rootDir);
42
+
43
+ const stackName = getStackName();
44
+ const stackLabel = getStackLabel(stackName);
45
+ const resolvedMainEnv = resolveStackEnvPath('main');
46
+ const resolvedActiveEnv = process.env.HAPPY_STACKS_ENV_FILE?.trim()
47
+ ? { envPath: expandHome(process.env.HAPPY_STACKS_ENV_FILE.trim()) }
48
+ : process.env.HAPPY_LOCAL_ENV_FILE?.trim()
49
+ ? { envPath: expandHome(process.env.HAPPY_LOCAL_ENV_FILE.trim()) }
50
+ : null;
51
+
52
+ const { homeEnv, homeLocal } = getHomeEnvPaths();
53
+ const updateCachePath = join(homeDir, 'cache', 'update.json');
54
+
55
+ const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
56
+ const componentDirs = Object.fromEntries(componentNames.map((name) => [name, getComponentDir(rootDir, name)]));
57
+
58
+ printResult({
59
+ json,
60
+ data: {
61
+ ok: true,
62
+ rootDir,
63
+ homeDir,
64
+ runtimeDir,
65
+ workspaceDir,
66
+ componentsDir,
67
+ stack: { name: stackName, label: stackLabel },
68
+ envFiles: {
69
+ homeEnv: { path: homeEnv, exists: existsSync(homeEnv) },
70
+ homeLocal: { path: homeLocal, exists: existsSync(homeLocal) },
71
+ active: resolvedActiveEnv ? { path: resolvedActiveEnv.envPath, exists: existsSync(resolvedActiveEnv.envPath) } : null,
72
+ main: { path: resolvedMainEnv.envPath, exists: existsSync(resolvedMainEnv.envPath) },
73
+ },
74
+ components: componentDirs,
75
+ update: {
76
+ enabled: (process.env.HAPPY_STACKS_UPDATE_CHECK ?? '1') !== '0',
77
+ cachePath: updateCachePath,
78
+ cacheExists: existsSync(updateCachePath),
79
+ },
80
+ },
81
+ text: [
82
+ `[where] root: ${rootDir}`,
83
+ `[where] home: ${homeDir}`,
84
+ `[where] runtime: ${runtimeDir}`,
85
+ `[where] workspace: ${workspaceDir}`,
86
+ `[where] components:${componentsDir}`,
87
+ '',
88
+ `[where] stack: ${stackName} (${stackLabel})`,
89
+ `[where] env (home defaults): ${existsSync(homeEnv) ? homeEnv : `${homeEnv} (missing)`}`,
90
+ `[where] env (home overrides): ${existsSync(homeLocal) ? homeLocal : `${homeLocal} (missing)`}`,
91
+ `[where] env (active): ${resolvedActiveEnv?.envPath ? resolvedActiveEnv.envPath : '(none)'}`,
92
+ `[where] env (main): ${resolvedMainEnv.envPath}`,
93
+ '',
94
+ ...componentNames.map((n) => `[where] component ${n}: ${componentDirs[n]}`),
95
+ '',
96
+ `[where] update cache: ${updateCachePath}`,
97
+ ].join('\n'),
98
+ });
99
+ }
100
+
101
+ main().catch((err) => {
102
+ console.error('[where] failed:', err);
103
+ process.exit(1);
104
+ });
105
+