happy-stacks 0.0.0 → 0.1.2
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 +22 -4
- package/bin/happys.mjs +76 -5
- package/docs/server-flavors.md +61 -2
- package/docs/stacks.md +16 -4
- package/extras/swiftbar/auth-login.sh +5 -5
- package/extras/swiftbar/happy-stacks.5s.sh +83 -41
- package/extras/swiftbar/happys-term.sh +151 -0
- package/extras/swiftbar/happys.sh +52 -0
- package/extras/swiftbar/lib/render.sh +74 -56
- package/extras/swiftbar/lib/system.sh +37 -6
- package/extras/swiftbar/lib/utils.sh +180 -4
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +2 -13
- package/extras/swiftbar/set-server-flavor.sh +8 -8
- package/extras/swiftbar/wt-pr.sh +1 -1
- package/package.json +1 -1
- package/scripts/auth.mjs +374 -3
- package/scripts/daemon.mjs +78 -11
- package/scripts/dev.mjs +122 -17
- package/scripts/init.mjs +238 -32
- package/scripts/migrate.mjs +292 -0
- package/scripts/mobile.mjs +51 -19
- package/scripts/run.mjs +118 -26
- package/scripts/service.mjs +176 -37
- package/scripts/stack.mjs +665 -22
- package/scripts/stop.mjs +157 -0
- package/scripts/tailscale.mjs +147 -21
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/utils/cli_registry.mjs +23 -0
- package/scripts/utils/config.mjs +9 -1
- package/scripts/utils/env.mjs +37 -15
- package/scripts/utils/expo.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +430 -0
- package/scripts/utils/pm.mjs +11 -2
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +46 -5
- package/scripts/utils/server.mjs +37 -0
- package/scripts/utils/stack_stop.mjs +206 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/worktrees.mjs +53 -7
package/scripts/stop.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import './utils/env.mjs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from './utils/args.mjs';
|
|
6
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
7
|
+
import { run, runCapture } from './utils/proc.mjs';
|
|
8
|
+
import { getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
|
|
9
|
+
|
|
10
|
+
function usage() {
|
|
11
|
+
return [
|
|
12
|
+
'[stop] usage:',
|
|
13
|
+
' happys stop [--except-stacks=main,exp1] [--yes] [--aggressive] [--no-docker] [--no-service] [--json]',
|
|
14
|
+
'',
|
|
15
|
+
'Stops stacks and related local processes (server, daemon, Expo, managed infra) using stack-scoped commands.',
|
|
16
|
+
'',
|
|
17
|
+
'Examples:',
|
|
18
|
+
' happys stop --except-stacks=main --yes',
|
|
19
|
+
' happys stop --yes --no-docker',
|
|
20
|
+
' happys stop --except-stacks=main --yes --aggressive',
|
|
21
|
+
].join('\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseCsv(raw) {
|
|
25
|
+
const s = String(raw ?? '').trim();
|
|
26
|
+
if (!s) return [];
|
|
27
|
+
return s
|
|
28
|
+
.split(',')
|
|
29
|
+
.map((p) => p.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function listAllStackNames() {
|
|
34
|
+
try {
|
|
35
|
+
// Reuse stack.mjs for enumeration (avoids duplicating legacy/new stack dir logic).
|
|
36
|
+
// Note: `stack list` intentionally omits `main`, so we add it back.
|
|
37
|
+
const rootDir = getRootDir(import.meta.url);
|
|
38
|
+
const out = await runCapture(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'list', '--json'], { cwd: rootDir });
|
|
39
|
+
const parsed = JSON.parse(out);
|
|
40
|
+
const stacks = Array.isArray(parsed?.stacks) ? parsed.stacks : [];
|
|
41
|
+
const all = ['main', ...stacks.filter((s) => s !== 'main')];
|
|
42
|
+
return all.sort();
|
|
43
|
+
} catch {
|
|
44
|
+
return ['main'];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
const rootDir = getRootDir(import.meta.url);
|
|
50
|
+
const argv = process.argv.slice(2);
|
|
51
|
+
const { flags, kv } = parseArgs(argv);
|
|
52
|
+
const json = wantsJson(argv, { flags });
|
|
53
|
+
|
|
54
|
+
if (wantsHelp(argv, { flags })) {
|
|
55
|
+
printResult({ json, data: { ok: true }, text: usage() });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const exceptStacks = new Set(parseCsv(kv.get('--except-stacks')));
|
|
60
|
+
const yes = flags.has('--yes');
|
|
61
|
+
const aggressive = flags.has('--aggressive');
|
|
62
|
+
const noDocker = flags.has('--no-docker');
|
|
63
|
+
const noService = flags.has('--no-service');
|
|
64
|
+
|
|
65
|
+
const stacks = await listAllStackNames();
|
|
66
|
+
const targets = stacks.filter((n) => !exceptStacks.has(n));
|
|
67
|
+
|
|
68
|
+
if (!targets.length) {
|
|
69
|
+
printResult({ json, data: { ok: true, stopped: [], skipped: stacks }, text: '[stop] nothing to do (all stacks excluded)' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!yes && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
74
|
+
throw new Error('[stop] refusing to stop stacks without --yes in non-interactive mode');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!yes) {
|
|
78
|
+
// Simple confirm prompt (avoid importing wizard/rl here).
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.log(`[stop] will stop stacks: ${targets.join(', ')}`);
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.log('[stop] re-run with --yes to proceed');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const results = [];
|
|
87
|
+
const errors = [];
|
|
88
|
+
const skipped = [];
|
|
89
|
+
|
|
90
|
+
for (const stackName of targets) {
|
|
91
|
+
if (stackName !== 'main') {
|
|
92
|
+
const { envPath } = resolveStackEnvPath(stackName);
|
|
93
|
+
// Stack name might appear in directory listings, but if it has no env file, treat it as non-existent.
|
|
94
|
+
if (!existsSync(envPath)) {
|
|
95
|
+
skipped.push({ stackName, reason: 'missing_env', envPath });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (!noService) {
|
|
101
|
+
// Best-effort: stop autostart service for the stack so it doesn't restart what we just stopped.
|
|
102
|
+
// eslint-disable-next-line no-await-in-loop
|
|
103
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'service', stackName, 'stop'], { cwd: rootDir }).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const args = [
|
|
107
|
+
join(rootDir, 'scripts', 'stack.mjs'),
|
|
108
|
+
'stop',
|
|
109
|
+
stackName,
|
|
110
|
+
...(aggressive ? ['--aggressive'] : []),
|
|
111
|
+
...(noDocker ? ['--no-docker'] : []),
|
|
112
|
+
];
|
|
113
|
+
if (json) {
|
|
114
|
+
// eslint-disable-next-line no-await-in-loop
|
|
115
|
+
const out = await runCapture(process.execPath, [...args, '--json'], { cwd: rootDir });
|
|
116
|
+
results.push({ stackName, out: JSON.parse(out) });
|
|
117
|
+
} else {
|
|
118
|
+
// eslint-disable-next-line no-await-in-loop
|
|
119
|
+
await run(process.execPath, args, { cwd: rootDir });
|
|
120
|
+
results.push({ stackName, ok: true });
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
errors.push({ stackName, error: e instanceof Error ? e.message : String(e) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (json) {
|
|
128
|
+
printResult({
|
|
129
|
+
json,
|
|
130
|
+
data: { ok: errors.length === 0, stopped: results, skipped, errors, exceptStacks: Array.from(exceptStacks) },
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// eslint-disable-next-line no-console
|
|
136
|
+
console.log(
|
|
137
|
+
`[stop] done (stopped=${results.length}${skipped.length ? ` skipped=${skipped.length}` : ''}${errors.length ? ` errors=${errors.length}` : ''})`
|
|
138
|
+
);
|
|
139
|
+
if (skipped.length) {
|
|
140
|
+
for (const s of skipped) {
|
|
141
|
+
// eslint-disable-next-line no-console
|
|
142
|
+
console.log(`[stop] skipped (${s.stackName}): ${s.reason}${s.envPath ? ` (${s.envPath})` : ''}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (errors.length) {
|
|
146
|
+
for (const e of errors) {
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.warn(`[stop] error (${e.stackName}): ${e.error}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main().catch((err) => {
|
|
154
|
+
console.error('[stop] failed:', err);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
});
|
|
157
|
+
|
package/scripts/tailscale.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import './utils/env.mjs';
|
|
|
2
2
|
import { parseArgs } from './utils/args.mjs';
|
|
3
3
|
import { run, runCapture } from './utils/proc.mjs';
|
|
4
4
|
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import { access } from 'node:fs/promises';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Manage Tailscale Serve for exposing the local UI/API over HTTPS (secure context).
|
|
@@ -43,34 +45,123 @@ function extractHttpsUrl(serveStatusText) {
|
|
|
43
45
|
return m ? m[0] : null;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function tailscaleStatusMatchesInternalServerUrl(status, internalServerUrl) {
|
|
49
|
+
const raw = (internalServerUrl ?? '').trim();
|
|
50
|
+
if (!raw) return true;
|
|
51
|
+
|
|
52
|
+
// Fast path.
|
|
53
|
+
if (status.includes(raw)) return true;
|
|
54
|
+
|
|
55
|
+
// Tailscale typically prints proxy targets like:
|
|
56
|
+
// |-- / proxy http://127.0.0.1:3005
|
|
57
|
+
let port = '';
|
|
58
|
+
try {
|
|
59
|
+
port = new URL(raw).port;
|
|
60
|
+
} catch {
|
|
61
|
+
port = '';
|
|
62
|
+
}
|
|
63
|
+
if (!port) return false;
|
|
64
|
+
|
|
65
|
+
const re = new RegExp(String.raw`\\bproxy\\s+https?:\\/\\/(?:127\\.0\\.0\\.1|localhost|0\\.0\\.0\\.0):${port}\\b`, 'i');
|
|
66
|
+
return re.test(status);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl) {
|
|
70
|
+
try {
|
|
71
|
+
const status = await tailscaleServeStatus();
|
|
72
|
+
const https = extractHttpsUrl(status);
|
|
73
|
+
if (!https) return null;
|
|
74
|
+
return tailscaleStatusMatchesInternalServerUrl(status, internalServerUrl) ? https : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractServeEnableUrl(text) {
|
|
81
|
+
const m = String(text ?? '').match(/https:\/\/login\.tailscale\.com\/f\/serve\?node=\S+/i);
|
|
82
|
+
return m ? m[0] : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseTimeoutMs(raw, defaultMs) {
|
|
86
|
+
const s = (raw ?? '').trim();
|
|
87
|
+
if (!s) return defaultMs;
|
|
88
|
+
const n = Number(s);
|
|
89
|
+
// Allow 0 to disable timeouts for user-triggered commands.
|
|
90
|
+
if (!Number.isFinite(n)) return defaultMs;
|
|
91
|
+
return n > 0 ? n : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tailscaleProbeTimeoutMs() {
|
|
95
|
+
return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_CMD_TIMEOUT_MS, 2500);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tailscaleUserEnableTimeoutMs() {
|
|
99
|
+
return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_ENABLE_TIMEOUT_MS, 30000);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tailscaleAutoEnableTimeoutMs() {
|
|
103
|
+
return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_ENABLE_TIMEOUT_MS_AUTO, tailscaleProbeTimeoutMs());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tailscaleUserResetTimeoutMs() {
|
|
107
|
+
return parseTimeoutMs(process.env.HAPPY_LOCAL_TAILSCALE_RESET_TIMEOUT_MS, 15000);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function tailscaleEnv() {
|
|
111
|
+
// LaunchAgents inherit `XPC_SERVICE_NAME`, which can confuse some CLI tools.
|
|
112
|
+
// In practice, we’ve seen Tailscale commands like `tailscale version` hang under
|
|
113
|
+
// this env. Strip it for any tailscale subprocesses.
|
|
114
|
+
const env = { ...process.env };
|
|
115
|
+
delete env.XPC_SERVICE_NAME;
|
|
116
|
+
return env;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function isExecutable(path) {
|
|
120
|
+
try {
|
|
121
|
+
await access(path, constants.X_OK);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
46
128
|
async function resolveTailscaleCmd() {
|
|
47
129
|
// Allow explicit override (useful for LaunchAgents where aliases don't exist).
|
|
48
130
|
if (process.env.HAPPY_LOCAL_TAILSCALE_BIN?.trim()) {
|
|
49
131
|
return process.env.HAPPY_LOCAL_TAILSCALE_BIN.trim();
|
|
50
132
|
}
|
|
51
133
|
|
|
52
|
-
// Try PATH first.
|
|
134
|
+
// Try PATH first (without executing `tailscale`, which can hang in some environments).
|
|
53
135
|
try {
|
|
54
|
-
await runCapture('
|
|
55
|
-
|
|
136
|
+
const found = (await runCapture('sh', ['-lc', 'command -v tailscale 2>/dev/null || true'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() })).trim();
|
|
137
|
+
if (found) {
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
56
140
|
} catch {
|
|
57
|
-
// fall
|
|
141
|
+
// ignore and fall back
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Common macOS app install paths.
|
|
145
|
+
//
|
|
146
|
+
// IMPORTANT:
|
|
147
|
+
// Prefer the lowercase `tailscale` CLI inside the app bundle. The capitalized
|
|
148
|
+
// `Tailscale` binary can behave differently under LaunchAgents (XPC env),
|
|
149
|
+
// potentially hanging instead of printing a version and exiting.
|
|
150
|
+
const appCliPath = '/Applications/Tailscale.app/Contents/MacOS/tailscale';
|
|
151
|
+
if (await isExecutable(appCliPath)) {
|
|
152
|
+
return appCliPath;
|
|
58
153
|
}
|
|
59
154
|
|
|
60
|
-
// Common macOS app install path.
|
|
61
155
|
const appPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
62
|
-
|
|
63
|
-
await runCapture(appPath, ['version']);
|
|
156
|
+
if (await isExecutable(appPath)) {
|
|
64
157
|
return appPath;
|
|
65
|
-
} catch {
|
|
66
|
-
// fall through
|
|
67
158
|
}
|
|
68
159
|
|
|
69
160
|
throw new Error(
|
|
70
161
|
`[local] tailscale CLI not found.\n` +
|
|
71
162
|
`- Install Tailscale, or\n` +
|
|
72
163
|
`- Put 'tailscale' on PATH, or\n` +
|
|
73
|
-
`- Set HAPPY_LOCAL_TAILSCALE_BIN="${
|
|
164
|
+
`- Set HAPPY_LOCAL_TAILSCALE_BIN="${appCliPath}"`
|
|
74
165
|
);
|
|
75
166
|
}
|
|
76
167
|
|
|
@@ -85,10 +176,10 @@ export async function tailscaleServeHttpsUrl() {
|
|
|
85
176
|
|
|
86
177
|
export async function tailscaleServeStatus() {
|
|
87
178
|
const cmd = await resolveTailscaleCmd();
|
|
88
|
-
return await runCapture(cmd, ['serve', 'status']);
|
|
179
|
+
return await runCapture(cmd, ['serve', 'status'], { env: tailscaleEnv(), timeoutMs: tailscaleProbeTimeoutMs() });
|
|
89
180
|
}
|
|
90
181
|
|
|
91
|
-
export async function tailscaleServeEnable({ internalServerUrl }) {
|
|
182
|
+
export async function tailscaleServeEnable({ internalServerUrl, timeoutMs } = {}) {
|
|
92
183
|
const cmd = await resolveTailscaleCmd();
|
|
93
184
|
const { upstream, servePath } = getServeConfig(internalServerUrl);
|
|
94
185
|
const args = ['serve', '--bg'];
|
|
@@ -96,14 +187,39 @@ export async function tailscaleServeEnable({ internalServerUrl }) {
|
|
|
96
187
|
args.push(`--set-path=${servePath}`);
|
|
97
188
|
}
|
|
98
189
|
args.push(upstream);
|
|
99
|
-
|
|
100
|
-
const
|
|
190
|
+
const env = tailscaleEnv();
|
|
191
|
+
const timeout = Number.isFinite(timeoutMs) ? (timeoutMs > 0 ? timeoutMs : 0) : tailscaleUserEnableTimeoutMs();
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// `tailscale serve --bg` can hang in some environments (and should never block stack startup).
|
|
195
|
+
// Use a short, best-effort timeout; if it prints an enable URL, open it and return a helpful result.
|
|
196
|
+
await runCapture(cmd, args, { env, timeoutMs: timeout });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const out = e && typeof e === 'object' && 'out' in e ? e.out : '';
|
|
199
|
+
const err = e && typeof e === 'object' && 'err' in e ? e.err : '';
|
|
200
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
201
|
+
const combined = `${out ?? ''}\n${err ?? ''}\n${msg ?? ''}`.trim();
|
|
202
|
+
const enableUrl = extractServeEnableUrl(combined);
|
|
203
|
+
if (enableUrl) {
|
|
204
|
+
// User-initiated action (CLI / menubar): open the enable page.
|
|
205
|
+
try {
|
|
206
|
+
await run('open', [enableUrl]);
|
|
207
|
+
} catch {
|
|
208
|
+
// ignore (headless / restricted environment)
|
|
209
|
+
}
|
|
210
|
+
return { status: combined || String(e), httpsUrl: null, enableUrl };
|
|
211
|
+
}
|
|
212
|
+
throw e;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const status = await runCapture(cmd, ['serve', 'status'], { env, timeoutMs: tailscaleProbeTimeoutMs() }).catch(() => '');
|
|
101
216
|
return { status, httpsUrl: status ? extractHttpsUrl(status) : null };
|
|
102
217
|
}
|
|
103
218
|
|
|
104
|
-
export async function tailscaleServeReset() {
|
|
219
|
+
export async function tailscaleServeReset({ timeoutMs } = {}) {
|
|
105
220
|
const cmd = await resolveTailscaleCmd();
|
|
106
|
-
|
|
221
|
+
const timeout = Number.isFinite(timeoutMs) ? (timeoutMs > 0 ? timeoutMs : 0) : tailscaleUserResetTimeoutMs();
|
|
222
|
+
await run(cmd, ['serve', 'reset'], { env: tailscaleEnv(), timeoutMs: timeout });
|
|
107
223
|
}
|
|
108
224
|
|
|
109
225
|
export async function maybeEnableTailscaleServe({ internalServerUrl }) {
|
|
@@ -112,7 +228,8 @@ export async function maybeEnableTailscaleServe({ internalServerUrl }) {
|
|
|
112
228
|
return null;
|
|
113
229
|
}
|
|
114
230
|
try {
|
|
115
|
-
|
|
231
|
+
// This is called from automation; it must not hang for long.
|
|
232
|
+
return await tailscaleServeEnable({ internalServerUrl, timeoutMs: tailscaleAutoEnableTimeoutMs() });
|
|
116
233
|
} catch (e) {
|
|
117
234
|
throw new Error(`[local] failed to enable tailscale serve (is Tailscale running/authenticated?): ${e instanceof Error ? e.message : String(e)}`);
|
|
118
235
|
}
|
|
@@ -125,7 +242,8 @@ export async function maybeResetTailscaleServe() {
|
|
|
125
242
|
return;
|
|
126
243
|
}
|
|
127
244
|
try {
|
|
128
|
-
|
|
245
|
+
// Shutdown path: never block for long.
|
|
246
|
+
await tailscaleServeReset({ timeoutMs: tailscaleProbeTimeoutMs() });
|
|
129
247
|
} catch {
|
|
130
248
|
// ignore
|
|
131
249
|
}
|
|
@@ -160,7 +278,7 @@ export async function resolvePublicServerUrl({
|
|
|
160
278
|
}
|
|
161
279
|
|
|
162
280
|
// If serve is already configured, use its HTTPS URL if present.
|
|
163
|
-
const existing = await
|
|
281
|
+
const existing = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
|
|
164
282
|
if (existing) {
|
|
165
283
|
return { publicServerUrl: existing, source: 'tailscale-status' };
|
|
166
284
|
}
|
|
@@ -172,7 +290,7 @@ export async function resolvePublicServerUrl({
|
|
|
172
290
|
|
|
173
291
|
// Try enabling serve (best-effort); then wait a bit for Tailscale to be ready/configured.
|
|
174
292
|
try {
|
|
175
|
-
const res = await tailscaleServeEnable({ internalServerUrl });
|
|
293
|
+
const res = await tailscaleServeEnable({ internalServerUrl, timeoutMs: tailscaleAutoEnableTimeoutMs() });
|
|
176
294
|
if (res?.httpsUrl) {
|
|
177
295
|
return { publicServerUrl: res.httpsUrl, source: 'tailscale-enable' };
|
|
178
296
|
}
|
|
@@ -185,7 +303,7 @@ export async function resolvePublicServerUrl({
|
|
|
185
303
|
: 15000;
|
|
186
304
|
const deadline = Date.now() + (Number.isFinite(waitMs) ? waitMs : 15000);
|
|
187
305
|
while (Date.now() < deadline) {
|
|
188
|
-
const url = await
|
|
306
|
+
const url = await tailscaleServeHttpsUrlForInternalServerUrl(internalServerUrl);
|
|
189
307
|
if (url) {
|
|
190
308
|
return { publicServerUrl: url, source: 'tailscale-wait' };
|
|
191
309
|
}
|
|
@@ -252,6 +370,14 @@ async function main() {
|
|
|
252
370
|
}
|
|
253
371
|
case 'enable': {
|
|
254
372
|
const res = await tailscaleServeEnable({ internalServerUrl });
|
|
373
|
+
if (res?.enableUrl && !res?.httpsUrl) {
|
|
374
|
+
printResult({
|
|
375
|
+
json,
|
|
376
|
+
data: { ok: true, httpsUrl: null, enableUrl: res.enableUrl },
|
|
377
|
+
text: `[local] tailscale serve is not enabled for this tailnet. Opened:\n${res.enableUrl}`,
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
255
381
|
printResult({
|
|
256
382
|
json,
|
|
257
383
|
data: { ok: true, httpsUrl: res.httpsUrl ?? null },
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import './utils/env.mjs';
|
|
2
|
+
import { parseArgs } from './utils/args.mjs';
|
|
3
|
+
import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
|
|
4
|
+
import { getComponentDir, getRootDir } from './utils/paths.mjs';
|
|
5
|
+
import { ensureDepsInstalled, requirePnpm } from './utils/pm.mjs';
|
|
6
|
+
import { pathExists } from './utils/fs.mjs';
|
|
7
|
+
import { run } from './utils/proc.mjs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { readFile } from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
12
|
+
|
|
13
|
+
async function detectPackageManagerCmd(dir) {
|
|
14
|
+
if (await pathExists(join(dir, 'yarn.lock'))) {
|
|
15
|
+
return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
|
|
16
|
+
}
|
|
17
|
+
await requirePnpm();
|
|
18
|
+
return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readScripts(dir) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(join(dir, 'package.json'), 'utf-8');
|
|
24
|
+
const pkg = JSON.parse(raw);
|
|
25
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
26
|
+
return scripts;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pickTypecheckScript(scripts) {
|
|
33
|
+
if (!scripts) return null;
|
|
34
|
+
const candidates = [
|
|
35
|
+
'typecheck',
|
|
36
|
+
'type-check',
|
|
37
|
+
'check-types',
|
|
38
|
+
'check:types',
|
|
39
|
+
'tsc',
|
|
40
|
+
'typescript',
|
|
41
|
+
];
|
|
42
|
+
return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
const argv = process.argv.slice(2);
|
|
47
|
+
const { flags } = parseArgs(argv);
|
|
48
|
+
const json = wantsJson(argv, { flags });
|
|
49
|
+
|
|
50
|
+
if (wantsHelp(argv, { flags })) {
|
|
51
|
+
printResult({
|
|
52
|
+
json,
|
|
53
|
+
data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
|
|
54
|
+
text: [
|
|
55
|
+
'[typecheck] usage:',
|
|
56
|
+
' happys typecheck [component...] [--json]',
|
|
57
|
+
'',
|
|
58
|
+
'components:',
|
|
59
|
+
` ${DEFAULT_COMPONENTS.join(' | ')}`,
|
|
60
|
+
'',
|
|
61
|
+
'examples:',
|
|
62
|
+
' happys typecheck',
|
|
63
|
+
' happys typecheck happy happy-cli',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
70
|
+
const requested = positionals.length ? positionals : ['all'];
|
|
71
|
+
const wantAll = requested.includes('all');
|
|
72
|
+
const components = wantAll ? DEFAULT_COMPONENTS : requested;
|
|
73
|
+
|
|
74
|
+
const rootDir = getRootDir(import.meta.url);
|
|
75
|
+
|
|
76
|
+
const results = [];
|
|
77
|
+
for (const component of components) {
|
|
78
|
+
if (!DEFAULT_COMPONENTS.includes(component)) {
|
|
79
|
+
results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${DEFAULT_COMPONENTS.join(', ')})` });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const dir = getComponentDir(rootDir, component);
|
|
84
|
+
if (!(await pathExists(dir))) {
|
|
85
|
+
results.push({ component, ok: false, skipped: false, dir, error: `missing component dir: ${dir}` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const scripts = await readScripts(dir);
|
|
90
|
+
if (!scripts) {
|
|
91
|
+
results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const script = pickTypecheckScript(scripts);
|
|
96
|
+
if (!script) {
|
|
97
|
+
results.push({ component, ok: true, skipped: true, dir, reason: 'no typecheck script found in package.json' });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await ensureDepsInstalled(dir, component);
|
|
102
|
+
const pm = await detectPackageManagerCmd(dir);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log(`[typecheck] ${component}: running ${pm.name} ${script}`);
|
|
107
|
+
await run(pm.cmd, pm.argsForScript(script), { cwd: dir, env: process.env });
|
|
108
|
+
results.push({ component, ok: true, skipped: false, dir, pm: pm.name, script });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
results.push({ component, ok: false, skipped: false, dir, pm: pm.name, script, error: String(e?.message ?? e) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ok = results.every((r) => r.ok);
|
|
115
|
+
if (json) {
|
|
116
|
+
printResult({ json, data: { ok, results } });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = ['[typecheck] results:'];
|
|
121
|
+
for (const r of results) {
|
|
122
|
+
if (r.ok && r.skipped) {
|
|
123
|
+
lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
|
|
124
|
+
} else if (r.ok) {
|
|
125
|
+
lines.push(`- ✅ ${r.component}: ok (${r.pm} ${r.script})`);
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(`- ❌ ${r.component}: failed (${r.pm ?? 'unknown'} ${r.script ?? ''})`);
|
|
128
|
+
if (r.error) lines.push(` - ${r.error}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!ok) {
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('[typecheck] failed');
|
|
134
|
+
}
|
|
135
|
+
printResult({ json: false, text: lines.join('\n') });
|
|
136
|
+
if (!ok) {
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main().catch((err) => {
|
|
142
|
+
console.error('[typecheck] failed:', err);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
|
145
|
+
|