|
1 | | -import { runCommand } from '@stacksjs/cli' |
2 | | -import { frameworkPath } from '@stacksjs/path' |
| 1 | +import { homedir, networkInterfaces } from 'node:os' |
| 2 | +import { basename, join } from 'node:path' |
| 3 | +import process from 'node:process' |
| 4 | +import { bold, cyan, dim, green } from '@stacksjs/cli' |
| 5 | +import { openDevWindow } from '@stacksjs/desktop' |
| 6 | +import { projectPath, storagePath } from '@stacksjs/path' |
| 7 | +import { findStacksProjects } from '@stacksjs/utils' |
| 8 | +import { findAvailablePort, waitForServer } from './dashboard-utils' |
3 | 9 |
|
4 | | -await runCommand('bun run dev', { |
5 | | - cwd: frameworkPath('system-tray'), |
| 10 | +interface TrayActionRequest { |
| 11 | + action?: string |
| 12 | + project?: string |
| 13 | + confirmed?: boolean |
| 14 | +} |
| 15 | + |
| 16 | +const preferredPort = Number(process.env.PORT_SYSTEM_TRAY) || 3009 |
| 17 | +const port = await findAvailablePort(preferredPort) |
| 18 | +const trayViews = storagePath('framework/defaults/views/system-tray') |
| 19 | +const appUrl = process.env.APP_URL || 'stacks.test' |
| 20 | +const domain = appUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') |
| 21 | +const hasPrettyDomain = !domain.includes('localhost:') && domain !== 'localhost' |
| 22 | +const trayDomain = hasPrettyDomain ? `tray.${domain}` : null |
| 23 | +const localUrl = `http://localhost:${port}` |
| 24 | + |
| 25 | +let cachedProjects: string[] | undefined |
| 26 | +async function discoverProjects(refresh = false): Promise<string[]> { |
| 27 | + if (cachedProjects && !refresh) |
| 28 | + return cachedProjects |
| 29 | + |
| 30 | + const discovered = await findStacksProjects(join(homedir(), 'Code'), { quiet: true }).catch(() => []) |
| 31 | + const candidates = [...new Set([projectPath(), ...discovered])] |
| 32 | + const checks = await Promise.all(candidates.map(async project => ({ |
| 33 | + project, |
| 34 | + valid: await Bun.file(join(project, 'buddy')).exists(), |
| 35 | + }))) |
| 36 | + cachedProjects = checks |
| 37 | + .filter(check => check.valid) |
| 38 | + .map(check => check.project) |
| 39 | + .sort((a, b) => basename(a).localeCompare(basename(b))) |
| 40 | + return cachedProjects |
| 41 | +} |
| 42 | + |
| 43 | +async function runBuddy(project: string, args: string[]): Promise<{ output: string, exitCode: number }> { |
| 44 | + const child = Bun.spawn([join(project, 'buddy'), ...args, '--no-interaction'], { |
| 45 | + cwd: project, |
| 46 | + stdout: 'pipe', |
| 47 | + stderr: 'pipe', |
| 48 | + }) |
| 49 | + const [stdout, stderr, exitCode] = await Promise.all([ |
| 50 | + new Response(child.stdout).text(), |
| 51 | + new Response(child.stderr).text(), |
| 52 | + child.exited, |
| 53 | + ]) |
| 54 | + return { output: [stdout, stderr].filter(Boolean).join('\n').trim(), exitCode } |
| 55 | +} |
| 56 | + |
| 57 | +function openPath(path: string): void { |
| 58 | + const command = process.platform === 'darwin' |
| 59 | + ? ['open', path] |
| 60 | + : process.platform === 'win32' |
| 61 | + ? ['cmd', '/c', 'start', '', path] |
| 62 | + : ['xdg-open', path] |
| 63 | + Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref() |
| 64 | +} |
| 65 | + |
| 66 | +function openTerminal(project: string): void { |
| 67 | + const command = process.platform === 'darwin' |
| 68 | + ? ['open', '-a', 'Terminal', project] |
| 69 | + : process.platform === 'win32' |
| 70 | + ? ['cmd', '/c', 'start', 'cmd', '/K', `cd /d ${project}`] |
| 71 | + : ['x-terminal-emulator', '--working-directory', project] |
| 72 | + Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref() |
| 73 | +} |
| 74 | + |
| 75 | +function localIpAddress(): string { |
| 76 | + for (const addresses of Object.values(networkInterfaces())) { |
| 77 | + for (const address of addresses || []) { |
| 78 | + if (address.family === 'IPv4' && !address.internal) |
| 79 | + return address.address |
| 80 | + } |
| 81 | + } |
| 82 | + return '127.0.0.1' |
| 83 | +} |
| 84 | + |
| 85 | +function dashboardUrl(path = ''): string { |
| 86 | + const base = hasPrettyDomain ? `https://dashboard.${domain}` : `http://localhost:${Number(process.env.PORT_ADMIN) || 3002}` |
| 87 | + return `${base}${path}` |
| 88 | +} |
| 89 | + |
| 90 | +async function handleAction(input: TrayActionRequest): Promise<Response> { |
| 91 | + const projects = await discoverProjects() |
| 92 | + const project = input.project || projectPath() |
| 93 | + if (!projects.includes(project)) |
| 94 | + return Response.json({ ok: false, error: 'Unknown Stacks project' }, { status: 400 }) |
| 95 | + |
| 96 | + switch (input.action) { |
| 97 | + case 'refresh': |
| 98 | + return Response.json({ ok: true, projects: await discoverProjects(true) }) |
| 99 | + case 'open-terminal': |
| 100 | + openTerminal(project) |
| 101 | + return Response.json({ ok: true, message: `Opened Terminal in ${basename(project)}` }) |
| 102 | + case 'env-check': { |
| 103 | + const result = await runBuddy(project, ['doctor']) |
| 104 | + return Response.json({ ok: result.exitCode === 0, ...result }) |
| 105 | + } |
| 106 | + case 'settings': |
| 107 | + openPath(dashboardUrl('/settings')) |
| 108 | + return Response.json({ ok: true, message: 'Opened dashboard settings' }) |
| 109 | + case 'check-updates': { |
| 110 | + const result = await runBuddy(project, ['outdated']) |
| 111 | + return Response.json({ ok: result.exitCode === 0, ...result }) |
| 112 | + } |
| 113 | + case 'copy-ip': |
| 114 | + return Response.json({ ok: true, value: localIpAddress(), message: 'IP address ready to copy' }) |
| 115 | + case 'open-dashboard': |
| 116 | + openPath(dashboardUrl()) |
| 117 | + return Response.json({ ok: true, message: 'Opened dashboard' }) |
| 118 | + case 'buddy-commands': { |
| 119 | + const result = await runBuddy(project, ['list']) |
| 120 | + return Response.json({ ok: result.exitCode === 0, ...result }) |
| 121 | + } |
| 122 | + case 'deploy': { |
| 123 | + if (!input.confirmed) |
| 124 | + return Response.json({ ok: false, confirmationRequired: true, error: 'Deployment confirmation required' }, { status: 409 }) |
| 125 | + const child = Bun.spawn([join(project, 'buddy'), 'deploy', '--no-interaction'], { |
| 126 | + cwd: project, |
| 127 | + stdout: 'inherit', |
| 128 | + stderr: 'inherit', |
| 129 | + }) |
| 130 | + child.unref() |
| 131 | + return Response.json({ ok: true, message: `Deployment started for ${basename(project)}` }) |
| 132 | + } |
| 133 | + case 'deploy-logs': |
| 134 | + openPath(join(project, 'storage/logs')) |
| 135 | + return Response.json({ ok: true, message: 'Opened deployment logs' }) |
| 136 | + case 'site-logs': |
| 137 | + openPath(join(project, 'storage/logs')) |
| 138 | + return Response.json({ ok: true, message: 'Opened site logs' }) |
| 139 | + case 'error-logs': |
| 140 | + openPath(join(project, 'storage/logs')) |
| 141 | + return Response.json({ ok: true, message: 'Opened error logs' }) |
| 142 | + case 'edit-env': |
| 143 | + openPath(join(project, '.env')) |
| 144 | + return Response.json({ ok: true, message: 'Opened .env' }) |
| 145 | + case 'edit-dns': |
| 146 | + openPath(join(project, 'config/dns.ts')) |
| 147 | + return Response.json({ ok: true, message: 'Opened DNS configuration' }) |
| 148 | + case 'edit-email': |
| 149 | + openPath(join(project, 'config/email.ts')) |
| 150 | + return Response.json({ ok: true, message: 'Opened email configuration' }) |
| 151 | + case 'ask-buddy': |
| 152 | + openPath(dashboardUrl('/buddy')) |
| 153 | + return Response.json({ ok: true, message: 'Opened Ask Buddy' }) |
| 154 | + default: |
| 155 | + return Response.json({ ok: false, error: 'Unknown tray action' }, { status: 400 }) |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +const { serve } = await import('bun-plugin-stx/serve') |
| 160 | +const serverPromise = serve({ |
| 161 | + patterns: [trayViews], |
| 162 | + port, |
| 163 | + componentsDir: storagePath('framework/defaults/resources/components'), |
| 164 | + quiet: true, |
| 165 | + auth: false, |
| 166 | + routes: { |
| 167 | + '/api/tray/projects': async () => Response.json({ ok: true, projects: await discoverProjects() }), |
| 168 | + '/api/tray/action': async (request: Request) => { |
| 169 | + if (request.method !== 'POST') |
| 170 | + return Response.json({ ok: false, error: 'Method not allowed' }, { status: 405 }) |
| 171 | + return handleAction(await request.json() as TrayActionRequest) |
| 172 | + }, |
| 173 | + }, |
| 174 | +} as any) |
| 175 | +serverPromise.catch((error: Error) => { |
| 176 | + console.error(`System tray server failed: ${error.message}`) |
| 177 | + process.exit(1) |
| 178 | +}) |
| 179 | + |
| 180 | +let proxyStarted = false |
| 181 | +if (trayDomain && !process.env.STACKS_PROXY_MANAGED) { |
| 182 | + try { |
| 183 | + const { startProxies } = await import('@stacksjs/rpx') |
| 184 | + await startProxies({ |
| 185 | + proxies: [{ from: `localhost:${port}`, to: trayDomain, cleanUrls: false }], |
| 186 | + https: { basePath: `${process.env.HOME}/.stacks/ssl`, validityDays: 825 }, |
| 187 | + regenerateUntrustedCerts: false, |
| 188 | + }) |
| 189 | + proxyStarted = true |
| 190 | + } |
| 191 | + catch { |
| 192 | + proxyStarted = false |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +await waitForServer(port, 2_000) |
| 197 | +const url = trayDomain && proxyStarted ? `https://${trayDomain}` : localUrl |
| 198 | +console.log() |
| 199 | +console.log(` ${bold(cyan('stacks tray'))}`) |
| 200 | +console.log() |
| 201 | +console.log(` ${green('➜')} ${bold('Local')}: ${cyan(url)}`) |
| 202 | +if (trayDomain) console.log(` ${dim('➜')} ${dim('Origin')}: ${dim(localUrl)}`) |
| 203 | +console.log() |
| 204 | + |
| 205 | +await openDevWindow(port, { |
| 206 | + url, |
| 207 | + title: 'Stacks', |
| 208 | + width: 440, |
| 209 | + height: 680, |
| 210 | + systemTray: true, |
| 211 | + hideDockIcon: true, |
| 212 | + hotReload: true, |
6 | 213 | }) |
| 214 | + |
| 215 | +const stop = () => { |
| 216 | + process.exit(0) |
| 217 | +} |
| 218 | +process.on('SIGINT', stop) |
| 219 | +process.on('SIGTERM', stop) |
| 220 | +await new Promise(() => {}) |
0 commit comments