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,278 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { run, runCapture } from './utils/proc.mjs';
4
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
5
+
6
+ /**
7
+ * Manage Tailscale Serve for exposing the local UI/API over HTTPS (secure context).
8
+ *
9
+ * This wraps:
10
+ * - `tailscale serve --bg http://127.0.0.1:3005`
11
+ * - `tailscale serve status`
12
+ * - `tailscale serve reset`
13
+ *
14
+ * Commands:
15
+ * - status
16
+ * - enable
17
+ * - disable (alias: reset)
18
+ * - url (print the first https:// URL from status output)
19
+ */
20
+
21
+ function getInternalServerUrl() {
22
+ const port = process.env.HAPPY_LOCAL_SERVER_PORT?.trim() ? Number(process.env.HAPPY_LOCAL_SERVER_PORT) : 3005;
23
+ return `http://127.0.0.1:${port}`;
24
+ }
25
+
26
+ function getServeConfig(internalServerUrl) {
27
+ const upstream = process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM?.trim()
28
+ ? process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM.trim()
29
+ : internalServerUrl;
30
+ const servePath = process.env.HAPPY_LOCAL_TAILSCALE_SERVE_PATH?.trim()
31
+ ? process.env.HAPPY_LOCAL_TAILSCALE_SERVE_PATH.trim()
32
+ : '/';
33
+ return { upstream, servePath };
34
+ }
35
+
36
+ function extractHttpsUrl(serveStatusText) {
37
+ const line = serveStatusText
38
+ .split('\n')
39
+ .map((l) => l.trim())
40
+ .find((l) => l.toLowerCase().includes('https://'));
41
+ if (!line) return null;
42
+ const m = line.match(/https:\/\/\S+/i);
43
+ return m ? m[0] : null;
44
+ }
45
+
46
+ async function resolveTailscaleCmd() {
47
+ // Allow explicit override (useful for LaunchAgents where aliases don't exist).
48
+ if (process.env.HAPPY_LOCAL_TAILSCALE_BIN?.trim()) {
49
+ return process.env.HAPPY_LOCAL_TAILSCALE_BIN.trim();
50
+ }
51
+
52
+ // Try PATH first.
53
+ try {
54
+ await runCapture('tailscale', ['version']);
55
+ return 'tailscale';
56
+ } catch {
57
+ // fall through
58
+ }
59
+
60
+ // Common macOS app install path.
61
+ const appPath = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
62
+ try {
63
+ await runCapture(appPath, ['version']);
64
+ return appPath;
65
+ } catch {
66
+ // fall through
67
+ }
68
+
69
+ throw new Error(
70
+ `[local] tailscale CLI not found.\n` +
71
+ `- Install Tailscale, or\n` +
72
+ `- Put 'tailscale' on PATH, or\n` +
73
+ `- Set HAPPY_LOCAL_TAILSCALE_BIN="${appPath}"`
74
+ );
75
+ }
76
+
77
+ export async function tailscaleServeHttpsUrl() {
78
+ try {
79
+ const status = await tailscaleServeStatus();
80
+ return extractHttpsUrl(status);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export async function tailscaleServeStatus() {
87
+ const cmd = await resolveTailscaleCmd();
88
+ return await runCapture(cmd, ['serve', 'status']);
89
+ }
90
+
91
+ export async function tailscaleServeEnable({ internalServerUrl }) {
92
+ const cmd = await resolveTailscaleCmd();
93
+ const { upstream, servePath } = getServeConfig(internalServerUrl);
94
+ const args = ['serve', '--bg'];
95
+ if (servePath && servePath !== '/' && servePath !== '') {
96
+ args.push(`--set-path=${servePath}`);
97
+ }
98
+ args.push(upstream);
99
+ await run(cmd, args);
100
+ const status = await runCapture(cmd, ['serve', 'status']).catch(() => '');
101
+ return { status, httpsUrl: status ? extractHttpsUrl(status) : null };
102
+ }
103
+
104
+ export async function tailscaleServeReset() {
105
+ const cmd = await resolveTailscaleCmd();
106
+ await run(cmd, ['serve', 'reset']);
107
+ }
108
+
109
+ export async function maybeEnableTailscaleServe({ internalServerUrl }) {
110
+ const enabled = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
111
+ if (!enabled) {
112
+ return null;
113
+ }
114
+ try {
115
+ return await tailscaleServeEnable({ internalServerUrl });
116
+ } catch (e) {
117
+ throw new Error(`[local] failed to enable tailscale serve (is Tailscale running/authenticated?): ${e instanceof Error ? e.message : String(e)}`);
118
+ }
119
+ }
120
+
121
+ export async function maybeResetTailscaleServe() {
122
+ const enabled = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
123
+ const resetOnExit = (process.env.HAPPY_LOCAL_TAILSCALE_RESET_ON_EXIT ?? '0') === '1';
124
+ if (!enabled || !resetOnExit) {
125
+ return;
126
+ }
127
+ try {
128
+ await tailscaleServeReset();
129
+ } catch {
130
+ // ignore
131
+ }
132
+ }
133
+
134
+ async function sleep(ms) {
135
+ await new Promise((r) => setTimeout(r, ms));
136
+ }
137
+
138
+ /**
139
+ * Resolve the best public server URL to present to users / generate links.
140
+ *
141
+ * Priority:
142
+ * 1) explicit HAPPY_LOCAL_SERVER_URL override (if non-default)
143
+ * 2) if enabled, prefer existing https://*.ts.net from tailscale serve status
144
+ * 3) fallback to defaultPublicUrl
145
+ *
146
+ * If HAPPY_LOCAL_TAILSCALE_SERVE=1, this can also try to enable serve and wait briefly for Tailscale to come up.
147
+ */
148
+ export async function resolvePublicServerUrl({
149
+ internalServerUrl,
150
+ defaultPublicUrl,
151
+ envPublicUrl,
152
+ allowEnable = true,
153
+ }) {
154
+ const preferTailscalePublicUrl = (process.env.HAPPY_LOCAL_TAILSCALE_PREFER_PUBLIC_URL ?? '1') !== '0';
155
+ const userExplicitlySetPublicUrl =
156
+ !!envPublicUrl && envPublicUrl !== defaultPublicUrl && envPublicUrl !== internalServerUrl;
157
+
158
+ if (userExplicitlySetPublicUrl || !preferTailscalePublicUrl) {
159
+ return { publicServerUrl: envPublicUrl || defaultPublicUrl, source: 'env' };
160
+ }
161
+
162
+ // If serve is already configured, use its HTTPS URL if present.
163
+ const existing = await tailscaleServeHttpsUrl();
164
+ if (existing) {
165
+ return { publicServerUrl: existing, source: 'tailscale-status' };
166
+ }
167
+
168
+ const enableServe = (process.env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0') === '1';
169
+ if (!enableServe || !allowEnable) {
170
+ return { publicServerUrl: envPublicUrl || defaultPublicUrl, source: 'default' };
171
+ }
172
+
173
+ // Try enabling serve (best-effort); then wait a bit for Tailscale to be ready/configured.
174
+ try {
175
+ const res = await tailscaleServeEnable({ internalServerUrl });
176
+ if (res?.httpsUrl) {
177
+ return { publicServerUrl: res.httpsUrl, source: 'tailscale-enable' };
178
+ }
179
+ } catch {
180
+ // ignore and fall back to waiting/polling
181
+ }
182
+
183
+ const waitMs = process.env.HAPPY_LOCAL_TAILSCALE_WAIT_MS?.trim()
184
+ ? Number(process.env.HAPPY_LOCAL_TAILSCALE_WAIT_MS.trim())
185
+ : 15000;
186
+ const deadline = Date.now() + (Number.isFinite(waitMs) ? waitMs : 15000);
187
+ while (Date.now() < deadline) {
188
+ const url = await tailscaleServeHttpsUrl();
189
+ if (url) {
190
+ return { publicServerUrl: url, source: 'tailscale-wait' };
191
+ }
192
+ await sleep(500);
193
+ }
194
+
195
+ return { publicServerUrl: envPublicUrl || defaultPublicUrl, source: 'default' };
196
+ }
197
+
198
+ async function main() {
199
+ const argv = process.argv.slice(2);
200
+ const { flags, kv } = parseArgs(argv);
201
+ const positionals = argv.filter((a) => !a.startsWith('--'));
202
+ const cmd = positionals[0] ?? 'help';
203
+ const json = wantsJson(argv, { flags });
204
+
205
+ if (wantsHelp(argv, { flags }) || cmd === 'help') {
206
+ printResult({
207
+ json,
208
+ data: { commands: ['status', 'enable', 'disable', 'reset', 'url'] },
209
+ text: [
210
+ '[tailscale] usage:',
211
+ ' happys tailscale status [--json]',
212
+ ' happys tailscale enable [--json]',
213
+ ' happys tailscale disable [--json]',
214
+ ' happys tailscale url [--json]',
215
+ '',
216
+ 'legacy (cloned repo):',
217
+ ' pnpm tailscale:status [--json]',
218
+ '',
219
+ 'advanced:',
220
+ ' node scripts/tailscale.mjs enable --upstream=<url> --path=/ [--json]',
221
+ ].join('\n'),
222
+ });
223
+ return;
224
+ }
225
+
226
+ const internalServerUrl = getInternalServerUrl();
227
+ if (flags.has('--upstream') || kv.get('--upstream')) {
228
+ process.env.HAPPY_LOCAL_TAILSCALE_UPSTREAM = kv.get('--upstream') ?? internalServerUrl;
229
+ }
230
+ if (flags.has('--path') || kv.get('--path')) {
231
+ process.env.HAPPY_LOCAL_TAILSCALE_SERVE_PATH = kv.get('--path') ?? '/';
232
+ }
233
+
234
+ switch (cmd) {
235
+ case 'status': {
236
+ const status = await tailscaleServeStatus();
237
+ if (json) {
238
+ printResult({ json, data: { status, httpsUrl: extractHttpsUrl(status) } });
239
+ } else {
240
+ process.stdout.write(status);
241
+ }
242
+ return;
243
+ }
244
+ case 'url': {
245
+ const status = await tailscaleServeStatus();
246
+ const url = extractHttpsUrl(status);
247
+ if (!url) {
248
+ throw new Error('[local] no https:// URL found in `tailscale serve status` output');
249
+ }
250
+ printResult({ json, data: { url }, text: url });
251
+ return;
252
+ }
253
+ case 'enable': {
254
+ const res = await tailscaleServeEnable({ internalServerUrl });
255
+ printResult({
256
+ json,
257
+ data: { ok: true, httpsUrl: res.httpsUrl ?? null },
258
+ text: res.httpsUrl ? `[local] tailscale serve enabled: ${res.httpsUrl}` : '[local] tailscale serve enabled',
259
+ });
260
+ return;
261
+ }
262
+ case 'disable':
263
+ case 'reset': {
264
+ await tailscaleServeReset();
265
+ printResult({ json, data: { ok: true }, text: '[local] tailscale serve reset' });
266
+ return;
267
+ }
268
+ default:
269
+ throw new Error(`[local] unknown tailscale command: ${cmd}`);
270
+ }
271
+ }
272
+
273
+ if (import.meta.url === `file://${process.argv[1]}`) {
274
+ main().catch((err) => {
275
+ console.error('[local] failed:', err);
276
+ process.exit(1);
277
+ });
278
+ }
@@ -0,0 +1,190 @@
1
+ import './utils/env.mjs';
2
+
3
+ import { rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ import { parseArgs } from './utils/args.mjs';
10
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
11
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
12
+ import { getRuntimeDir } from './utils/runtime.mjs';
13
+
14
+ function expandHome(p) {
15
+ return p.replace(/^~(?=\/)/, homedir());
16
+ }
17
+
18
+ function resolveWorkspaceDir({ rootDir, homeDir }) {
19
+ // Uninstall should never default to deleting the repo root (getWorkspaceDir() can fall back to cliRootDir).
20
+ const fromEnv = (process.env.HAPPY_STACKS_WORKSPACE_DIR ?? '').trim();
21
+ if (fromEnv) {
22
+ return expandHome(fromEnv);
23
+ }
24
+ void rootDir;
25
+ return join(homeDir, 'workspace');
26
+ }
27
+
28
+ function resolveSwiftbarPluginsDir() {
29
+ // Same logic as extras/swiftbar/install.sh.
30
+ const s =
31
+ 'DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; if [[ -n "$DIR" && -d "$DIR" ]]; then echo "$DIR"; exit 0; fi; D="$HOME/Library/Application Support/SwiftBar/Plugins"; if [[ -d "$D" ]]; then echo "$D"; exit 0; fi; echo ""';
32
+ const res = spawnSync('bash', ['-lc', s], { encoding: 'utf-8' });
33
+ const out = String(res.stdout ?? '').trim();
34
+ return out || null;
35
+ }
36
+
37
+ async function removeSwiftbarPluginFiles() {
38
+ if (process.platform !== 'darwin') {
39
+ return { ok: true, removed: 0, pluginsDir: null };
40
+ }
41
+ const pluginsDir = resolveSwiftbarPluginsDir();
42
+ if (!pluginsDir) {
43
+ return { ok: true, removed: 0, pluginsDir: null };
44
+ }
45
+
46
+ let removed = 0;
47
+ const patterns = ['happy-stacks.*.sh', 'happy-local.*.sh'];
48
+ for (const pat of patterns) {
49
+ const res = spawnSync('bash', ['-lc', `rm -f "${pluginsDir}"/${pat} 2>/dev/null || true`], { stdio: 'ignore' });
50
+ void res;
51
+ // best-effort count: if directory exists, we can scan remaining files; skip precise counts
52
+ }
53
+
54
+ // Count remaining matches (best-effort).
55
+ const check = spawnSync('bash', ['-lc', `ls -1 "${pluginsDir}"/happy-stacks.*.sh 2>/dev/null | wc -l | tr -d ' '`], {
56
+ encoding: 'utf-8',
57
+ });
58
+ const remaining = Number(String(check.stdout ?? '').trim());
59
+ if (Number.isFinite(remaining) && remaining === 0) {
60
+ removed = 1;
61
+ }
62
+ return { ok: true, removed, pluginsDir };
63
+ }
64
+
65
+ async function main() {
66
+ const argv = process.argv.slice(2);
67
+ const { flags } = parseArgs(argv);
68
+ const json = wantsJson(argv, { flags });
69
+ if (wantsHelp(argv, { flags }) || argv.includes('help')) {
70
+ printResult({
71
+ json,
72
+ data: { flags: ['--remove-workspace', '--remove-stacks', '--yes'], json: true },
73
+ text: [
74
+ '[uninstall] usage:',
75
+ ' happys uninstall [--json] # dry-run',
76
+ ' happys uninstall --yes [--json]',
77
+ ' happys uninstall --remove-workspace --yes',
78
+ ' happys uninstall --remove-stacks --yes',
79
+ '',
80
+ 'notes:',
81
+ ' - default removes: runtime, shims, cache, SwiftBar assets + plugin files, and LaunchAgent services',
82
+ ' - stacks under ~/.happy/stacks are kept unless --remove-stacks is provided',
83
+ ].join('\n'),
84
+ });
85
+ return;
86
+ }
87
+
88
+ const rootDir = getRootDir(import.meta.url);
89
+ const homeDir = getHappyStacksHomeDir();
90
+ const runtimeDir = getRuntimeDir();
91
+ const workspaceDir = resolveWorkspaceDir({ rootDir, homeDir });
92
+
93
+ const yes = flags.has('--yes');
94
+ const removeWorkspace = flags.has('--remove-workspace');
95
+ const removeStacks = flags.has('--remove-stacks');
96
+
97
+ const dryRun = !yes;
98
+
99
+ // 1) Stop/uninstall services best-effort.
100
+ if (!dryRun) {
101
+ try {
102
+ spawnSync(process.execPath, [join(rootDir, 'scripts', 'service.mjs'), 'uninstall'], {
103
+ stdio: json ? 'ignore' : 'inherit',
104
+ env: process.env,
105
+ cwd: rootDir,
106
+ });
107
+ } catch {
108
+ // ignore
109
+ }
110
+ }
111
+
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 }));
114
+
115
+ // 3) Remove home-managed runtime + shims + extras + cache + env pointers.
116
+ const toRemove = [
117
+ join(homeDir, 'bin'),
118
+ join(homeDir, 'runtime'),
119
+ join(homeDir, 'extras'),
120
+ join(homeDir, 'cache'),
121
+ join(homeDir, '.env'),
122
+ join(homeDir, 'env.local'),
123
+ ];
124
+ const removedPaths = [];
125
+ for (const p of toRemove) {
126
+ try {
127
+ if (existsSync(p)) {
128
+ if (!dryRun) {
129
+ await rm(p, { recursive: true, force: true });
130
+ }
131
+ removedPaths.push(p);
132
+ }
133
+ } catch {
134
+ // ignore
135
+ }
136
+ }
137
+
138
+ // 4) Optionally remove workspace (components/worktrees).
139
+ if (removeWorkspace) {
140
+ const ws = expandHome(workspaceDir);
141
+ if (existsSync(ws)) {
142
+ if (!dryRun) {
143
+ await rm(ws, { recursive: true, force: true });
144
+ }
145
+ removedPaths.push(ws);
146
+ }
147
+ }
148
+
149
+ // 5) Optionally remove stacks data.
150
+ if (removeStacks) {
151
+ const stacksRoot = join(homedir(), '.happy', 'stacks');
152
+ if (existsSync(stacksRoot)) {
153
+ if (!dryRun) {
154
+ await rm(stacksRoot, { recursive: true, force: true });
155
+ }
156
+ removedPaths.push(stacksRoot);
157
+ }
158
+ }
159
+
160
+ printResult({
161
+ json,
162
+ data: {
163
+ ok: true,
164
+ homeDir,
165
+ runtimeDir,
166
+ workspaceDir,
167
+ removedPaths,
168
+ menubar,
169
+ removeWorkspace,
170
+ removeStacks,
171
+ dryRun,
172
+ },
173
+ text: [
174
+ dryRun ? '[uninstall] dry run (no changes made)' : '[uninstall] complete',
175
+ dryRun ? '[uninstall] re-run with --yes to apply removals' : null,
176
+ `[uninstall] home: ${homeDir}`,
177
+ `[uninstall] removed: ${removedPaths.length ? removedPaths.join(', ') : '(nothing)'}`,
178
+ menubar?.pluginsDir ? `[uninstall] SwiftBar plugins dir: ${menubar.pluginsDir}` : null,
179
+ removeWorkspace ? `[uninstall] workspace removed: ${workspaceDir}` : `[uninstall] workspace kept: ${workspaceDir}`,
180
+ removeStacks ? `[uninstall] stacks removed: ~/.happy/stacks` : `[uninstall] stacks kept: ~/.happy/stacks`,
181
+ ]
182
+ .filter(Boolean)
183
+ .join('\n'),
184
+ });
185
+ }
186
+
187
+ main().catch((err) => {
188
+ console.error('[uninstall] failed:', err);
189
+ process.exit(1);
190
+ });
@@ -0,0 +1,17 @@
1
+ export function parseArgs(argv) {
2
+ const flags = new Set();
3
+ const kv = new Map();
4
+ for (const raw of argv) {
5
+ if (!raw.startsWith('--')) {
6
+ continue;
7
+ }
8
+ const [k, v] = raw.split('=', 2);
9
+ if (v === undefined) {
10
+ flags.add(k);
11
+ } else {
12
+ kv.set(k, v);
13
+ }
14
+ }
15
+ return { flags, kv };
16
+ }
17
+
@@ -0,0 +1,24 @@
1
+ export function wantsJson(argv, { flags = null } = {}) {
2
+ if (flags?.has('--json')) {
3
+ return true;
4
+ }
5
+ return argv.includes('--json');
6
+ }
7
+
8
+ export function wantsHelp(argv, { flags = null } = {}) {
9
+ if (flags?.has('--help')) {
10
+ return true;
11
+ }
12
+ return argv.includes('--help') || argv.includes('-h');
13
+ }
14
+
15
+ export function printResult({ json, data, text }) {
16
+ if (json) {
17
+ process.stdout.write(JSON.stringify(data ?? null, null, 2) + '\n');
18
+ return;
19
+ }
20
+ if (text) {
21
+ process.stdout.write(text.endsWith('\n') ? text : text + '\n');
22
+ }
23
+ }
24
+