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.
Files changed (95) hide show
  1. package/README.md +130 -74
  2. package/bin/happys.mjs +140 -9
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/server-flavors.md +61 -2
  8. package/docs/stacks.md +55 -4
  9. package/extras/swiftbar/auth-login.sh +10 -7
  10. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +175 -83
  12. package/extras/swiftbar/happys-term.sh +128 -0
  13. package/extras/swiftbar/happys.sh +35 -0
  14. package/extras/swiftbar/install.sh +99 -13
  15. package/extras/swiftbar/lib/git.sh +309 -1
  16. package/extras/swiftbar/lib/icons.sh +2 -2
  17. package/extras/swiftbar/lib/render.sh +279 -132
  18. package/extras/swiftbar/lib/system.sh +64 -10
  19. package/extras/swiftbar/lib/utils.sh +469 -10
  20. package/extras/swiftbar/pnpm-term.sh +2 -122
  21. package/extras/swiftbar/pnpm.sh +4 -14
  22. package/extras/swiftbar/set-interval.sh +10 -5
  23. package/extras/swiftbar/set-server-flavor.sh +19 -10
  24. package/extras/swiftbar/wt-pr.sh +10 -3
  25. package/package.json +2 -1
  26. package/scripts/auth.mjs +833 -14
  27. package/scripts/build.mjs +24 -4
  28. package/scripts/cli-link.mjs +3 -3
  29. package/scripts/completion.mjs +15 -8
  30. package/scripts/daemon.mjs +200 -23
  31. package/scripts/dev.mjs +230 -57
  32. package/scripts/doctor.mjs +26 -21
  33. package/scripts/edison.mjs +1828 -0
  34. package/scripts/happy.mjs +3 -7
  35. package/scripts/init.mjs +275 -46
  36. package/scripts/install.mjs +14 -8
  37. package/scripts/lint.mjs +145 -0
  38. package/scripts/menubar.mjs +81 -8
  39. package/scripts/migrate.mjs +302 -0
  40. package/scripts/mobile.mjs +59 -21
  41. package/scripts/run.mjs +222 -43
  42. package/scripts/self.mjs +3 -7
  43. package/scripts/server_flavor.mjs +3 -3
  44. package/scripts/service.mjs +190 -38
  45. package/scripts/setup.mjs +790 -0
  46. package/scripts/setup_pr.mjs +182 -0
  47. package/scripts/stack.mjs +2273 -92
  48. package/scripts/stop.mjs +160 -0
  49. package/scripts/tailscale.mjs +164 -23
  50. package/scripts/test.mjs +144 -0
  51. package/scripts/tui.mjs +556 -0
  52. package/scripts/typecheck.mjs +145 -0
  53. package/scripts/ui_gateway.mjs +248 -0
  54. package/scripts/uninstall.mjs +21 -13
  55. package/scripts/utils/auth_files.mjs +58 -0
  56. package/scripts/utils/auth_login_ux.mjs +76 -0
  57. package/scripts/utils/auth_sources.mjs +12 -0
  58. package/scripts/utils/browser.mjs +22 -0
  59. package/scripts/utils/canonical_home.mjs +20 -0
  60. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
  61. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  62. package/scripts/utils/config.mjs +13 -1
  63. package/scripts/utils/dev_auth_key.mjs +169 -0
  64. package/scripts/utils/dev_daemon.mjs +104 -0
  65. package/scripts/utils/dev_expo_web.mjs +112 -0
  66. package/scripts/utils/dev_server.mjs +183 -0
  67. package/scripts/utils/env.mjs +94 -23
  68. package/scripts/utils/env_file.mjs +36 -0
  69. package/scripts/utils/expo.mjs +96 -0
  70. package/scripts/utils/handy_master_secret.mjs +94 -0
  71. package/scripts/utils/happy_server_infra.mjs +484 -0
  72. package/scripts/utils/localhost_host.mjs +17 -0
  73. package/scripts/utils/ownership.mjs +135 -0
  74. package/scripts/utils/paths.mjs +5 -2
  75. package/scripts/utils/pm.mjs +132 -22
  76. package/scripts/utils/ports.mjs +51 -13
  77. package/scripts/utils/proc.mjs +75 -7
  78. package/scripts/utils/runtime.mjs +1 -3
  79. package/scripts/utils/sandbox.mjs +14 -0
  80. package/scripts/utils/server.mjs +61 -0
  81. package/scripts/utils/server_port.mjs +9 -0
  82. package/scripts/utils/server_urls.mjs +54 -0
  83. package/scripts/utils/stack_context.mjs +23 -0
  84. package/scripts/utils/stack_runtime_state.mjs +104 -0
  85. package/scripts/utils/stack_startup.mjs +208 -0
  86. package/scripts/utils/stack_stop.mjs +255 -0
  87. package/scripts/utils/stacks.mjs +38 -0
  88. package/scripts/utils/validate.mjs +42 -1
  89. package/scripts/utils/watch.mjs +63 -0
  90. package/scripts/utils/worktrees.mjs +57 -1
  91. package/scripts/where.mjs +14 -7
  92. package/scripts/worktrees.mjs +135 -15
  93. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  94. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  95. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -0,0 +1,248 @@
