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,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
|
+
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
+
|