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,379 @@
1
+ import './utils/env.mjs';
2
+ import { parseArgs } from './utils/args.mjs';
3
+ import { pathExists } from './utils/fs.mjs';
4
+ import { run } from './utils/proc.mjs';
5
+ import { getComponentDir, getRootDir } from './utils/paths.mjs';
6
+ import { getServerComponentName } from './utils/server.mjs';
7
+ import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/pm.mjs';
8
+ import { dirname, join } from 'node:path';
9
+ import { mkdir } from 'node:fs/promises';
10
+ import { installService, uninstallService } from './service.mjs';
11
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
12
+ import { ensureEnvLocalUpdated } from './utils/env_local.mjs';
13
+ import { isTty, prompt, promptSelect, withRl } from './utils/wizard.mjs';
14
+
15
+ /**
16
+ * Install/setup the local stack:
17
+ * - ensure components exist (optionally clone if missing)
18
+ * - install dependencies where needed
19
+ * - build happy-cli (optional) and install `happy`/`happys` shims under `~/.happy-stacks/bin`
20
+ * - build the web UI bundle (so `run` can serve it)
21
+ * - optional macOS autostart (LaunchAgent)
22
+ */
23
+
24
+ const DEFAULT_FORK_REPOS = {
25
+ serverLight: 'https://github.com/leeroybrun/happy-server-light.git',
26
+ // We don't currently maintain a separate fork for full happy-server; default to upstream.
27
+ serverFull: 'https://github.com/slopus/happy-server.git',
28
+ cli: 'https://github.com/leeroybrun/happy-cli.git',
29
+ ui: 'https://github.com/leeroybrun/happy.git',
30
+ };
31
+
32
+ const DEFAULT_UPSTREAM_REPOS = {
33
+ // Upstream for server-light lives in the main happy-server repo.
34
+ serverLight: 'https://github.com/slopus/happy-server.git',
35
+ serverFull: 'https://github.com/slopus/happy-server.git',
36
+ cli: 'https://github.com/slopus/happy-cli.git',
37
+ ui: 'https://github.com/slopus/happy.git',
38
+ };
39
+
40
+ function repoUrlsFromOwners({ forkOwner, upstreamOwner }) {
41
+ const fork = (name) => `https://github.com/${forkOwner}/${name}.git`;
42
+ const up = (name) => `https://github.com/${upstreamOwner}/${name}.git`;
43
+ return {
44
+ forks: {
45
+ serverLight: fork('happy-server-light'),
46
+ serverFull: fork('happy-server') /* best-effort; user can override */,
47
+ cli: fork('happy-cli'),
48
+ ui: fork('happy'),
49
+ },
50
+ upstream: {
51
+ // server-light upstream lives in happy-server
52
+ serverLight: up('happy-server'),
53
+ serverFull: up('happy-server'),
54
+ cli: up('happy-cli'),
55
+ ui: up('happy'),
56
+ },
57
+ };
58
+ }
59
+
60
+ function resolveRepoSource({ flags }) {
61
+ if (flags.has('--forks')) {
62
+ return 'forks';
63
+ }
64
+ if (flags.has('--upstream')) {
65
+ return 'upstream';
66
+ }
67
+ const fromEnv = (process.env.HAPPY_LOCAL_REPO_SOURCE ?? '').trim().toLowerCase();
68
+ if (fromEnv === 'fork' || fromEnv === 'forks') {
69
+ return 'forks';
70
+ }
71
+ if (fromEnv === 'upstream') {
72
+ return 'upstream';
73
+ }
74
+ return 'forks';
75
+ }
76
+
77
+ function getRepoUrls({ repoSource }) {
78
+ const defaults = repoSource === 'upstream' ? DEFAULT_UPSTREAM_REPOS : DEFAULT_FORK_REPOS;
79
+ return {
80
+ // Backwards compatible: HAPPY_LOCAL_SERVER_REPO_URL historically referred to the server-light component.
81
+ serverLight: process.env.HAPPY_LOCAL_SERVER_LIGHT_REPO_URL?.trim() || process.env.HAPPY_LOCAL_SERVER_REPO_URL?.trim() || defaults.serverLight,
82
+ serverFull: process.env.HAPPY_LOCAL_SERVER_FULL_REPO_URL?.trim() || defaults.serverFull,
83
+ cli: process.env.HAPPY_LOCAL_CLI_REPO_URL?.trim() || defaults.cli,
84
+ ui: process.env.HAPPY_LOCAL_UI_REPO_URL?.trim() || defaults.ui,
85
+ };
86
+ }
87
+
88
+ async function ensureComponentPresent({ dir, label, repoUrl, allowClone }) {
89
+ if (await pathExists(dir)) {
90
+ return;
91
+ }
92
+ if (!allowClone) {
93
+ throw new Error(`[local] missing ${label} at ${dir} (run with --clone or add it under components/)`);
94
+ }
95
+ if (!repoUrl) {
96
+ throw new Error(
97
+ `[local] missing ${label} at ${dir} and no repo URL configured.\n` +
98
+ `Set HAPPY_LOCAL_${label}_REPO_URL, or run: happys bootstrap -- --forks / --upstream`
99
+ );
100
+ }
101
+ await mkdir(dirname(dir), { recursive: true });
102
+ console.log(`[local] cloning ${label} into ${dir}...`);
103
+ await run('git', ['clone', repoUrl, dir]);
104
+ }
105
+
106
+ async function ensureUpstreamRemote({ repoDir, upstreamUrl }) {
107
+ if (!(await pathExists(join(repoDir, '.git')))) {
108
+ return;
109
+ }
110
+ try {
111
+ await run('git', ['remote', 'get-url', 'upstream'], { cwd: repoDir });
112
+ // Upstream remote exists; best-effort update if different.
113
+ await run('git', ['remote', 'set-url', 'upstream', upstreamUrl], { cwd: repoDir }).catch(() => {});
114
+ } catch {
115
+ await run('git', ['remote', 'add', 'upstream', upstreamUrl], { cwd: repoDir });
116
+ }
117
+ }
118
+
119
+ async function interactiveWizard({ rootDir, defaults }) {
120
+ return await withRl(async (rl) => {
121
+ const repoSource = await promptSelect(rl, {
122
+ title: 'Select repo source:',
123
+ options: [
124
+ { label: `forks (default, recommended)`, value: 'forks' },
125
+ { label: `upstream (slopus/*)`, value: 'upstream' },
126
+ ],
127
+ defaultIndex: defaults.repoSource === 'upstream' ? 1 : 0,
128
+ });
129
+
130
+ const forkOwner = await prompt(rl, `GitHub fork owner (default: ${defaults.forkOwner}): `, { defaultValue: defaults.forkOwner });
131
+ const upstreamOwner = await prompt(rl, `GitHub upstream owner (default: ${defaults.upstreamOwner}): `, {
132
+ defaultValue: defaults.upstreamOwner,
133
+ });
134
+
135
+ const serverMode = await promptSelect(rl, {
136
+ title: 'Which server components should be set up?',
137
+ options: [
138
+ { label: 'happy-server-light only (default)', value: 'happy-server-light' },
139
+ { label: 'happy-server only (full server)', value: 'happy-server' },
140
+ { label: 'both (server-light + full server)', value: 'both' },
141
+ ],
142
+ defaultIndex: defaults.serverComponentName === 'both' ? 2 : defaults.serverComponentName === 'happy-server' ? 1 : 0,
143
+ });
144
+
145
+ const allowClone = await promptSelect(rl, {
146
+ title: 'Clone missing component repos?',
147
+ options: [
148
+ { label: 'yes (default)', value: true },
149
+ { label: 'no', value: false },
150
+ ],
151
+ defaultIndex: defaults.allowClone ? 0 : 1,
152
+ });
153
+
154
+ const enableAutostart = await promptSelect(rl, {
155
+ title: 'Enable macOS autostart (LaunchAgent)?',
156
+ options: [
157
+ { label: 'no (default)', value: false },
158
+ { label: 'yes', value: true },
159
+ ],
160
+ defaultIndex: defaults.enableAutostart ? 1 : 0,
161
+ });
162
+
163
+ const buildTauri = await promptSelect(rl, {
164
+ title: 'Build Tauri desktop app as part of setup?',
165
+ options: [
166
+ { label: 'no (default)', value: false },
167
+ { label: 'yes', value: true },
168
+ ],
169
+ defaultIndex: defaults.buildTauri ? 1 : 0,
170
+ });
171
+
172
+ const configureGit = await promptSelect(rl, {
173
+ title: 'Configure upstream Git remotes and create mirror branches (slopus/main)?',
174
+ options: [
175
+ { label: 'yes (default)', value: true },
176
+ { label: 'no', value: false },
177
+ ],
178
+ defaultIndex: 0,
179
+ });
180
+
181
+ return {
182
+ repoSource,
183
+ forkOwner: forkOwner.trim() || defaults.forkOwner,
184
+ upstreamOwner: upstreamOwner.trim() || defaults.upstreamOwner,
185
+ serverComponentName: serverMode,
186
+ allowClone,
187
+ enableAutostart,
188
+ buildTauri,
189
+ configureGit,
190
+ };
191
+ });
192
+ }
193
+
194
+ async function main() {
195
+ const argv = process.argv.slice(2);
196
+ const { flags, kv } = parseArgs(argv);
197
+ const json = wantsJson(argv, { flags });
198
+ if (wantsHelp(argv, { flags })) {
199
+ printResult({
200
+ json,
201
+ data: { flags: ['--forks', '--upstream', '--clone', '--no-clone', '--autostart', '--no-autostart', '--server=...'], json: true },
202
+ text: [
203
+ '[bootstrap] usage:',
204
+ ' happys bootstrap [--forks|--upstream] [--server=happy-server|happy-server-light|both] [--json]',
205
+ ' happys bootstrap --interactive',
206
+ ' happys bootstrap --no-clone',
207
+ ].join('\n'),
208
+ });
209
+ return;
210
+ }
211
+ const rootDir = getRootDir(import.meta.url);
212
+
213
+ const interactive = flags.has('--interactive') && isTty();
214
+
215
+ // Defaults for wizard.
216
+ const defaultRepoSource = resolveRepoSource({ flags });
217
+ const defaults = {
218
+ repoSource: defaultRepoSource,
219
+ forkOwner: 'leeroybrun',
220
+ upstreamOwner: 'slopus',
221
+ serverComponentName: getServerComponentName({ kv }),
222
+ allowClone: !flags.has('--no-clone') && ((process.env.HAPPY_LOCAL_CLONE_MISSING ?? '1') !== '0' || flags.has('--clone')),
223
+ enableAutostart: flags.has('--autostart') || (process.env.HAPPY_LOCAL_AUTOSTART ?? '0') === '1',
224
+ buildTauri: flags.has('--tauri') && !flags.has('--no-tauri'),
225
+ };
226
+
227
+ const wizard = interactive ? await interactiveWizard({ rootDir, defaults }) : null;
228
+ const repoSource = wizard?.repoSource ?? defaultRepoSource;
229
+
230
+ // Persist chosen repo source + URLs into the user config env file:
231
+ // - main stack env by default (recommended; consistent across install modes)
232
+ // - legacy fallback: <repo>/env.local when no home config exists yet
233
+ if (wizard) {
234
+ const owners = repoUrlsFromOwners({ forkOwner: wizard.forkOwner, upstreamOwner: wizard.upstreamOwner });
235
+ const chosen = repoSource === 'upstream' ? owners.upstream : owners.forks;
236
+ await ensureEnvLocalUpdated({
237
+ rootDir,
238
+ updates: [
239
+ { key: 'HAPPY_STACKS_REPO_SOURCE', value: repoSource },
240
+ { key: 'HAPPY_LOCAL_REPO_SOURCE', value: repoSource },
241
+ { key: 'HAPPY_STACKS_UI_REPO_URL', value: chosen.ui },
242
+ { key: 'HAPPY_LOCAL_UI_REPO_URL', value: chosen.ui },
243
+ { key: 'HAPPY_STACKS_CLI_REPO_URL', value: chosen.cli },
244
+ { key: 'HAPPY_LOCAL_CLI_REPO_URL', value: chosen.cli },
245
+ // Backwards compatible: SERVER_REPO_URL historically meant server-light.
246
+ { key: 'HAPPY_STACKS_SERVER_REPO_URL', value: chosen.serverLight },
247
+ { key: 'HAPPY_LOCAL_SERVER_REPO_URL', value: chosen.serverLight },
248
+ { key: 'HAPPY_STACKS_SERVER_LIGHT_REPO_URL', value: chosen.serverLight },
249
+ { key: 'HAPPY_LOCAL_SERVER_LIGHT_REPO_URL', value: chosen.serverLight },
250
+ { key: 'HAPPY_STACKS_SERVER_FULL_REPO_URL', value: chosen.serverFull },
251
+ { key: 'HAPPY_LOCAL_SERVER_FULL_REPO_URL', value: chosen.serverFull },
252
+ ],
253
+ });
254
+ }
255
+
256
+ const repos = getRepoUrls({ repoSource });
257
+
258
+ // Default: clone missing components (fresh checkouts "just work").
259
+ // Disable with --no-clone or HAPPY_LOCAL_CLONE_MISSING=0.
260
+ const cloneMissingDefault = (process.env.HAPPY_LOCAL_CLONE_MISSING ?? '1') !== '0';
261
+ const allowClone =
262
+ wizard?.allowClone ?? (!flags.has('--no-clone') && (flags.has('--clone') || cloneMissingDefault));
263
+ const enableAutostart = wizard?.enableAutostart ?? (flags.has('--autostart') || (process.env.HAPPY_LOCAL_AUTOSTART ?? '0') === '1');
264
+ const disableAutostart = flags.has('--no-autostart');
265
+
266
+ const serverComponentName = (wizard?.serverComponentName ?? getServerComponentName({ kv })).trim();
267
+ const serverLightDir = getComponentDir(rootDir, 'happy-server-light');
268
+ const serverFullDir = getComponentDir(rootDir, 'happy-server');
269
+ const cliDir = getComponentDir(rootDir, 'happy-cli');
270
+ const uiDir = getComponentDir(rootDir, 'happy');
271
+
272
+ // Ensure components exist (embedded layout)
273
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
274
+ await ensureComponentPresent({
275
+ dir: serverLightDir,
276
+ label: 'SERVER',
277
+ repoUrl: repos.serverLight,
278
+ allowClone,
279
+ });
280
+ }
281
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
282
+ await ensureComponentPresent({
283
+ dir: serverFullDir,
284
+ label: 'SERVER_FULL',
285
+ repoUrl: repos.serverFull,
286
+ allowClone,
287
+ });
288
+ }
289
+ await ensureComponentPresent({
290
+ dir: cliDir,
291
+ label: 'CLI',
292
+ repoUrl: repos.cli,
293
+ allowClone,
294
+ });
295
+ await ensureComponentPresent({
296
+ dir: uiDir,
297
+ label: 'UI',
298
+ repoUrl: repos.ui,
299
+ allowClone,
300
+ });
301
+
302
+ const cliDirFinal = cliDir;
303
+ const uiDirFinal = uiDir;
304
+
305
+ // Install deps
306
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
307
+ await ensureDepsInstalled(serverLightDir, 'happy-server-light');
308
+ }
309
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
310
+ await ensureDepsInstalled(serverFullDir, 'happy-server');
311
+ }
312
+ await ensureDepsInstalled(uiDirFinal, 'happy');
313
+ await ensureDepsInstalled(cliDirFinal, 'happy-cli');
314
+
315
+ // CLI build + link
316
+ const buildCli = (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
317
+ const npmLinkCli = (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
318
+ await ensureCliBuilt(cliDirFinal, { buildCli });
319
+ await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
320
+
321
+ // Build UI (so run works without expo dev server)
322
+ const buildArgs = [join(rootDir, 'scripts', 'build.mjs')];
323
+ // Tauri builds are opt-in (slow + requires additional toolchain).
324
+ const buildTauri = wizard?.buildTauri ?? (flags.has('--tauri') && !flags.has('--no-tauri'));
325
+ if (buildTauri) {
326
+ buildArgs.push('--tauri');
327
+ } else if (flags.has('--no-tauri')) {
328
+ buildArgs.push('--no-tauri');
329
+ }
330
+ await run(process.execPath, buildArgs, { cwd: rootDir });
331
+
332
+ // Optional autostart (macOS)
333
+ if (disableAutostart) {
334
+ await uninstallService();
335
+ } else if (enableAutostart) {
336
+ await installService();
337
+ }
338
+
339
+ // Optional git remote + mirror branch configuration
340
+ if (wizard?.configureGit) {
341
+ // Ensure upstream remotes exist so `happys wt sync-all` works consistently.
342
+ const upstreamRepos = getRepoUrls({ repoSource: 'upstream' });
343
+ await ensureUpstreamRemote({ repoDir: uiDir, upstreamUrl: upstreamRepos.ui });
344
+ await ensureUpstreamRemote({ repoDir: cliDir, upstreamUrl: upstreamRepos.cli });
345
+ // server-light and server-full both track upstream happy-server
346
+ if (await pathExists(serverLightDir)) {
347
+ await ensureUpstreamRemote({ repoDir: serverLightDir, upstreamUrl: upstreamRepos.serverLight });
348
+ }
349
+ if (await pathExists(serverFullDir)) {
350
+ await ensureUpstreamRemote({ repoDir: serverFullDir, upstreamUrl: upstreamRepos.serverFull });
351
+ }
352
+
353
+ // Create/update mirror branches like slopus/main for each repo (best-effort).
354
+ try {
355
+ await run(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), 'sync-all', '--json'], { cwd: rootDir });
356
+ } catch {
357
+ // ignore (still useful even if one component fails)
358
+ }
359
+ }
360
+
361
+ printResult({
362
+ json,
363
+ data: {
364
+ ok: true,
365
+ repoSource,
366
+ serverComponentName,
367
+ dirs: { serverLightDir, serverFullDir, cliDir: cliDirFinal, uiDir: uiDirFinal },
368
+ cloned: allowClone,
369
+ autostart: enableAutostart ? 'enabled' : disableAutostart ? 'disabled' : 'unchanged',
370
+ interactive: Boolean(wizard),
371
+ },
372
+ text: '[local] setup complete',
373
+ });
374
+ }
375
+
376
+ main().catch((err) => {
377
+ console.error('[local] install failed:', err);
378
+ process.exit(1);
379
+ });
@@ -0,0 +1,107 @@
1
+ import './utils/env.mjs';
2
+ import { cp, mkdir } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { getHappyStacksHomeDir, getRootDir } from './utils/paths.mjs';
7
+ import { parseArgs } from './utils/args.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
9
+
10
+ async function ensureSwiftbarAssets({ cliRootDir }) {
11
+ const homeDir = getHappyStacksHomeDir();
12
+ const destDir = join(homeDir, 'extras', 'swiftbar');
13
+ const srcDir = join(cliRootDir, 'extras', 'swiftbar');
14
+
15
+ if (!existsSync(srcDir)) {
16
+ throw new Error(`[menubar] missing assets at: ${srcDir}`);
17
+ }
18
+
19
+ await mkdir(destDir, { recursive: true });
20
+ await cp(srcDir, destDir, {
21
+ recursive: true,
22
+ force: true,
23
+ filter: (p) => !p.includes('.DS_Store'),
24
+ });
25
+
26
+ return { homeDir, destDir };
27
+ }
28
+
29
+ function openSwiftbarPluginsDir() {
30
+ const s = 'DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; open "$DIR"';
31
+ const res = spawnSync('bash', ['-lc', s], { stdio: 'inherit' });
32
+ if (res.status !== 0) {
33
+ process.exit(res.status ?? 1);
34
+ }
35
+ }
36
+
37
+ function removeSwiftbarPlugins() {
38
+ const s =
39
+ 'DIR="$(defaults read com.ameba.SwiftBar PluginDirectory 2>/dev/null)"; if [[ -z "$DIR" ]]; then DIR="$HOME/Library/Application Support/SwiftBar/Plugins"; fi; if [[ -d "$DIR" ]]; then rm -f "$DIR"/happy-stacks.*.sh "$DIR"/happy-local.*.sh 2>/dev/null || true; echo "$DIR"; else echo ""; fi';
40
+ const res = spawnSync('bash', ['-lc', s], { encoding: 'utf-8' });
41
+ if (res.status !== 0) {
42
+ return null;
43
+ }
44
+ const out = String(res.stdout ?? '').trim();
45
+ return out || null;
46
+ }
47
+
48
+ async function main() {
49
+ const rawArgv = process.argv.slice(2);
50
+ const argv = rawArgv[0] === 'menubar' ? rawArgv.slice(1) : rawArgv;
51
+ const { flags } = parseArgs(argv);
52
+ const json = wantsJson(argv, { flags });
53
+
54
+ const cmd = argv.find((a) => !a.startsWith('--')) || 'help';
55
+ if (wantsHelp(argv, { flags }) || cmd === 'help') {
56
+ printResult({
57
+ json,
58
+ data: { commands: ['install', 'uninstall', 'open'] },
59
+ text: [
60
+ '[menubar] usage:',
61
+ ' happys menubar install [--json]',
62
+ ' happys menubar uninstall [--json]',
63
+ ' happys menubar open [--json]',
64
+ '',
65
+ 'notes:',
66
+ ' - installs SwiftBar plugin into the active SwiftBar plugin folder',
67
+ ' - keeps plugin source under ~/.happy-stacks/extras/swiftbar for stability',
68
+ ].join('\n'),
69
+ });
70
+ return;
71
+ }
72
+
73
+ const cliRootDir = getRootDir(import.meta.url);
74
+
75
+ if (cmd === 'menubar:open' || cmd === 'open') {
76
+ if (json) {
77
+ printResult({ json, data: { ok: true } });
78
+ return;
79
+ }
80
+ openSwiftbarPluginsDir();
81
+ return;
82
+ }
83
+
84
+ if (cmd === 'menubar:uninstall' || cmd === 'uninstall') {
85
+ const dir = removeSwiftbarPlugins();
86
+ printResult({ json, data: { ok: true, pluginsDir: dir }, text: dir ? `[menubar] removed plugins from ${dir}` : '[menubar] no plugins dir found' });
87
+ return;
88
+ }
89
+
90
+ if (cmd === 'menubar:install' || cmd === 'install') {
91
+ const { destDir } = await ensureSwiftbarAssets({ cliRootDir });
92
+ const installer = join(destDir, 'install.sh');
93
+ const res = spawnSync('bash', [installer, '--force'], { stdio: 'inherit', env: { ...process.env, HAPPY_STACKS_HOME_DIR: getHappyStacksHomeDir() } });
94
+ if (res.status !== 0) {
95
+ process.exit(res.status ?? 1);
96
+ }
97
+ printResult({ json, data: { ok: true }, text: '[menubar] installed' });
98
+ return;
99
+ }
100
+
101
+ throw new Error(`[menubar] unknown command: ${cmd}`);
102
+ }
103
+
104
+ main().catch((err) => {
105
+ console.error('[menubar] failed:', err);
106
+ process.exit(1);
107
+ });