1
+ import './utils/env.mjs';
2
+ import http from 'node:http';
3
+ import net from 'node:net';
4
+ import { extname, resolve, sep } from 'node:path';
5
+ import { readFile, stat } from 'node:fs/promises';
6
+
7
+ import { parseArgs } from './utils/cli/args.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
9
+
10
+ function usage() {
11
+ return [
12
+ '[ui-gateway] usage:',
13
+ ' node scripts/ui_gateway.mjs --port=<port> --backend-url=<url> --minio-port=<port> --bucket=<name> [--ui-dir=<path>] [--no-ui]',
14
+ '',
15
+ 'Reverse-proxy gateway that can optionally serve the built Happy web UI at /.',
16
+ '',
17
+ 'Always proxies:',
18
+ '- /v1/* and /health to backend-url',
19
+ '- /v1/updates websocket upgrades to backend-url (socket.io)',
20
+ '- /files/* to local Minio (http://127.0.0.1:<minio-port>/<bucket>/...)',
21
+ ].join('\n');
22
+ }
23
+
24
+ function normalizePublicPath(path) {
25
+ const p = String(path ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
26
+ const parts = p.split('/').filter(Boolean);
27
+ if (parts.some((part) => part === '..')) {
28
+ throw new Error('Invalid path');
29
+ }
30
+ if (p.includes(':') || p.startsWith('/')) {
31
+ throw new Error('Invalid path');
32
+ }
33
+ return parts.join('/');
34
+ }
35
+
36
+ function contentTypeForExt(ext) {
37
+ const e = ext.toLowerCase();
38
+ if (e === '.html') return 'text/html; charset=utf-8';
39
+ if (e === '.js') return 'text/javascript; charset=utf-8';
40
+ if (e === '.css') return 'text/css; charset=utf-8';
41
+ if (e === '.json') return 'application/json; charset=utf-8';
42
+ if (e === '.svg') return 'image/svg+xml';
43
+ if (e === '.ico') return 'image/x-icon';
44
+ if (e === '.wasm') return 'application/wasm';
45
+ if (e === '.ttf') return 'font/ttf';
46
+ if (e === '.woff') return 'font/woff';
47
+ if (e === '.woff2') return 'font/woff2';
48
+ if (e === '.png') return 'image/png';
49
+ if (e === '.jpg' || e === '.jpeg') return 'image/jpeg';
50
+ if (e === '.webp') return 'image/webp';
51
+ if (e === '.gif') return 'image/gif';
52
+ return 'application/octet-stream';
53
+ }
54
+
55
+ async function sendUiFile({ uiRoot, relPath, res }) {
56
+ const candidate = resolve(uiRoot, relPath);
57
+ if (!(candidate === uiRoot || candidate.startsWith(uiRoot + sep))) {
58
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
59
+ res.end('Not found');
60
+ return;
61
+ }
62
+ const bytes = await readFile(candidate);
63
+ const ext = extname(candidate);
64
+ const isHtml = ext.toLowerCase() === '.html';
65
+ res.setHeader('content-type', contentTypeForExt(ext));
66
+ if (isHtml) {
67
+ res.setHeader('cache-control', 'no-cache');
68
+ } else {
69
+ res.setHeader('cache-control', 'public, max-age=31536000, immutable');
70
+ }
71
+ res.end(bytes);
72
+ }
73
+
74
+ async function sendIndex({ uiRoot, res }) {
75
+ const indexPath = resolve(uiRoot, 'index.html');
76
+ const html = (await readFile(indexPath, 'utf-8')) + '\n<!-- Welcome to Happy Server! -->\n';
77
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
78
+ res.end(html);
79
+ }
80
+
81
+ function proxyHttp({ target, req, res, rewritePath = (p) => p }) {
82
+ const url = new URL(target);
83
+ const method = req.method || 'GET';
84
+ const headers = { ...req.headers };
85
+ // Let Node compute correct host
86
+ delete headers.host;
87
+
88
+ const upstream = http.request(
89
+ {
90
+ protocol: url.protocol,
91
+ hostname: url.hostname,
92
+ port: url.port,
93
+ method,
94
+ path: rewritePath(req.url || '/'),
95
+ headers,
96
+ },
97
+ (up) => {
98
+ res.writeHead(up.statusCode || 502, up.headers);
99
+ up.pipe(res);
100
+ }
101
+ );
102
+ upstream.on('error', () => {
103
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
104
+ res.end('Bad gateway');
105
+ });
106
+ req.pipe(upstream);
107
+ }
108
+
109
+ function proxyUpgrade({ target, req, socket, head }) {
110
+ const url = new URL(target);
111
+ const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
112
+
113
+ const upstream = net.connect(port, url.hostname, () => {
114
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
115
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
116
+ lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
117
+ }
118
+ lines.push('', '');
119
+ upstream.write(lines.join('\r\n'));
120
+ if (head?.length) upstream.write(head);
121
+ socket.pipe(upstream).pipe(socket);
122
+ });
123
+ upstream.on('error', () => {
124
+ try {
125
+ socket.destroy();
126
+ } catch {
127
+ // ignore
128
+ }
129
+ });
130
+ }
131
+
132
+ async function main() {
133
+ const argv = process.argv.slice(2);
134
+ const { flags, kv } = parseArgs(argv);
135
+ const json = wantsJson(argv, { flags });
136
+ if (wantsHelp(argv, { flags })) {
137
+ printResult({ json, data: { ok: true }, text: usage() });
138
+ return;
139
+ }
140
+
141
+ const portRaw = (kv.get('--port') ?? '').trim();
142
+ const backendUrl = (kv.get('--backend-url') ?? '').trim();
143
+ const minioPortRaw = (kv.get('--minio-port') ?? '').trim();
144
+ const bucket = (kv.get('--bucket') ?? '').trim();
145
+ const serveUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
146
+ const uiDir = serveUi ? (kv.get('--ui-dir') ?? '').trim() : '';
147
+
148
+ const port = portRaw ? Number(portRaw) : NaN;
149
+ const minioPort = minioPortRaw ? Number(minioPortRaw) : NaN;
150
+
151
+ if (!backendUrl || !bucket || !Number.isFinite(port) || port <= 0 || !Number.isFinite(minioPort) || minioPort <= 0) {
152
+ throw new Error(usage());
153
+ }
154
+
155
+ const uiRoot = uiDir ? resolve(uiDir) : '';
156
+
157
+ const server = http.createServer(async (req, res) => {
158
+ try {
159
+ const url = req.url || '/';
160
+
161
+ // API + health proxy
162
+ if (url.startsWith('/v1/') || url === '/health' || url === '/metrics' || url.startsWith('/v2/')) {
163
+ proxyHttp({ target: backendUrl, req, res });
164
+ return;
165
+ }
166
+
167
+ // Public files proxy (Minio path-style)
168
+ if (url.startsWith('/files/')) {
169
+ proxyHttp({
170
+ target: `http://127.0.0.1:${minioPort}`,
171
+ req,
172
+ res,
173
+ rewritePath: (p) => {
174
+ const raw = p.replace(/^\/files\/?/, '');
175
+ const safe = normalizePublicPath(raw);
176
+ return `/${encodeURIComponent(bucket)}/${safe}`;
177
+ },
178
+ });
179
+ return;
180
+ }
181
+
182
+ // UI static
183
+ if (url === '/' || url === '/ui' || url === '/ui/') {
184
+ if (uiRoot) {
185
+ await sendIndex({ uiRoot, res });
186
+ return;
187
+ }
188
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-cache' });
189
+ res.end('Welcome to Happy Server!');
190
+ return;
191
+ }
192
+ if (url.startsWith('/ui/')) {
193
+ res.writeHead(302, { location: '/' });
194
+ res.end();
195
+ return;
196
+ }
197
+
198
+ if (uiRoot) {
199
+ const rel = normalizePublicPath(decodeURIComponent(url));
200
+ const candidate = resolve(uiRoot, rel);
201
+ const exists = candidate === uiRoot || candidate.startsWith(uiRoot + sep) ? await stat(candidate).then(() => true).catch(() => false) : false;
202
+ if (exists) {
203
+ await sendUiFile({ uiRoot, relPath: rel, res });
204
+ return;
205
+ }
206
+
207
+ // SPA fallback
208
+ await sendIndex({ uiRoot, res });
209
+ return;
210
+ }
211
+
212
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
213
+ res.end('Not found');
214
+ } catch {
215
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
216
+ res.end('Internal error');
217
+ }
218
+ });
219
+
220
+ server.on('upgrade', (req, socket, head) => {
221
+ try {
222
+ const url = req.url || '';
223
+ // socket.io upgrades for realtime updates
224
+ if (url.startsWith('/v1/updates')) {
225
+ proxyUpgrade({ target: backendUrl, req, socket, head });
226
+ return;
227
+ }
228
+ socket.destroy();
229
+ } catch {
230
+ try {
231
+ socket.destroy();
232
+ } catch {
233
+ // ignore
234
+ }
235
+ }
236
+ });
237
+
238
+ await new Promise((resolvePromise) => server.listen({ port, host: '0.0.0.0' }, resolvePromise));
239
+ // eslint-disable-next-line no-console
240
+ console.log(`[ui-gateway] ready on http://127.0.0.1:${port}`);
241
+ }
242
+
243
+ main().catch((err) => {
244
+ // eslint-disable-next-line no-console
245
+ console.error(err);
246
+ process.exit(1);
247
+ });
248
+
@@ -2,18 +2,16 @@ import './utils/env.mjs';
2
2
 
