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,95 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Create a PR worktree (optionally scoped to a stack).
5
+ #
6
+ # Usage:
7
+ # ./wt-pr.sh <component> [stackName]
8
+ #
9
+ # Examples:
10
+ # ./wt-pr.sh happy
11
+ # ./wt-pr.sh happy-cli exp1
12
+ #
13
+ # Notes:
14
+ # - Uses an AppleScript prompt so it works well from SwiftBar without needing Terminal input.
15
+ # - Defaults to using the chosen remote's PR head ref and uses --use so the component becomes active.
16
+
17
+ COMPONENT="${1:-}"
18
+ STACK_NAME="${2:-}"
19
+
20
+ HAPPY_STACKS_HOME_DIR="${HAPPY_STACKS_HOME_DIR:-$HOME/.happy-stacks}"
21
+ HAPPY_LOCAL_DIR="${HAPPY_LOCAL_DIR:-$HAPPY_STACKS_HOME_DIR}"
22
+
23
+ HAPPYS="$HAPPY_LOCAL_DIR/extras/swiftbar/pnpm.sh"
24
+ if [[ ! -x "$HAPPYS" ]]; then
25
+ HAPPYS="$(command -v happys 2>/dev/null || true)"
26
+ fi
27
+ if [[ -z "$HAPPYS" ]]; then
28
+ echo "happys not found (run: happys init)" >&2
29
+ exit 1
30
+ fi
31
+
32
+ if ! command -v osascript >/dev/null 2>&1; then
33
+ echo "osascript not available" >&2
34
+ exit 1
35
+ fi
36
+
37
+ if [[ "$COMPONENT" == "_prompt_" ]]; then
38
+ COMPONENT=""
39
+ fi
40
+
41
+ if [[ -z "$COMPONENT" ]]; then
42
+ COMPONENT="$(osascript <<'APPLESCRIPT'
43
+ tell application "System Events"
44
+ activate
45
+ set theChoice to choose from list {"happy", "happy-cli", "happy-server-light", "happy-server"} with title "Happy Stacks — Component" with prompt "Choose component:" default items {"happy"}
46
+ if theChoice is false then
47
+ return ""
48
+ end if
49
+ return item 1 of theChoice
50
+ end tell
51
+ APPLESCRIPT
52
+ )" || true
53
+ COMPONENT="$(echo "${COMPONENT:-}" | tr -d '\r' | xargs || true)"
54
+ if [[ -z "$COMPONENT" ]]; then
55
+ echo "cancelled" >&2
56
+ exit 0
57
+ fi
58
+ fi
59
+
60
+ PR_INPUT="$(osascript <<'APPLESCRIPT'
61
+ tell application "System Events"
62
+ activate
63
+ set theDialogText to text returned of (display dialog "PR URL or number:" default answer "" with title "Happy Stacks — PR worktree")
64
+ return theDialogText
65
+ end tell
66
+ APPLESCRIPT
67
+ )" || true
68
+
69
+ PR_INPUT="$(echo "${PR_INPUT:-}" | tr -d '\r' | xargs || true)"
70
+ if [[ -z "$PR_INPUT" ]]; then
71
+ echo "cancelled" >&2
72
+ exit 0
73
+ fi
74
+
75
+ REMOTE_CHOICE="$(osascript <<'APPLESCRIPT'
76
+ tell application "System Events"
77
+ activate
78
+ set theChoice to button returned of (display dialog "Remote to fetch PR from:" with title "Happy Stacks — PR remote" buttons {"upstream", "origin"} default button "upstream")
79
+ return theChoice
80
+ end tell
81
+ APPLESCRIPT
82
+ )" || true
83
+
84
+ REMOTE_CHOICE="$(echo "${REMOTE_CHOICE:-upstream}" | tr -d '\r' | xargs || true)"
85
+ if [[ -z "$REMOTE_CHOICE" ]]; then
86
+ REMOTE_CHOICE="upstream"
87
+ fi
88
+
89
+ if [[ -n "$STACK_NAME" && "$STACK_NAME" != "main" ]]; then
90
+ "$HAPPYS" stack wt "$STACK_NAME" -- pr "$COMPONENT" "$PR_INPUT" --remote="$REMOTE_CHOICE" --use
91
+ else
92
+ "$HAPPYS" wt pr "$COMPONENT" "$PR_INPUT" --remote="$REMOTE_CHOICE" --use
93
+ fi
94
+
95
+ echo "ok"
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "happy-stacks",
3
+ "type": "module",
4
+ "version": "0.0.0",
5
+ "packageManager": "pnpm@10.18.3",
6
+ "bin": {
7
+ "happys": "./bin/happys.mjs",
8
+ "happy-stacks": "./bin/happys.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "docs/",
13
+ "extras/",
14
+ "scripts/"
15
+ ],
16
+ "scripts": {
17
+ "init": "node ./scripts/init.mjs",
18
+ "uninstall": "node ./scripts/uninstall.mjs",
19
+ "where": "node ./scripts/where.mjs",
20
+ "self": "node ./scripts/self.mjs",
21
+ "bootstrap": "node ./scripts/install.mjs",
22
+ "build": "node ./scripts/build.mjs",
23
+ "start": "node ./scripts/run.mjs",
24
+ "dev": "node ./scripts/dev.mjs",
25
+ "happy": "node ./scripts/happy.mjs",
26
+ "wt": "node ./scripts/worktrees.mjs",
27
+ "srv": "node ./scripts/server_flavor.mjs",
28
+ "server-flavor": "node ./scripts/server_flavor.mjs",
29
+ "stack": "node ./scripts/stack.mjs",
30
+ "mobile": "node ./scripts/mobile.mjs",
31
+ "mobile:prebuild": "node ./scripts/mobile.mjs --prebuild --clean --no-metro",
32
+ "mobile:ios": "node ./scripts/mobile.mjs --run-ios --no-metro",
33
+ "mobile:ios:release": "node ./scripts/mobile.mjs --run-ios --no-metro --configuration=Release",
34
+ "mobile:install": "node ./scripts/mobile.mjs --run-ios --no-metro --configuration=Release",
35
+ "mobile:devices": "xcrun xcdevice list",
36
+ "cli:link": "node ./scripts/cli-link.mjs",
37
+ "auth": "node ./scripts/auth.mjs",
38
+ "service:status": "node ./scripts/service.mjs status",
39
+ "service:start": "node ./scripts/service.mjs start",
40
+ "service:stop": "node ./scripts/service.mjs stop",
41
+ "service:restart": "node ./scripts/service.mjs restart",
42
+ "service:enable": "node ./scripts/service.mjs enable",
43
+ "service:disable": "node ./scripts/service.mjs disable",
44
+ "service:install": "node ./scripts/service.mjs install",
45
+ "service:uninstall": "node ./scripts/service.mjs uninstall",
46
+ "tailscale:status": "node ./scripts/tailscale.mjs status",
47
+ "tailscale:url": "node ./scripts/tailscale.mjs url",
48
+ "tailscale:enable": "node ./scripts/tailscale.mjs enable",
49
+ "tailscale:disable": "node ./scripts/tailscale.mjs disable",
50
+ "stack:doctor": "node ./scripts/doctor.mjs",
51
+ "stack:fix": "node ./scripts/doctor.mjs --fix",
52
+ "logs": "node ./scripts/service.mjs logs",
53
+ "logs:tail": "node ./scripts/service.mjs tail",
54
+ "menubar:install": "node ./scripts/menubar.mjs install",
55
+ "menubar:uninstall": "node ./scripts/menubar.mjs uninstall",
56
+ "menubar:open": "bash -lc 'DIR=\"$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)\"; if [[ -z \"$DIR\" ]]; then DIR=\"$HOME/Library/Application Support/SwiftBar/Plugins\"; fi; open \"$DIR\"'"
57
+ }
58
+ }
@@ -0,0 +1,272 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
4
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir, getStackName } from './utils/paths.mjs';
5
+ import { resolvePublicServerUrl } from './tailscale.mjs';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { spawn } from 'node:child_process';
10
+
11
+ function getInternalServerUrl() {
12
+ const portRaw = (process.env.HAPPY_LOCAL_SERVER_PORT ?? process.env.HAPPY_STACKS_SERVER_PORT ?? '').trim();
13
+ const port = portRaw ? Number(portRaw) : 3005;
14
+ const n = Number.isFinite(port) ? port : 3005;
15
+ return { port: n, url: `http://127.0.0.1:${n}` };
16
+ }
17
+
18
+ function expandTilde(p) {
19
+ return p.replace(/^~(?=\/)/, homedir());
20
+ }
21
+
22
+ function resolveCliHomeDir() {
23
+ const fromEnv = (process.env.HAPPY_LOCAL_CLI_HOME_DIR ?? process.env.HAPPY_STACKS_CLI_HOME_DIR ?? '').trim();
24
+ if (fromEnv) {
25
+ return expandTilde(fromEnv);
26
+ }
27
+ return join(getDefaultAutostartPaths().baseDir, 'cli');
28
+ }
29
+
30
+ function fileHasContent(path) {
31
+ try {
32
+ if (!existsSync(path)) return false;
33
+ return readFileSync(path, 'utf-8').trim().length > 0;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function checkDaemonState(cliHomeDir) {
40
+ const statePath = join(cliHomeDir, 'daemon.state.json');
41
+ const lockPath = join(cliHomeDir, 'daemon.state.json.lock');
42
+
43
+ const alive = (pid) => {
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ if (existsSync(statePath)) {
53
+ try {
54
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
55
+ const pid = Number(state?.pid);
56
+ if (Number.isFinite(pid) && pid > 0) {
57
+ return alive(pid) ? { status: 'running', pid } : { status: 'stale_state', pid };
58
+ }
59
+ return { status: 'bad_state' };
60
+ } catch {
61
+ return { status: 'bad_state' };
62
+ }
63
+ }
64
+
65
+ if (existsSync(lockPath)) {
66
+ try {
67
+ const pid = Number(readFileSync(lockPath, 'utf-8').trim());
68
+ if (Number.isFinite(pid) && pid > 0) {
69
+ return alive(pid) ? { status: 'starting', pid } : { status: 'stale_lock', pid };
70
+ }
71
+ } catch {
72
+ // ignore
73
+ }
74
+ }
75
+
76
+ return { status: 'stopped' };
77
+ }
78
+
79
+ async function fetchHealth(internalServerUrl) {
80
+ const ctl = new AbortController();
81
+ const t = setTimeout(() => ctl.abort(), 1500);
82
+ try {
83
+ const res = await fetch(`${internalServerUrl}/health`, { method: 'GET', signal: ctl.signal });
84
+ const body = (await res.text()).trim();
85
+ return { ok: res.ok, status: res.status, body };
86
+ } catch {
87
+ return { ok: false, status: null, body: null };
88
+ } finally {
89
+ clearTimeout(t);
90
+ }
91
+ }
92
+
93
+ function authLoginSuggestion(stackName) {
94
+ return stackName === 'main' ? 'happys auth login' : `happys stack auth ${stackName} login`;
95
+ }
96
+
97
+ async function cmdStatus({ json }) {
98
+ const rootDir = getRootDir(import.meta.url);
99
+ const stackName = getStackName();
100
+
101
+ const { port, url: internalServerUrl } = getInternalServerUrl();
102
+ const defaultPublicUrl = `http://localhost:${port}`;
103
+ const envPublicUrl = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
104
+ const { publicServerUrl } = await resolvePublicServerUrl({
105
+ internalServerUrl,
106
+ defaultPublicUrl,
107
+ envPublicUrl,
108
+ allowEnable: false,
109
+ });
110
+
111
+ const cliHomeDir = resolveCliHomeDir();
112
+ const accessKeyPath = join(cliHomeDir, 'access.key');
113
+ const settingsPath = join(cliHomeDir, 'settings.json');
114
+
115
+ const auth = {
116
+ ok: fileHasContent(accessKeyPath),
117
+ accessKeyPath,
118
+ hasAccessKey: fileHasContent(accessKeyPath),
119
+ settingsPath,
120
+ hasSettings: fileHasContent(settingsPath),
121
+ };
122
+
123
+ const daemon = checkDaemonState(cliHomeDir);
124
+ const health = await fetchHealth(internalServerUrl);
125
+
126
+ const out = {
127
+ stackName,
128
+ internalServerUrl,
129
+ publicServerUrl,
130
+ cliHomeDir,
131
+ auth,
132
+ daemon,
133
+ serverHealth: health,
134
+ cliBin: join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs'),
135
+ };
136
+
137
+ if (json) {
138
+ printResult({ json, data: out });
139
+ return;
140
+ }
141
+
142
+ const authLine = auth.ok ? '✅ auth: ok' : '❌ auth: required';
143
+ const daemonLine =
144
+ daemon.status === 'running'
145
+ ? `✅ daemon: running (pid=${daemon.pid})`
146
+ : daemon.status === 'starting'
147
+ ? `⏳ daemon: starting (pid=${daemon.pid})`
148
+ : daemon.status === 'stale_state'
149
+ ? `⚠️ daemon: stale state file (pid=${daemon.pid} not running)`
150
+ : daemon.status === 'stale_lock'
151
+ ? `⚠️ daemon: stale lock file (pid=${daemon.pid} not running)`
152
+ : daemon.status === 'bad_state'
153
+ ? '⚠️ daemon: unreadable state'
154
+ : '❌ daemon: not running';
155
+
156
+ const serverLine = health.ok ? `✅ server: healthy (${health.status})` : `⚠️ server: unreachable (${internalServerUrl})`;
157
+
158
+ console.log(`[auth] stack: ${stackName}`);
159
+ console.log(`[auth] urls: internal=${internalServerUrl} public=${publicServerUrl}`);
160
+ console.log(`[auth] cli: ${cliHomeDir}`);
161
+ console.log('');
162
+ console.log(authLine);
163
+ if (!auth.ok) {
164
+ console.log(` ↪ run: ${authLoginSuggestion(stackName)}`);
165
+ }
166
+ console.log(daemonLine);
167
+ console.log(serverLine);
168
+ if (auth.ok && daemon.status !== 'running') {
169
+ console.log(` ↪ auth is OK; this looks like a daemon/runtime issue. Try: happys doctor`);
170
+ }
171
+ }
172
+
173
+ async function cmdLogin({ argv, json }) {
174
+ const rootDir = getRootDir(import.meta.url);
175
+ const stackName = getStackName();
176
+
177
+ const { port, url: internalServerUrl } = getInternalServerUrl();
178
+ const defaultPublicUrl = `http://localhost:${port}`;
179
+ const envPublicUrl = (process.env.HAPPY_LOCAL_SERVER_URL ?? process.env.HAPPY_STACKS_SERVER_URL ?? '').trim();
180
+ const { publicServerUrl } = await resolvePublicServerUrl({
181
+ internalServerUrl,
182
+ defaultPublicUrl,
183
+ envPublicUrl,
184
+ allowEnable: false,
185
+ });
186
+
187
+ const cliHomeDir = resolveCliHomeDir();
188
+ const cliBin = join(getComponentDir(rootDir, 'happy-cli'), 'bin', 'happy.mjs');
189
+
190
+ const force = !argv.includes('--no-force');
191
+ const wantPrint = argv.includes('--print');
192
+
193
+ const nodeArgs = [cliBin, 'auth', 'login'];
194
+ if (force || argv.includes('--force')) {
195
+ nodeArgs.push('--force');
196
+ }
197
+
198
+ const env = {
199
+ ...process.env,
200
+ HAPPY_HOME_DIR: cliHomeDir,
201
+ HAPPY_SERVER_URL: internalServerUrl,
202
+ HAPPY_WEBAPP_URL: publicServerUrl,
203
+ };
204
+
205
+ if (wantPrint) {
206
+ const cmd = `HAPPY_HOME_DIR="${cliHomeDir}" HAPPY_SERVER_URL="${internalServerUrl}" HAPPY_WEBAPP_URL="${publicServerUrl}" node "${cliBin}" auth login${nodeArgs.includes('--force') ? ' --force' : ''}`;
207
+ if (json) {
208
+ printResult({ json, data: { ok: true, stackName, cmd } });
209
+ } else {
210
+ console.log(cmd);
211
+ }
212
+ return;
213
+ }
214
+
215
+ if (!json) {
216
+ console.log(`[auth] stack: ${stackName}`);
217
+ console.log(`[auth] launching login...`);
218
+ }
219
+
220
+ const child = spawn(process.execPath, nodeArgs, {
221
+ cwd: rootDir,
222
+ env,
223
+ stdio: 'inherit',
224
+ });
225
+
226
+ await new Promise((resolve) => child.on('exit', resolve));
227
+ if (json) {
228
+ printResult({ json, data: { ok: child.exitCode === 0, exitCode: child.exitCode } });
229
+ } else if (child.exitCode && child.exitCode !== 0) {
230
+ process.exit(child.exitCode);
231
+ }
232
+ }
233
+
234
+ async function main() {
235
+ const argv = process.argv.slice(2);
236
+ const { flags } = parseArgs(argv);
237
+ const json = wantsJson(argv, { flags });
238
+
239
+ const cmd = argv.find((a) => !a.startsWith('--')) || 'status';
240
+ if (wantsHelp(argv, { flags }) || cmd === 'help') {
241
+ printResult({
242
+ json,
243
+ data: { commands: ['status', 'login'], stackScoped: 'happys stack auth <name> status|login' },
244
+ text: [
245
+ '[auth] usage:',
246
+ ' happys auth status [--json]',
247
+ ' happys auth login [--force] [--print] [--json]',
248
+ '',
249
+ 'stack-scoped:',
250
+ ' happys stack auth <name> status [--json]',
251
+ ' happys stack auth <name> login [--force] [--print] [--json]',
252
+ ].join('\n'),
253
+ });
254
+ return;
255
+ }
256
+
257
+ if (cmd === 'status') {
258
+ await cmdStatus({ json });
259
+ return;
260
+ }
261
+ if (cmd === 'login') {
262
+ await cmdLogin({ argv, json });
263
+ return;
264
+ }
265
+
266
+ throw new Error(`[auth] unknown command: ${cmd}`);
267
+ }
268
+
269
+ main().catch((err) => {
270
+ console.error('[auth] failed:', err);
271
+ process.exit(1);
272
+ });
@@ -0,0 +1,204 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
4
+ import { ensureDepsInstalled, pmExecBin, requireDir } from './utils/pm.mjs';
5
+ import { dirname, join } from 'node:path';
6
+ import { readFile, rm, mkdir, writeFile } from 'node:fs/promises';
7
+ import { tailscaleServeHttpsUrl } from './tailscale.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
9
+
10
+ /**
11
+ * Build a lightweight static web UI bundle (no Expo dev server).
12
+ *
13
+ * Output directory default: ~/.happy/stacks/main/ui (legacy: ~/.happy/local/ui)
14
+ * Server will serve it at / when HAPPY_SERVER_LIGHT_UI_DIR is set.
15
+ * (Legacy /ui paths are redirected to /.)
16
+ */
17
+
18
+ async function main() {
19
+ const argv = process.argv.slice(2);
20
+ const { flags } = parseArgs(argv);
21
+ const json = wantsJson(argv, { flags });
22
+ if (wantsHelp(argv, { flags })) {
23
+ printResult({
24
+ json,
25
+ data: { flags: ['--tauri', '--no-tauri'], json: true },
26
+ text: [
27
+ '[build] usage:',
28
+ ' happys build [--tauri] [--json]',
29
+ ' (legacy in a cloned repo): pnpm build [-- --tauri] [--json]',
30
+ ' node scripts/build.mjs [--tauri|--no-tauri] [--json]',
31
+ ].join('\n'),
32
+ });
33
+ return;
34
+ }
35
+ const rootDir = getRootDir(import.meta.url);
36
+ const uiDir = getComponentDir(rootDir, 'happy');
37
+ await requireDir('happy', uiDir);
38
+
39
+ const serverPort = process.env.HAPPY_LOCAL_SERVER_PORT
40
+ ? parseInt(process.env.HAPPY_LOCAL_SERVER_PORT, 10)
41
+ : 3005;
42
+
43
+ // For Tauri builds we embed an explicit API base URL (tauri:// origins cannot use window.location.origin).
44
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
45
+
46
+ const outDir = process.env.HAPPY_LOCAL_UI_BUILD_DIR?.trim()
47
+ ? process.env.HAPPY_LOCAL_UI_BUILD_DIR.trim()
48
+ : join(getDefaultAutostartPaths().baseDir, 'ui');
49
+
50
+ // UI is served at root; /ui redirects to /.
51
+
52
+ await ensureDepsInstalled(uiDir, 'happy');
53
+
54
+ // Clean output to avoid stale assets.
55
+ await rm(outDir, { recursive: true, force: true });
56
+ await mkdir(outDir, { recursive: true });
57
+
58
+ console.log(`[local] exporting web UI to ${outDir}...`);
59
+
60
+ // Build for root hosting (the server redirects /ui -> /).
61
+ const env = {
62
+ ...process.env,
63
+ NODE_ENV: 'production',
64
+ EXPO_PUBLIC_DEBUG: '0',
65
+ // Leave empty for web export so the app uses window.location.origin at runtime.
66
+ // (Important for Tailscale: a phone loading `http://100.x.y.z:3005` must not call `http://localhost:3005`.)
67
+ EXPO_PUBLIC_HAPPY_SERVER_URL: '',
68
+ };
69
+
70
+ // Expo CLI is available via node_modules/.bin once dependencies are installed.
71
+ await pmExecBin({ dir: uiDir, bin: 'expo', args: ['export', '--platform', 'web', '--output-dir', outDir], env });
72
+
73
+ if (json) {
74
+ printResult({ json, data: { ok: true, outDir, tauriBuilt: false } });
75
+ } else {
76
+ console.log('[local] UI build complete');
77
+ }
78
+
79
+ //
80
+ // Tauri build (optional)
81
+ //
82
+ // Default: do NOT build Tauri (it's slow and requires extra toolchain).
83
+ // Enable explicitly with:
84
+ // - `happys build -- --tauri`, or
85
+ // - `HAPPY_LOCAL_BUILD_TAURI=1`
86
+ const envBuildTauri = (process.env.HAPPY_LOCAL_BUILD_TAURI ?? '').trim();
87
+ const buildTauriFromEnv = envBuildTauri !== '' ? envBuildTauri !== '0' : false;
88
+ const buildTauri = !flags.has('--no-tauri') && (flags.has('--tauri') || buildTauriFromEnv);
89
+ if (!buildTauri) {
90
+ return;
91
+ }
92
+
93
+ // Default to debug builds for local development so devtools are available.
94
+ const tauriDebug = (process.env.HAPPY_LOCAL_TAURI_DEBUG ?? '1') === '1';
95
+
96
+ // Choose the API endpoint the Tauri app should use.
97
+ //
98
+ // Priority:
99
+ // 1) HAPPY_LOCAL_TAURI_SERVER_URL (explicit override)
100
+ // 2) If available, a Tailscale Serve https://*.ts.net URL (portable across machines on the same tailnet)
101
+ // 3) Fallback to internal loopback (same-machine)
102
+ const tauriServerUrlOverride = process.env.HAPPY_LOCAL_TAURI_SERVER_URL?.trim()
103
+ ? process.env.HAPPY_LOCAL_TAURI_SERVER_URL.trim()
104
+ : '';
105
+ const preferTailscale = (process.env.HAPPY_LOCAL_TAURI_PREFER_TAILSCALE ?? '1') !== '0';
106
+ const tailscaleUrl = preferTailscale ? await tailscaleServeHttpsUrl() : null;
107
+ const tauriServerUrl = tauriServerUrlOverride || tailscaleUrl || internalServerUrl;
108
+
109
+ const tauriDistDir = process.env.HAPPY_LOCAL_TAURI_UI_DIR?.trim()
110
+ ? process.env.HAPPY_LOCAL_TAURI_UI_DIR.trim()
111
+ : join(uiDir, 'dist');
112
+
113
+ await rm(tauriDistDir, { recursive: true, force: true });
114
+ await mkdir(tauriDistDir, { recursive: true });
115
+
116
+ console.log(`[local] exporting web UI for Tauri to ${tauriDistDir}...`);
117
+
118
+ const tauriEnv = {
119
+ ...process.env,
120
+ NODE_ENV: 'production',
121
+ EXPO_PUBLIC_DEBUG: '0',
122
+ // In Tauri, window.location.origin is a tauri:// origin, so we must hardcode the API base.
123
+ EXPO_PUBLIC_HAPPY_SERVER_URL: tauriServerUrl,
124
+ // Some parts of the app use EXPO_PUBLIC_SERVER_URL; keep them aligned.
125
+ EXPO_PUBLIC_SERVER_URL: tauriServerUrl,
126
+ // For the Tauri bundle we want root-relative assets (no /ui baseUrl), so do not set EXPO_PUBLIC_WEB_BASE_URL
127
+ };
128
+ delete tauriEnv.EXPO_PUBLIC_WEB_BASE_URL;
129
+
130
+ await pmExecBin({
131
+ dir: uiDir,
132
+ bin: 'expo',
133
+ // Important: clear bundler cache so EXPO_PUBLIC_* inlining doesn't reuse
134
+ // the previous (web) export's transform results.
135
+ args: ['export', '--platform', 'web', '--output-dir', tauriDistDir, '-c'],
136
+ env: tauriEnv,
137
+ });
138
+
139
+ // Build the Tauri app using a generated config that skips upstream beforeBuildCommand (which uses yarn).
140
+ const tauriConfigPath = join(uiDir, 'src-tauri', 'tauri.conf.json');
141
+ const tauriConfigRaw = await readFile(tauriConfigPath, 'utf-8');
142
+ const tauriConfig = JSON.parse(tauriConfigRaw);
143
+ tauriConfig.build = tauriConfig.build ?? {};
144
+ // Prefer the upstream relative dist dir when possible (less surprising for Tauri tooling).
145
+ tauriConfig.build.frontendDist = tauriDistDir === join(uiDir, 'dist') ? '../dist' : tauriDistDir;
146
+ tauriConfig.build.beforeBuildCommand = null;
147
+ tauriConfig.build.beforeDevCommand = null;
148
+
149
+ // Build a separate "local" app so it doesn't reuse previous storage (server URL, auth, etc).
150
+ // This avoids needing any changes in the Happy source code to override a previously saved server.
151
+ tauriConfig.identifier = process.env.HAPPY_LOCAL_TAURI_IDENTIFIER?.trim()
152
+ ? process.env.HAPPY_LOCAL_TAURI_IDENTIFIER.trim()
153
+ : 'com.happy.stacks';
154
+ tauriConfig.productName = process.env.HAPPY_LOCAL_TAURI_PRODUCT_NAME?.trim()
155
+ ? process.env.HAPPY_LOCAL_TAURI_PRODUCT_NAME.trim()
156
+ : 'Happy Stacks';
157
+ if (tauriConfig.app?.windows?.length) {
158
+ tauriConfig.app.windows = tauriConfig.app.windows.map((w) => ({
159
+ ...w,
160
+ title: tauriConfig.productName ?? w.title,
161
+ }));
162
+ }
163
+
164
+ if (tauriDebug) {
165
+ // Enable devtools in debug builds (useful for troubleshooting connectivity).
166
+ tauriConfig.app = tauriConfig.app ?? {};
167
+ tauriConfig.app.windows = Array.isArray(tauriConfig.app.windows) ? tauriConfig.app.windows : [];
168
+ if (tauriConfig.app.windows.length > 0) {
169
+ tauriConfig.app.windows = tauriConfig.app.windows.map((w) => ({ ...w, devtools: true }));
170
+ }
171
+ }
172
+
173
+ const generatedConfigPath = join(getDefaultAutostartPaths().baseDir, 'tauri.conf.happy-stacks.json');
174
+ await mkdir(dirname(generatedConfigPath), { recursive: true });
175
+ await writeFile(generatedConfigPath, JSON.stringify(tauriConfig, null, 2), 'utf-8');
176
+
177
+ console.log('[local] building Tauri app...');
178
+ const cargoTargetDir = join(getDefaultAutostartPaths().baseDir, 'tauri-target');
179
+ await mkdir(cargoTargetDir, { recursive: true });
180
+
181
+ const tauriBuildEnv = {
182
+ ...process.env,
183
+ // Fixes builds after moving the repo by isolating cargo outputs from old absolute paths.
184
+ CARGO_TARGET_DIR: cargoTargetDir,
185
+ // Newer Tauri CLI parses CI as a boolean; many environments set CI=1 which fails.
186
+ CI: 'false',
187
+ };
188
+
189
+ const tauriArgs = ['build', '--config', generatedConfigPath];
190
+ if (tauriDebug) {
191
+ tauriArgs.push('--debug');
192
+ }
193
+ await pmExecBin({ dir: uiDir, bin: 'tauri', args: tauriArgs, env: tauriBuildEnv });
194
+ if (json) {
195
+ printResult({ json, data: { ok: true, outDir, tauriBuilt: true, tauriServerUrl } });
196
+ } else {
197
+ console.log('[local] Tauri build complete');
198
+ }
199
+ }
200
+
201
+ main().catch((err) => {
202
+ console.error('[local] build failed:', err);
203
+ process.exit(1);
204
+ });
@@ -0,0 +1,58 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { getComponentDir, getRootDir } from './utils/paths.mjs';
4
+ import { ensureCliBuilt, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
5
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
6
+
7
+ /**
8
+ * Link the local Happy CLI wrapper into your PATH.
9
+ *
10
+ * This is intentionally extracted so you can re-run linking without doing a full `happys bootstrap`.
11
+ *
12
+ * What it does:
13
+ * - optionally builds `components/happy-cli` (controlled by env/flags)
14
+ * - installs `happy`/`happys` shims under `~/.happy-stacks/bin` (recommended over `npm link`)
15
+ *
16
+ * Env:
17
+ * - HAPPY_LOCAL_CLI_BUILD=0 to skip building happy-cli
18
+ * - HAPPY_LOCAL_NPM_LINK=0 to skip shim installation
19
+ *
20
+ * Flags:
21
+ * - --no-build: skip building happy-cli
22
+ * - --no-link: skip shim installation
23
+ */
24
+
25
+ async function main() {
26
+ const argv = process.argv.slice(2);
27
+ const { flags } = parseArgs(argv);
28
+ const json = wantsJson(argv, { flags });
29
+ if (wantsHelp(argv, { flags })) {
30
+ printResult({
31
+ json,
32
+ data: { flags: ['--no-build', '--no-link'], json: true },
33
+ text: [
34
+ '[cli-link] usage:',
35
+ ' happys cli:link [--no-build] [--no-link] [--json]',
36
+ ' (legacy in a cloned repo): pnpm cli:link [-- --no-build] [--json]',
37
+ ' node scripts/cli-link.mjs [--no-build] [--no-link] [--json]',
38
+ ].join('\n'),
39
+ });
40
+ return;
41
+ }
42
+
43
+ const rootDir = getRootDir(import.meta.url);
44
+ const cliDir = getComponentDir(rootDir, 'happy-cli');
45
+
46
+ const buildCli = !flags.has('--no-build') && (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
47
+ const npmLinkCli = !flags.has('--no-link') && (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
48
+
49
+ await ensureCliBuilt(cliDir, { buildCli });
50
+ await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
51
+
52
+ printResult({ json, data: { ok: true, buildCli, npmLinkCli }, text: '[local] cli link complete' });
53
+ }
54
+
55
+ main().catch((err) => {
56
+ console.error('[local] cli link failed:', err);
57
+ process.exit(1);
58
+ });