3
3
  import { rm } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
5
  import { join } from 'node:path';
7
6
  import { spawnSync } from 'node:child_process';
8
7
 
9
- import { parseArgs } from './utils/args.mjs';
10
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
11
- import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
8
+ import { parseArgs } from './utils/cli/args.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { expandHome } from './utils/canonical_home.mjs';
11
+ import { getHappyStacksHomeDir, getRootDir, getStacksStorageRoot } from './utils/paths.mjs';
12
12
  import { getRuntimeDir } from './utils/runtime.mjs';
13
-
14
- function expandHome(p) {
15
- return p.replace(/^~(?=\/)/, homedir());
16
- }
13
+ import { getCanonicalHomeEnvPath } from './utils/config.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
17
15
 
18
16
  function resolveWorkspaceDir({ rootDir, homeDir }) {
19
17
  // Uninstall should never default to deleting the repo root (getWorkspaceDir() can fall back to cliRootDir).
@@ -69,13 +67,14 @@ async function main() {
69
67
  if (wantsHelp(argv, { flags }) || argv.includes('help')) {
70
68
  printResult({
71
69
  json,
72
- data: { flags: ['--remove-workspace', '--remove-stacks', '--yes'], json: true },
70
+ data: { flags: ['--remove-workspace', '--remove-stacks', '--yes', '--global'], json: true },
73
71
  text: [
74
72
  '[uninstall] usage:',
75
73
  ' happys uninstall [--json] # dry-run',
76
74
  ' happys uninstall --yes [--json]',
77
75
  ' happys uninstall --remove-workspace --yes',
78
76
  ' happys uninstall --remove-stacks --yes',
77
+ ' happys uninstall --global --yes # also remove global OS integrations (services/SwiftBar) even in sandbox mode',
79
78
  '',
80
79
  'notes:',
81
80
  ' - default removes: runtime, shims, cache, SwiftBar assets + plugin files, and LaunchAgent services',
@@ -93,11 +92,12 @@ async function main() {
93
92
  const yes = flags.has('--yes');
94
93
  const removeWorkspace = flags.has('--remove-workspace');
95
94
  const removeStacks = flags.has('--remove-stacks');
95
+ const allowGlobal = flags.has('--global') || sandboxAllowsGlobalSideEffects();
96
96
 
97
97
  const dryRun = !yes;
98
98
 
99
99
  // 1) Stop/uninstall services best-effort.
100
- if (!dryRun) {
100
+ if (!dryRun && (!isSandboxed() || allowGlobal)) {
101
101
  try {
102
102
  spawnSync(process.execPath, [join(rootDir, 'scripts', 'service.mjs'), 'uninstall'], {
103
103
  stdio: json ? 'ignore' : 'inherit',
@@ -110,9 +110,15 @@ async function main() {
110
110
  }
111
111
 
112
112
  // 2) Remove SwiftBar plugin files best-effort.
113
- const menubar = dryRun ? { ok: true, removed: 0, pluginsDir: resolveSwiftbarPluginsDir() } : await removeSwiftbarPluginFiles().catch(() => ({ ok: false, removed: 0, pluginsDir: null }));
113
+ const menubar =
114
+ isSandboxed() && !allowGlobal
115
+ ? { ok: true, removed: 0, pluginsDir: null, skipped: 'sandbox' }
116
+ : dryRun
117
+ ? { ok: true, removed: 0, pluginsDir: resolveSwiftbarPluginsDir() }
118
+ : await removeSwiftbarPluginFiles().catch(() => ({ ok: false, removed: 0, pluginsDir: null }));
114
119
 
115
120
  // 3) Remove home-managed runtime + shims + extras + cache + env pointers.
121
+ const canonicalEnv = getCanonicalHomeEnvPath();
116
122
  const toRemove = [
117
123
  join(homeDir, 'bin'),
118
124
  join(homeDir, 'runtime'),
@@ -120,6 +126,8 @@ async function main() {
120
126
  join(homeDir, 'cache'),
121
127
  join(homeDir, '.env'),
122
128
  join(homeDir, 'env.local'),
129
+ // Stable pointer file (can differ from homeDir for custom installs).
130
+ canonicalEnv,
123
131
  ];
124
132
  const removedPaths = [];
125
133
  for (const p of toRemove) {
@@ -148,7 +156,7 @@ async function main() {
148
156
 
149
157
  // 5) Optionally remove stacks data.
150
158
  if (removeStacks) {
151
- const stacksRoot = join(homedir(), '.happy', 'stacks');
159
+ const stacksRoot = getStacksStorageRoot();
152
160
  if (existsSync(stacksRoot)) {
153
161
  if (!dryRun) {
154
162
  await rm(stacksRoot, { recursive: true, force: true });
@@ -177,7 +185,7 @@ async function main() {
177
185
  `[uninstall] removed: ${removedPaths.length ? removedPaths.join(', ') : '(nothing)'}`,
178
186
  menubar?.pluginsDir ? `[uninstall] SwiftBar plugins dir: ${menubar.pluginsDir}` : null,
179
187
  removeWorkspace ? `[uninstall] workspace removed: ${workspaceDir}` : `[uninstall] workspace kept: ${workspaceDir}`,
180
- removeStacks ? `[uninstall] stacks removed: ~/.happy/stacks` : `[uninstall] stacks kept: ~/.happy/stacks`,
188
+ removeStacks ? `[uninstall] stacks removed: ${getStacksStorageRoot()}` : `[uninstall] stacks kept: ${getStacksStorageRoot()}`,
181
189
  ]
182
190
  .filter(Boolean)
183
191
  .join('\n'),
@@ -0,0 +1,58 @@
1
+ import { chmod, copyFile, lstat, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ async function ensureDir(p) {
6
+ await mkdir(p, { recursive: true });
7
+ }
8
+
9
+ export async function removeFileOrSymlinkIfExists(path) {
10
+ try {
11
+ const st = await lstat(path);
12
+ if (st.isDirectory()) {
13
+ throw new Error(`[auth] refusing to remove directory path: ${path}`);
14
+ }
15
+ await unlink(path);
16
+ return true;
17
+ } catch (e) {
18
+ if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') return false;
19
+ throw e;
20
+ }
21
+ }
22
+
23
+ export async function writeSecretFileIfMissing({ path, secret, force = false }) {
24
+ if (!force && existsSync(path)) return false;
25
+ if (force && existsSync(path)) {
26
+ await removeFileOrSymlinkIfExists(path);
27
+ }
28
+ await ensureDir(dirname(path));
29
+ await writeFile(path, secret, { encoding: 'utf-8', mode: 0o600 });
30
+ return true;
31
+ }
32
+
33
+ export async function copyFileIfMissing({ from, to, mode, force = false }) {
34
+ if (!force && existsSync(to)) return false;
35
+ if (!existsSync(from)) return false;
36
+ await ensureDir(dirname(to));
37
+ // IMPORTANT: if `to` is a symlink and we "overwrite" it, do NOT write through it to the symlink target.
38
+ if (force && existsSync(to)) {
39
+ await removeFileOrSymlinkIfExists(to);
40
+ }
41
+ await copyFile(from, to);
42
+ if (mode) {
43
+ await chmod(to, mode).catch(() => {});
44
+ }
45
+ return true;
46
+ }
47
+
48
+ export async function linkFileIfMissing({ from, to, force = false }) {
49
+ if (!force && existsSync(to)) return false;
50
+ if (!existsSync(from)) return false;
51
+ await ensureDir(dirname(to));
52
+ if (force && existsSync(to)) {
53
+ await removeFileOrSymlinkIfExists(to);
54
+ }
55
+ await symlink(from, to);
56
+ return true;
57
+ }
58
+
@@ -0,0 +1,76 @@
1
+ export function normalizeAuthLoginContext(raw) {
2
+ const v = String(raw ?? '')
3
+ .trim()
4
+ .toLowerCase();
5
+ if (v === 'selfhost' || v === 'self-host' || v === 'self_host') return 'selfhost';
6
+ if (v === 'dev' || v === 'developer' || v === 'development') return 'dev';
7
+ if (v === 'stack') return 'stack';
8
+ return 'generic';
9
+ }
10
+
11
+ export function printAuthLoginInstructions({
12
+ stackName,
13
+ context = 'generic',
14
+ webappUrl,
15
+ webappUrlSource,
16
+ internalServerUrl,
17
+ publicServerUrl,
18
+ rerunCmd,
19
+ }) {
20
+ const ctx = normalizeAuthLoginContext(context);
21
+ const title =
22
+ ctx === 'selfhost'
23
+ ? '[auth] login (self-host)'
24
+ : ctx === 'dev'
25
+ ? '[auth] login (dev)'
26
+ : ctx === 'stack'
27
+ ? `[auth] login (stack=${stackName || 'unknown'})`
28
+ : '[auth] login';
29
+
30
+ // eslint-disable-next-line no-console
31
+ console.log('');
32
+ // eslint-disable-next-line no-console
33
+ console.log(title);
34
+ // eslint-disable-next-line no-console
35
+ console.log('[auth] steps:');
36
+ // eslint-disable-next-line no-console
37
+ console.log(' 1) A browser window will open for authentication');
38
+ // eslint-disable-next-line no-console
39
+ console.log(' 2) Sign in (or create an account if this is your first time)');
40
+ // eslint-disable-next-line no-console
41
+ console.log(' 3) Approve this terminal/machine connection');
42
+ // eslint-disable-next-line no-console
43
+ console.log(' 4) Return here — the CLI will finish automatically');
44
+
45
+ if (webappUrl) {
46
+ // eslint-disable-next-line no-console
47
+ console.log('');
48
+ // eslint-disable-next-line no-console
49
+ console.log(`[auth] webapp: ${webappUrl}${webappUrlSource ? ` (${webappUrlSource})` : ''}`);
50
+ }
51
+ if (internalServerUrl) {
52
+ // eslint-disable-next-line no-console
53
+ console.log(`[auth] internal: ${internalServerUrl}`);
54
+ }
55
+ if (publicServerUrl) {
56
+ // eslint-disable-next-line no-console
57
+ console.log(`[auth] public: ${publicServerUrl}`);
58
+ }
59
+
60
+ if (ctx === 'selfhost') {
61
+ // eslint-disable-next-line no-console
62
+ console.log('');
63
+ // eslint-disable-next-line no-console
64
+ console.log('[auth] note: this is required so the daemon can register this machine and sync sessions across devices.');
65
+ }
66
+
67
+ // eslint-disable-next-line no-console
68
+ console.log('');
69
+ // eslint-disable-next-line no-console
70
+ console.log('[auth] tips:');
71
+ // eslint-disable-next-line no-console
72
+ console.log('- If the browser page does not load, make sure Happy is running and reachable.');
73
+ // eslint-disable-next-line no-console
74
+ console.log(`- Re-run anytime: ${rerunCmd || 'happys auth login'}`);
75
+ }
76
+
@@ -0,0 +1,12 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ export function isLegacyAuthSourceName(name) {
5
+ const s = String(name ?? '').trim().toLowerCase();
6
+ return s === 'legacy' || s === 'system' || s === 'local-install';
7
+ }
8
+
9
+ export function getLegacyHappyBaseDir() {
10
+ return join(homedir(), '.happy');
11
+ }
12
+
@@ -0,0 +1,22 @@
1
+ import { runCapture } from './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,20 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ export function expandHome(p) {
5
+ return String(p ?? '').replace(/^~(?=\/)/, homedir());
6
+ }
7
+
8
+ export function getCanonicalHomeDirFromEnv(env = process.env) {
9
+ const fromEnv = (
10
+ (env.HAPPY_STACKS_CANONICAL_HOME_DIR ?? '').trim() ||
11
+ (env.HAPPY_LOCAL_CANONICAL_HOME_DIR ?? '').trim() ||
12
+ ''
13
+ );
14
+ return fromEnv ? expandHome(fromEnv) : join(homedir(), '.happy-stacks');
15
+ }
16
+
17
+ export function getCanonicalHomeEnvPathFromEnv(env = process.env) {
18
+ return join(getCanonicalHomeDirFromEnv(env), '.env');
19
+ }
20
+
@@ -20,6 +20,22 @@ export function getHappysRegistry() {
20
20
  rootUsage:
21
21
  'happys init [--home-dir=PATH] [--workspace-dir=PATH] [--runtime-dir=PATH] [--install-path] [--no-runtime] [--no-bootstrap] [--] [bootstrap args...]',
22
22
  description: 'Initialize ~/.happy-stacks (runtime + shims)',
23
+ hidden: true,
24
+ },
25
+ {
26
+ name: 'setup',
27
+ kind: 'node',
28
+ scriptRelPath: 'scripts/setup.mjs',
29
+ rootUsage: 'happys setup [--profile=selfhost|dev] [--json]',
30
+ description: 'Guided setup (selfhost or dev)',
31
+ },
32
+ {
33
+ name: 'setup-pr',
34
+ aliases: ['setupPR', 'setuppr'],
35
+ kind: 'node',
36
+ scriptRelPath: 'scripts/setup_pr.mjs',
37
+ rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev]',
38
+ description: 'One-shot: set up + run a PR stack (maintainer-friendly)',
23
39
  },
24
40
  {
25
41
  name: 'uninstall',
@@ -42,6 +58,7 @@ export function getHappysRegistry() {
42
58
  scriptRelPath: 'scripts/install.mjs',
43
59
  rootUsage: 'happys bootstrap [-- ...]',
44
60
  description: 'Clone/install components and deps',
61
+ hidden: true,
45
62
  },
46
63
  {
47
64
  name: 'start',
@@ -57,6 +74,13 @@ export function getHappysRegistry() {
57
74
  rootUsage: 'happys dev [-- ...]',
58
75
  description: 'Start local stack (dev)',
59
76
  },
77
+ {
78
+ name: 'stop',
79
+ kind: 'node',
80
+ scriptRelPath: 'scripts/stop.mjs',
81
+ rootUsage: 'happys stop [--except-stacks=main,exp1] [--yes] [--aggressive] [--no-docker] [--no-service] [--json]',
82
+ description: 'Stop stacks and related local processes',
83
+ },
60
84
  {
61
85
  name: 'build',
62
86
  kind: 'node',
@@ -64,6 +88,42 @@ export function getHappysRegistry() {
64
88
  rootUsage: 'happys build [-- ...]',
65
89
  description: 'Build UI bundle',
66
90
  },
91
+ {
92
+ name: 'lint',
93
+ kind: 'node',
94
+ scriptRelPath: 'scripts/lint.mjs',
95
+ rootUsage: 'happys lint [component...] [--json]',
96
+ description: 'Run linters for components',
97
+ },
98
+ {
99
+ name: 'typecheck',
100
+ aliases: ['type-check', 'check-types'],
101
+ kind: 'node',
102
+ scriptRelPath: 'scripts/typecheck.mjs',
103
+ rootUsage: 'happys typecheck [component...] [--json]',
104
+ description: 'Run TypeScript typechecks for components',
105
+ },
106
+ {
107
+ name: 'test',
108
+ kind: 'node',
109
+ scriptRelPath: 'scripts/test.mjs',
110
+ rootUsage: 'happys test [component...] [--json]',
111
+ description: 'Run tests for components',
112
+ },
113
+ {
114
+ name: 'edison',
115
+ kind: 'node',
116
+ scriptRelPath: 'scripts/edison.mjs',
117
+ rootUsage: 'happys edison [--stack=<name>] -- <edison args...>',
118
+ description: 'Run Edison with Happy Stacks integration',
119
+ },
120
+ {
121
+ name: 'migrate',
122
+ kind: 'node',
123
+ scriptRelPath: 'scripts/migrate.mjs',
124
+ rootUsage: 'happys migrate light-to-server --from-stack=<name> --to-stack=<name> [--include-files] [--force] [--json]',
125
+ description: 'Migrate data between server flavors (experimental)',
126
+ },
67
127
  {
68
128
  name: 'mobile',
69
129
  kind: 'node',
@@ -78,6 +138,13 @@ export function getHappysRegistry() {
78
138
  rootUsage: 'happys doctor [--fix] [--json]',
79
139
  description: 'Diagnose/fix local setup',
80
140
  },
141
+ {
142
+ name: 'tui',
143
+ kind: 'node',
144
+ scriptRelPath: 'scripts/tui.mjs',
145
+ rootUsage: 'happys tui <happys args...> [--json]',
146
+ description: 'Run happys commands in a split-pane TUI',
147
+ },
81
148
  {
82
149
  name: 'self',
83
150
  kind: 'node',
@@ -250,6 +317,9 @@ export function renderHappysRootHelp() {
250
317
  return [
251
318
  'happys - Happy Stacks CLI',
252
319
  '',
320
+ 'global flags:',
321
+ ' --sandbox-dir PATH Run fully isolated under PATH (no writes to your real ~/.happy-stacks or ~/.happy/stacks)',
322
+ '',
253
323
  'usage:',
254
324
  ...usageLines.map((l) => ` ${l}`),
255
325
  '',
@@ -260,3 +330,4 @@ export function renderHappysRootHelp() {
260
330
  ' happys help [command]',
261
331
  ].join('\n');
262
332
  }
333
+
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
- import { listWorktreeSpecs } from './worktrees.mjs';
2
+ import { listWorktreeSpecs } from '../worktrees.mjs';
3
3
 
4
4
  export function isTty() {
5
5
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);