├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── README.md ├── assets ├── command-icon.png └── forge-icon-64.png ├── metadata ├── laravel-forge-1.png ├── laravel-forge-2.png └── laravel-forge-3.png ├── package-lock.json ├── package.json ├── src ├── api │ ├── Mock.ts │ ├── Server.ts │ └── Site.ts ├── check-deploy-status.tsx ├── components │ ├── EmptyView.tsx │ ├── actions │ │ ├── ServerCommands.tsx │ │ └── SiteCommands.tsx │ ├── configs │ │ ├── EnvFile.tsx │ │ └── NginxFile.tsx │ ├── servers │ │ ├── ServerSingle.tsx │ │ └── ServersList.tsx │ └── sites │ │ ├── DeployHistory.tsx │ │ ├── SiteSingle.tsx │ │ └── SitesList.tsx ├── config.ts ├── hooks │ ├── useAllSites.ts │ ├── useConfig.ts │ ├── useDeploymentOutput.ts │ ├── useDeployments.ts │ ├── useIsSiteOnline.ts │ ├── useServers.ts │ └── useSites.ts ├── index.tsx ├── lib │ ├── api.ts │ ├── auth.ts │ ├── cache.ts │ ├── color.ts │ ├── faker.ts │ └── url.ts └── types.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@raycast"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: [kevinbatdorf] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # misc 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": false 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Laravel Forge Changelog 2 | 3 | ## [Fix] - 2023-05-12 4 | - Fixes bug in displaying the ssh:// protocol string 5 | ## [Fix] - 2023-05-04 6 | 7 | - Fixes a bug in the launch command invocation. 8 | 9 | ## [Complete Rewrite] - 2023-04-17 10 | - Rewrite from scratch using modern Raycast features 11 | - Better caching with predictive pre-fetching 12 | - Passive deployment checking via BG command 13 | - Dynamic activity icons 14 | - Trigger command from anywhere with arguments 15 | - Shows system notification when deploy starts 16 | - Add view into recent deployments 17 | 18 | ## [Cache optimization] - 2022-12-29 19 | - Update initial view to show cached data immediately 20 | 21 | ## [Per-site SSH Command] - 2022-04-20 22 | - Add “Open SSH connection” command to sites 23 | 24 | ## [Better search and updated UI] - 2022-04-02 25 | - Update Raycast deprecated components 26 | - Add new transition and error views 27 | - Add server search by site and site alias 28 | - Add positional breadcrumbs to show server/site relationship 29 | - Various type improvements and code tweaks 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Forge 2 | A command center for sites managed by [Laravel Forge](https://forge.laravel.com/). 3 | 4 | Get an API token here: https://forge.laravel.com/user-profile/api 5 | 6 | Source repo: https://github.com/KevinBatdorf/laravel-forge-raycast 7 | ## Features from Forge API 8 | - View site details 9 | - View deployment status 10 | - Multiple accounts 11 | - Trigger deploy script 12 | - Reboot services 13 | 14 | ## Non-Forge API Features 15 | - Check site connectivity 16 | - Open command from raycast:// url 17 | - Background deploy status refresh with menubar display 18 | - System notification on deploy 19 | - Open terminal session 20 | - Copy meta information 21 | -------------------------------------------------------------------------------- /assets/command-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/44fa7f97be4c69736578ef1ce9b075eda3b36755/assets/command-icon.png -------------------------------------------------------------------------------- /assets/forge-icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/44fa7f97be4c69736578ef1ce9b075eda3b36755/assets/forge-icon-64.png -------------------------------------------------------------------------------- /metadata/laravel-forge-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/44fa7f97be4c69736578ef1ce9b075eda3b36755/metadata/laravel-forge-1.png -------------------------------------------------------------------------------- /metadata/laravel-forge-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/44fa7f97be4c69736578ef1ce9b075eda3b36755/metadata/laravel-forge-2.png -------------------------------------------------------------------------------- /metadata/laravel-forge-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/44fa7f97be4c69736578ef1ce9b075eda3b36755/metadata/laravel-forge-3.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://www.raycast.com/schemas/extension.json", 3 | "name": "laravel-forge", 4 | "title": "Laravel Forge", 5 | "description": "View and manage your Laravel Forge-managed servers", 6 | "icon": "command-icon.png", 7 | "author": "KevinBatdorf", 8 | "contributors": [ 9 | "macbookandrew" 10 | ], 11 | "license": "MIT", 12 | "categories": [ 13 | "Productivity", 14 | "Developer Tools" 15 | ], 16 | "commands": [ 17 | { 18 | "name": "index", 19 | "title": "Manage Servers", 20 | "subtitle": "Laravel Forge", 21 | "icon": "command-icon.png", 22 | "description": "Search and manage your Laravel Forge servers and sites", 23 | "mode": "view", 24 | "arguments": [ 25 | { 26 | "name": "server", 27 | "placeholder": "Server", 28 | "type": "text", 29 | "required": false 30 | } 31 | ] 32 | }, 33 | { 34 | "name": "check-deploy-status", 35 | "title": "Forge Deployments", 36 | "description": "Shows sites that are currently being deployed", 37 | "mode": "menu-bar", 38 | "interval": "10s" 39 | } 40 | ], 41 | "preferences": [ 42 | { 43 | "name": "laravel_forge_api_key", 44 | "type": "password", 45 | "required": true, 46 | "title": "Laravel Forge API Key", 47 | "description": "Generate from your Laravel Forge profile", 48 | "placeholder": "API Key" 49 | }, 50 | { 51 | "name": "laravel_forge_ssh_user", 52 | "type": "textfield", 53 | "required": false, 54 | "title": "Laravel Forge SSH User", 55 | "default": "forge", 56 | "description": "Change the SSH user to login with", 57 | "placeholder": "SSH User" 58 | }, 59 | { 60 | "name": "laravel_forge_api_key_two", 61 | "type": "password", 62 | "required": false, 63 | "title": "Laravel Forge API Key 2 (optional)", 64 | "description": "Optionally add a second account.", 65 | "placeholder": "API Key (optional)" 66 | }, 67 | { 68 | "name": "laravel_forge_ssh_user_two", 69 | "type": "textfield", 70 | "required": false, 71 | "title": "Laravel Forge SSH User 2 (optional)", 72 | "default": "forge", 73 | "description": "Change the SSH user to login with ont he second account", 74 | "placeholder": "SSH User" 75 | } 76 | ], 77 | "dependencies": { 78 | "@raycast/api": "^1.49.3", 79 | "@raycast/utils": "^1.5.2", 80 | "date-fns": "^2.29.3", 81 | "lodash": "^4.17.21", 82 | "node-fetch": "^3.3.1", 83 | "run-applescript": "^6.1.0", 84 | "swr": "^2.1.3" 85 | }, 86 | "devDependencies": { 87 | "@faker-js/faker": "^7.6.0", 88 | "@raycast/eslint-config": "1.0.5", 89 | "@types/lodash": "^4.14.194", 90 | "@types/node": "~18.15.11", 91 | "@types/react": "^18.0.35", 92 | "@typescript-eslint/eslint-plugin": "^5.58.0", 93 | "@typescript-eslint/parser": "^5.58.0", 94 | "eslint": "^8.38.0", 95 | "eslint-config-prettier": "^8.8.0", 96 | "node-ray": "^1.19.4", 97 | "prettier": "2.8.7", 98 | "react-devtools": "^4.27.4", 99 | "typescript": "^5.0.4" 100 | }, 101 | "scripts": { 102 | "build": "ray build -e dist", 103 | "dev": "ray develop", 104 | "fix-lint": "ray lint --fix", 105 | "lint": "ray lint", 106 | "publish": "npx @raycast/api@latest publish" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/api/Mock.ts: -------------------------------------------------------------------------------- 1 | import { createFakeServer, createFakeSite } from "../lib/faker"; 2 | import { IServer, ISite } from "../types"; 3 | 4 | export const MockServer = { 5 | getAll: async (): Promise => createFakeServer(25), 6 | }; 7 | 8 | export const MockSite = { 9 | getAll: async (serverId: IServer["id"]): Promise => 10 | createFakeSite(serverId, Math.floor(Math.random() * 3) + 1), 11 | get: async (site: ISite): Promise => site, 12 | }; 13 | -------------------------------------------------------------------------------- /src/api/Server.ts: -------------------------------------------------------------------------------- 1 | import { getPreferenceValues } from "@raycast/api"; 2 | import { sortBy } from "lodash"; 3 | import { FORGE_API_URL } from "../config"; 4 | import { IServer, ISite } from "../types"; 5 | import { apiFetch } from "../lib/api"; 6 | import { Site } from "./Site"; 7 | 8 | const defaultHeaders = { 9 | "Content-Type": "application/x-www-form-urlencoded", 10 | Accept: "application/json", 11 | }; 12 | 13 | type DynamicReboot = { 14 | serverId: number; 15 | token: string; 16 | key?: string; 17 | label?: string; 18 | }; 19 | 20 | export const Server = { 21 | async getAll() { 22 | const preferences = getPreferenceValues(); 23 | // Because we have support for two accounts, pass the key through 24 | let servers = await getServers({ 25 | tokenKey: "laravel_forge_api_key", 26 | token: preferences?.laravel_forge_api_key as string, 27 | sshUser: (preferences?.laravel_forge_ssh_user as string) || "forge", 28 | }); 29 | 30 | if (preferences?.laravel_forge_api_key_two) { 31 | const serversTwo = await getServers({ 32 | tokenKey: "laravel_forge_api_key_two", 33 | token: preferences?.laravel_forge_api_key_two as string, 34 | sshUser: (preferences?.laravel_forge_ssh_user_two as string) || "forge", 35 | }); 36 | servers = [...servers, ...serversTwo]; 37 | } 38 | return sortBy(servers, (s) => s?.name?.toLowerCase()) ?? {}; 39 | }, 40 | 41 | async reboot({ serverId, token, key = "" }: DynamicReboot) { 42 | const endpoint = key ? `servers/${serverId}/${key}/reboot` : `servers/${serverId}/reboot`; 43 | await apiFetch(`${FORGE_API_URL}/${endpoint}`, { 44 | method: "post", 45 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 46 | }); 47 | }, 48 | }; 49 | 50 | const getServers = async ({ token, tokenKey, sshUser }: { token: string; tokenKey: string; sshUser: string }) => { 51 | const { servers } = await apiFetch<{ servers: IServer[] }>(`${FORGE_API_URL}/servers`, { 52 | method: "get", 53 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 54 | }); 55 | 56 | // Get site data which will by searchable along with servers 57 | let keywordsByServer: Record> = {}; 58 | try { 59 | const sites = await Site.getSitesWithoutServer({ token }); 60 | keywordsByServer = getSiteKeywords(sites ?? []); 61 | } catch (error) { 62 | console.error(error); 63 | // fail gracefully here as it's not critical information 64 | } 65 | 66 | return servers 67 | .map((server) => { 68 | server.keywords = server?.id && keywordsByServer[server.id] ? [...keywordsByServer[server.id]] : []; 69 | server.api_token_key = tokenKey; 70 | server.ssh_user = sshUser; 71 | return server; 72 | }) 73 | .filter((s) => !s.revoked); 74 | }; 75 | 76 | const getSiteKeywords = (sites: ISite[]) => { 77 | return sites?.reduce((acc, site): Record> => { 78 | if (!site?.server_id) return acc; 79 | const keywords = [site?.name ?? "", ...(site?.aliases ?? [])]; 80 | if (!acc[site.server_id]) { 81 | acc[site.server_id] = new Set(); 82 | } 83 | keywords.forEach((keyword) => site?.server_id && acc[site.server_id].add(keyword)); 84 | return acc; 85 | }, >>{}); 86 | }; 87 | -------------------------------------------------------------------------------- /src/api/Site.ts: -------------------------------------------------------------------------------- 1 | import { sortBy } from "lodash"; 2 | import { FORGE_API_URL } from "../config"; 3 | import { ConfigFile, IDeployment, IServer, ISite } from "../types"; 4 | import { apiFetch, apiFetchText } from "../lib/api"; 5 | 6 | const defaultHeaders = { 7 | "Content-Type": "application/x-www-form-urlencoded", 8 | Accept: "application/json", 9 | }; 10 | type ServerWithToken = { serverId: IServer["id"]; token: string }; 11 | type ServerSiteWithToken = { serverId: IServer["id"]; siteId: ISite["id"]; token: string }; 12 | 13 | export const Site = { 14 | async getSitesWithoutServer({ token }: { token: string }) { 15 | if (!token) return []; 16 | const { sites } = await apiFetch<{ sites: ISite[] }>(`${FORGE_API_URL}/sites`, { 17 | method: "get", 18 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 19 | }); 20 | return sortAndFilterSites(sites); 21 | }, 22 | 23 | async getAll({ serverId, token }: ServerWithToken) { 24 | const { sites } = await apiFetch<{ sites: ISite[] }>(`${FORGE_API_URL}/servers/${serverId}/sites`, { 25 | method: "get", 26 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 27 | }); 28 | return sortAndFilterSites(sites); 29 | }, 30 | 31 | async deploy({ serverId, siteId, token }: ServerSiteWithToken) { 32 | await apiFetch(`${FORGE_API_URL}/servers/${serverId}/sites/${siteId}/deployment/deploy`, { 33 | method: "post", 34 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 35 | }); 36 | }, 37 | 38 | async getConfig({ serverId, siteId, token, type }: ServerSiteWithToken & { type: ConfigFile }) { 39 | const response = await apiFetchText(`${FORGE_API_URL}/servers/${serverId}/sites/${siteId}/${type}`, { 40 | method: "get", 41 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 42 | }); 43 | return response.trim(); 44 | }, 45 | 46 | async getDeploymentHistory({ serverId, siteId, token }: ServerSiteWithToken) { 47 | const endpoint = `${FORGE_API_URL}/servers/${serverId}/sites/${siteId}/deployment-history`; 48 | const { deployments } = await apiFetch<{ deployments: IDeployment[] }>(endpoint, { 49 | method: "get", 50 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 51 | }); 52 | return deployments; 53 | }, 54 | 55 | async getDeploymentOutput({ 56 | serverId, 57 | siteId, 58 | deploymentId, 59 | token, 60 | }: ServerSiteWithToken & { deploymentId: IDeployment["id"] }) { 61 | const endpoint = `${FORGE_API_URL}/servers/${serverId}/sites/${siteId}/deployment-history/${deploymentId}/output`; 62 | const { output } = await apiFetch<{ output: string }>(endpoint, { 63 | method: "get", 64 | headers: { ...defaultHeaders, Authorization: `Bearer ${token}` }, 65 | }); 66 | return output; 67 | }, 68 | }; 69 | 70 | export const sortAndFilterSites = (sites: ISite[]) => { 71 | const filtered = 72 | sites?.map((site) => { 73 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 74 | const { telegram_secret, ...siteData } = site; 75 | return siteData; 76 | }) ?? []; 77 | return sortBy(filtered, "name") as ISite[]; 78 | }; 79 | -------------------------------------------------------------------------------- /src/check-deploy-status.tsx: -------------------------------------------------------------------------------- 1 | import { Cache, Image, LaunchType, MenuBarExtra, launchCommand, open, updateCommandMetadata } from "@raycast/api"; 2 | import { useAllSites } from "./hooks/useAllSites"; 3 | import { ISite } from "./types"; 4 | import { runAppleScript } from "run-applescript"; 5 | import { useEffect } from "react"; 6 | 7 | const cache = new Cache(); 8 | if (!cache.get("deploying-ids")) { 9 | cache.set("deploying-ids", JSON.stringify([])); 10 | } 11 | if (!cache.get("deploying-last-status")) { 12 | cache.set("deploying-last-status", JSON.stringify([])); 13 | } 14 | const recentlyDeployed = () => JSON.parse(cache.get("deploying-ids") ?? "[]"); 15 | const lastStatus = () => JSON.parse(cache.get("deploying-last-status") ?? "[]"); 16 | 17 | interface RecentEntry { 18 | id: number; 19 | timestamp: number; 20 | } 21 | 22 | export default function Command() { 23 | const { sites: sitesTokenOne, loading: loadingOne } = useAllSites("laravel_forge_api_key"); 24 | const { sites: sitesTokenTwo, loading: loadingTwo } = useAllSites("laravel_forge_api_key_two"); 25 | const allSites = [...(sitesTokenOne ?? []), ...(sitesTokenTwo ?? [])]; 26 | const deploying = allSites.filter((site: ISite) => site.deployment_status === "deploying"); 27 | 28 | const newDeploying = deploying.filter((site: ISite) => { 29 | // find any that were null but now are deploying 30 | return lastStatus().find((last: ISite) => last.id === site.id)?.deployment_status !== "deploying"; 31 | }); 32 | 33 | if (newDeploying.length > 0) { 34 | const toShow = newDeploying[0]; 35 | // Seems the best we can do? 36 | runAppleScript(`display notification "Deploying ${toShow.name}" with title "Laravel Forge"`); 37 | } 38 | if (allSites?.length) cache.set("deploying-last-status", JSON.stringify(allSites)); 39 | 40 | // Clear out any sites that have been deploying for more than 3 minutes 41 | const IdsToKeep = recentlyDeployed().filter((entry: { id: number; timestamp: number }) => { 42 | return new Date().getTime() - entry.timestamp < 1000 * 60 * 3; 43 | }); 44 | cache.set("deploying-ids", JSON.stringify(IdsToKeep)); 45 | 46 | // Add any sites currently deploying to the cache, and update their timestamp 47 | deploying.forEach((site: ISite) => { 48 | const deployingIds = recentlyDeployed().filter((entry: { id: number }) => entry.id !== site.id); 49 | const entry: RecentEntry = { 50 | id: site.id, 51 | timestamp: new Date().getTime(), 52 | }; 53 | cache.set("deploying-ids", JSON.stringify([...deployingIds, entry])); 54 | }); 55 | 56 | const recentlyActive = recentlyDeployed() 57 | .map((entry: RecentEntry) => allSites?.find((site: ISite) => site.id === entry.id) ?? {}) 58 | .filter((site: ISite) => site?.id && site.deployment_status !== "deploying"); 59 | 60 | useEffect(() => { 61 | updateCommandMetadata({ subtitle: deploying?.length > 0 ? "Deploying..." : "Idle" }); 62 | }, [deploying]); 63 | 64 | return ( 65 | 0 ? "#19b69c" : { light: "#000000", dark: "#ffffff", adjustContrast: false }, 71 | }} 72 | tooltip="Laravel Forge" 73 | > 74 | {deploying?.length > 0 && } 75 | {deploying.map((site: ISite) => ( 76 | 82 | launchCommand({ 83 | name: "index", 84 | type: LaunchType.UserInitiated, 85 | arguments: { server: String(site.server_id) }, 86 | }) 87 | } 88 | /> 89 | ))} 90 | {recentlyActive?.length > 0 && } 91 | {recentlyActive.map((site: ISite) => ( 92 | 98 | launchCommand({ 99 | name: "index", 100 | type: LaunchType.UserInitiated, 101 | arguments: { server: String(site.server_id) }, 102 | }) 103 | } 104 | /> 105 | ))} 106 | 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/components/EmptyView.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "@raycast/api"; 2 | 3 | export const EmptyView = ({ title }: { title: string }) => ( 4 | 5 | 6 | 7 | ); 8 | -------------------------------------------------------------------------------- /src/components/actions/ServerCommands.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Action, showToast, LocalStorage, Toast } from "@raycast/api"; 2 | import { Server } from "../../api/Server"; 3 | import { IServer } from "../../types"; 4 | import { clearCache } from "../../lib/cache"; 5 | import { unwrapToken } from "../../lib/auth"; 6 | 7 | export const ServerCommands = ({ server }: { server: IServer }) => { 8 | const token = unwrapToken(server.api_token_key); 9 | return ( 10 | <> 11 | 12 | 18 | { 22 | showToast(Toast.Style.Animated, "Rebooting server..."); 23 | Server.reboot({ serverId: server.id, token }).catch(() => { 24 | showToast(Toast.Style.Failure, "Failed to reboot server"); 25 | }); 26 | }} 27 | /> 28 | {server.ip_address && } 29 | 30 | { 33 | await clearCache(); 34 | await LocalStorage.clear(); 35 | await showToast({ title: "All Forge Cache Cleared" }); 36 | }} 37 | icon={Icon.Eraser} 38 | /> 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/actions/SiteCommands.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Action, showToast, Toast } from "@raycast/api"; 2 | import { Site } from "../../api/Site"; 3 | import { IServer, ISite } from "../../types"; 4 | import { unwrapToken } from "../../lib/auth"; 5 | import { useIsSiteOnline } from "../../hooks/useIsSiteOnline"; 6 | 7 | export const SiteCommands = ({ site, server }: { site: ISite; server: IServer }) => { 8 | const token = unwrapToken(server.api_token_key); 9 | const { url } = useIsSiteOnline(site); 10 | return ( 11 | <> 12 | 17 | {/* As fas as I'm aware only sites with a repo can deploy */} 18 | {site.repository && ( 19 | { 23 | showToast(Toast.Style.Animated, "Deploying..."); 24 | Site.deploy({ siteId: site.id, serverId: server.id, token }).catch(() => 25 | showToast(Toast.Style.Failure, "Failed to trigger deploy script") 26 | ); 27 | }} 28 | /> 29 | )} 30 | {url && } 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/configs/EnvFile.tsx: -------------------------------------------------------------------------------- 1 | import { Detail } from "@raycast/api"; 2 | import { IServer, ISite } from "../../types"; 3 | import { useConfig } from "../../hooks/useConfig"; 4 | 5 | export const EnvFile = ({ site, server }: { site: ISite; server: IServer }) => { 6 | const { fileString: markdown, loading, error } = useConfig({ type: "env", site, server }); 7 | if (error) return ; 8 | if (loading) return ; 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/configs/NginxFile.tsx: -------------------------------------------------------------------------------- 1 | import { Detail } from "@raycast/api"; 2 | import { IServer, ISite } from "../../types"; 3 | import { useConfig } from "../../hooks/useConfig"; 4 | 5 | export const NginxFile = ({ site, server }: { site: ISite; server: IServer }) => { 6 | const { fileString: markdown, loading, error } = useConfig({ type: "nginx", site, server }); 7 | if (error) return ; 8 | if (loading) return ; 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/servers/ServerSingle.tsx: -------------------------------------------------------------------------------- 1 | import { ActionPanel, List, Icon, Action, showToast, Toast } from "@raycast/api"; 2 | import { Server } from "../../api/Server"; 3 | import { IServer } from "../../types"; 4 | import { unwrapToken } from "../../lib/auth"; 5 | import { SitesList } from "../sites/SitesList"; 6 | 7 | export const ServerSingle = ({ server }: { server: IServer }) => { 8 | const token = unwrapToken(server.api_token_key); 9 | 10 | return ( 11 | 12 | Sites`}> 13 | 14 | 15 | 16 | 24 | 25 | 26 | } 27 | /> 28 | 36 | 41 | 45 | 46 | } 47 | /> 48 | 56 | 57 | 58 | } 59 | /> 60 | 68 | 69 | 70 | } 71 | /> 72 | 73 | 74 | 81 | { 85 | showToast(Toast.Style.Animated, `Rebooting server...`); 86 | await Server.reboot({ serverId: server.id, token }).catch(() => { 87 | showToast(Toast.Style.Failure, `Failed to reboot server`); 88 | }); 89 | }} 90 | /> 91 | 92 | } 93 | /> 94 | {Object.entries({ mysql: "MySQL", nginx: "Nginx", postgres: "Postgres", php: "PHP" }).map(([key, label]) => { 95 | return ( 96 | 103 | { 108 | showToast(Toast.Style.Animated, `Rebooting ${label}...`); 109 | await Server.reboot({ serverId: server.id, token, key }).catch(() => { 110 | showToast(Toast.Style.Failure, `Failed to reboot ${label}`); 111 | }); 112 | }} 113 | /> 114 | 115 | } 116 | /> 117 | ); 118 | })} 119 | 120 | 121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /src/components/servers/ServersList.tsx: -------------------------------------------------------------------------------- 1 | import { Action, ActionPanel, Icon, List, Toast, showToast, useNavigation } from "@raycast/api"; 2 | import { useServers } from "../../hooks/useServers"; 3 | import { IServer } from "../../types"; 4 | import { EmptyView } from "../../components/EmptyView"; 5 | import { ServerSingle } from "./ServerSingle"; 6 | import { ServerCommands } from "../actions/ServerCommands"; 7 | import { getServerColor } from "../../lib/color"; 8 | import { useSites } from "../../hooks/useSites"; 9 | import { useEffect, useState } from "react"; 10 | 11 | export const ServersList = ({ search }: { search: string }) => { 12 | const [preLoadedServer, setPreLoadedServer] = useState(); 13 | const { servers, loading, error } = useServers(); 14 | const [incomingSearch, setIncomingSearch] = useState(search); 15 | useSites(preLoadedServer, { 16 | // Immutable 17 | revalidateIfStale: false, 18 | revalidateOnFocus: false, 19 | revalidateOnReconnect: false, 20 | }); 21 | const { push } = useNavigation(); 22 | 23 | useEffect(() => { 24 | if (!incomingSearch) return; 25 | const server = 26 | // First match by ID, then if not do a full search 27 | servers?.find((server) => server.id.toString() === incomingSearch) || 28 | servers?.find((server) => JSON.stringify(server).includes(incomingSearch)); 29 | if (!server) return; 30 | showToast(Toast.Style.Success, `Now showing: ${server?.name}` ?? `Now showing: #${server?.id}`); 31 | push(); 32 | setIncomingSearch(""); 33 | }, [incomingSearch]); 34 | 35 | const preFetchSites = (serverId: string | null) => { 36 | const server = servers?.find((server) => server.id.toString() === serverId); 37 | setPreLoadedServer(server); 38 | }; 39 | 40 | if (error?.message) { 41 | return ; 42 | } 43 | if (servers?.length === 0 && !loading) { 44 | return ; 45 | } 46 | 47 | return ( 48 | 49 | {servers?.map((server: IServer) => { 50 | return ; 51 | })} 52 | 53 | ); 54 | }; 55 | 56 | const ServerListItem = ({ server }: { server: IServer }) => { 57 | if (!server?.id) return null; 58 | return ( 59 | 71 | 72 | } 76 | /> 77 | 78 | 79 | 80 | 81 | 82 | } 83 | /> 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/sites/DeployHistory.tsx: -------------------------------------------------------------------------------- 1 | import { Action, ActionPanel, Color, Detail, Icon, List } from "@raycast/api"; 2 | import { getDeplymentStateIcon } from "../../lib/color"; 3 | import { EmptyView } from "../EmptyView"; 4 | import { IDeployment, IServer, ISite } from "../../types"; 5 | import { useDeployments } from "../../hooks/useDeployments"; 6 | import { formatDistance } from "date-fns"; 7 | import { useDeploymentOutput } from "../../hooks/useDeploymentOutput"; 8 | 9 | export const DeployHistory = ({ site, server }: { site: ISite; server: IServer }) => { 10 | const { deployments, loading } = useDeployments({ site, server }); 11 | if (!deployments?.length && !loading) { 12 | return ; 13 | } 14 | return ( 15 | 16 | {deployments?.map((deployment: IDeployment) => ( 17 | 18 | ))} 19 | 20 | ); 21 | }; 22 | 23 | const DeployHistorySingle = ({ 24 | site, 25 | server, 26 | deployment, 27 | }: { 28 | site: ISite; 29 | server: IServer; 30 | deployment: IDeployment; 31 | }) => { 32 | const { text: stateText, icon } = getDeplymentStateIcon(deployment?.status ?? "unknown"); 33 | const { id, started_at, ended_at, status } = deployment; 34 | return ( 35 | 53 | 60 | ) : ( 61 | 62 | ) 63 | } 64 | /> 65 | 66 | } 67 | /> 68 | ); 69 | }; 70 | 71 | const DeployDetails = ({ site, server, deployment }: { site: ISite; server: IServer; deployment: IDeployment }) => { 72 | const { output, loading } = useDeploymentOutput({ site, server, deployment }); 73 | const { status, commit_message, displayable_type, commit_author, commit_hash, started_at, ended_at } = deployment; 74 | return ( 75 | 81 | {commit_author && } 82 | {displayable_type && } 83 | 84 | {ended_at ? ( 85 | 86 | 90 | 91 | ) : null} 92 | 93 | {ended_at ? ( 94 | 95 | ) : null} 96 | 97 | 98 | 110 | 111 | {commit_hash && } 112 | 113 | } 114 | /> 115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /src/components/sites/SiteSingle.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, List, ActionPanel, Action, showToast, Toast } from "@raycast/api"; 2 | import { Site } from "../../api/Site"; 3 | import { IServer, ISite } from "../../types"; 4 | import { EnvFile } from "../configs/EnvFile"; 5 | import { NginxFile } from "../configs/NginxFile"; 6 | import { unwrapToken } from "../../lib/auth"; 7 | import { useIsSiteOnline } from "../../hooks/useIsSiteOnline"; 8 | import { useEffect, useState } from "react"; 9 | import { siteStatusState } from "../../lib/color"; 10 | import { DeployHistory } from "./DeployHistory"; 11 | import { useSites } from "../../hooks/useSites"; 12 | 13 | export const SiteSingle = ({ site, server }: { site: ISite; server: IServer }) => { 14 | const { sites } = useSites(server); 15 | const siteData = sites?.find((s) => s.id === site.id); 16 | const { url } = useIsSiteOnline(site); 17 | 18 | return ( 19 | 20 | {siteData?.id ? ( 21 | Sites -> ${siteData.name}`}> 22 | 30 | 31 | 32 | } 33 | /> 34 | {site.repository && } 35 | {site.repository && ( 36 | 44 | } 48 | /> 49 | 50 | } 51 | /> 52 | )} 53 | 61 | 66 | 70 | 71 | } 72 | /> 73 | 81 | } 86 | /> 87 | 91 | 92 | } 93 | /> 94 | 102 | } 106 | /> 107 | 108 | } 109 | /> 110 | {url && ( 111 | 119 | 120 | 121 | } 122 | /> 123 | )} 124 | 125 | ) : null} 126 | {siteData?.id ? ( 127 | 128 | {Object.entries({ 129 | id: "Forge site ID", 130 | server_d: "Forge server ID", 131 | name: "Site name", 132 | aliases: "Aliases", 133 | is_secured: "SSL", 134 | deployment_url: "Deployment webhook Url", 135 | tags: "Tags", 136 | directory: "Directory", 137 | repository: "Repository", 138 | quick_deploy: "Quick deploy enabled", 139 | deployment_status: "Deploy status", 140 | }).map(([key, label]) => { 141 | const value = siteData[key as keyof ISite]?.toString() ?? ""; 142 | return ( 143 | value.length > 0 && ( 144 | 151 | 152 | 153 | } 154 | /> 155 | ) 156 | ); 157 | })} 158 | 159 | ) : null} 160 | 161 | ); 162 | }; 163 | 164 | const DeployListItem = ({ siteData, server }: { siteData?: ISite; server: IServer }) => { 165 | const token = unwrapToken(server.api_token_key); 166 | const [lastDeployTime, setLastDeployTime] = useState(0); 167 | 168 | useEffect(() => { 169 | if (siteData?.deployment_status !== "deploying") return; 170 | // rerender every 1s to update the deployment status icon 171 | const id = setTimeout(() => setLastDeployTime(Date.now()), 1000); 172 | return () => clearTimeout(id); 173 | }, [siteData, lastDeployTime]); 174 | 175 | if (!siteData?.repository) return null; 176 | 177 | return ( 178 | 194 | { 198 | showToast(Toast.Style.Success, "Deploying..."); 199 | Site.deploy({ siteId: siteData.id, serverId: server.id, token }).catch(() => 200 | showToast(Toast.Style.Failure, "Failed to trigger deploy script") 201 | ); 202 | }} 203 | /> 204 | } 208 | /> 209 | 210 | } 211 | /> 212 | ); 213 | }; 214 | -------------------------------------------------------------------------------- /src/components/sites/SitesList.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, List, ActionPanel, Action } from "@raycast/api"; 2 | import { siteStatusState } from "../../lib/color"; 3 | import { IServer, ISite } from "../../types"; 4 | import { ServerCommands } from "../actions/ServerCommands"; 5 | import { SiteSingle } from "./SiteSingle"; 6 | import { SiteCommands } from "../actions/SiteCommands"; 7 | import { useSites } from "../../hooks/useSites"; 8 | import { API_RATE_LIMIT } from "../../config"; 9 | import { useIsSiteOnline } from "../../hooks/useIsSiteOnline"; 10 | import { useEffect, useState } from "react"; 11 | 12 | export const SitesList = ({ server }: { server: IServer }) => { 13 | const refreshInterval = 60_000 / API_RATE_LIMIT + 100; 14 | const { sites, loading, error } = useSites(server, { refreshInterval }); 15 | 16 | if (loading) return ; 17 | if (error) return ; 18 | if (!sites?.length) return ; 19 | 20 | return ( 21 | <> 22 | {sites.map((site: ISite) => ( 23 | 24 | ))} 25 | 26 | ); 27 | }; 28 | 29 | const SiteListItem = ({ site, server }: { site: ISite; server: IServer }) => { 30 | const [lastDeployTime, setLastDeployTime] = useState(0); 31 | const { isOnline, loading } = useIsSiteOnline(site); 32 | const { icon: stateIcon, text: stateText } = siteStatusState(site, loading ? true : isOnline); 33 | 34 | useEffect(() => { 35 | if (site?.deployment_status !== "deploying") return; 36 | // rerender every 1s to update the deployment status icon 37 | const id = setTimeout(() => setLastDeployTime(Date.now()), 1000); 38 | return () => clearTimeout(id); 39 | }, [site, lastDeployTime]); 40 | 41 | if (!site?.id) return null; 42 | return ( 43 | 52 | 53 | } 57 | /> 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | } 67 | /> 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const API_RATE_LIMIT = 20; 2 | export const FORGE_API_URL = "https://forge.laravel.com/api/v1"; 3 | 4 | export const USE_FAKE_DATA = false; 5 | -------------------------------------------------------------------------------- /src/hooks/useAllSites.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { ISite } from "../types"; 3 | import { Site } from "../api/Site"; 4 | import { unwrapToken } from "../lib/auth"; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | const fetcher = ([_, tokenKey]: [unknown, string]) => 8 | Site.getSitesWithoutServer({ 9 | token: unwrapToken(tokenKey), 10 | }); 11 | 12 | export const useAllSites = (tokenKey: string) => { 13 | const { data, error } = useSWR(["all-sites", tokenKey], fetcher, { 14 | refreshInterval: 60_000 * 5, 15 | }); 16 | 17 | return { 18 | sites: data, 19 | loading: !error && !data, 20 | error: error, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/hooks/useConfig.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { ConfigFile, IServer, ISite } from "../types"; 3 | import { Site } from "../api/Site"; 4 | import { unwrapToken } from "../lib/auth"; 5 | 6 | type key = [IServer["id"], ISite["id"], ConfigFile, IServer["api_token_key"]]; 7 | 8 | const fetcher = async ([serverId, siteId, type, tokenKey]: key) => 9 | await Site.getConfig({ type, siteId, serverId, token: unwrapToken(tokenKey) }); 10 | 11 | type IncomingProps = { server?: IServer; site?: ISite; type: ConfigFile }; 12 | export const useConfig = ({ server, site, type }: IncomingProps) => { 13 | const { data, error } = useSWR( 14 | server?.id ? [server.id, site?.id, type, server.api_token_key] : null, 15 | fetcher, 16 | { refreshInterval: 5_000 } 17 | ); 18 | return { 19 | fileString: data, 20 | loading: !error && !data, 21 | error: error, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/hooks/useDeploymentOutput.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { IDeployment, IServer, ISite } from "../types"; 3 | import { Site } from "../api/Site"; 4 | import { unwrapToken } from "../lib/auth"; 5 | 6 | type key = [IServer["id"], ISite["id"], IDeployment["id"], IServer["api_token_key"]]; 7 | 8 | const fetcher = async ([serverId, siteId, deploymentId, tokenKey]: key) => 9 | await Site.getDeploymentOutput({ siteId, serverId, deploymentId, token: unwrapToken(tokenKey) }); 10 | 11 | type IncomingProps = { 12 | server: IServer; 13 | site: ISite; 14 | deployment: IDeployment; 15 | }; 16 | 17 | export const useDeploymentOutput = ({ server, site, deployment }: IncomingProps) => { 18 | const { data, error } = useSWR( 19 | server?.id ? [server.id, site?.id, deployment.id, server.api_token_key] : null, 20 | fetcher, 21 | { 22 | refreshInterval: 5_000, 23 | } 24 | ); 25 | return { 26 | output: data, 27 | loading: !error && !data, 28 | error: error, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/hooks/useDeployments.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { IDeployment, IServer, ISite } from "../types"; 3 | import { Site } from "../api/Site"; 4 | import { unwrapToken } from "../lib/auth"; 5 | 6 | type key = [IServer["id"], ISite["id"], IServer["api_token_key"]]; 7 | 8 | const fetcher = async ([serverId, siteId, tokenKey]: key) => 9 | await Site.getDeploymentHistory({ siteId, serverId, token: unwrapToken(tokenKey) }); 10 | 11 | type IncomingProps = { server?: IServer; site?: ISite }; 12 | export const useDeployments = ({ server, site }: IncomingProps) => { 13 | const { data, error } = useSWR( 14 | server?.id ? [server.id, site?.id, server.api_token_key] : null, 15 | fetcher, 16 | { 17 | refreshInterval: 5_000, 18 | } 19 | ); 20 | return { 21 | deployments: data, 22 | loading: !error && !data, 23 | error: error, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/hooks/useIsSiteOnline.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { ISite } from "../types"; 3 | import fetch from "node-fetch"; 4 | import { findValidUrlsFromSite } from "../lib/url"; 5 | import { USE_FAKE_DATA } from "../config"; 6 | 7 | const fetcher = async (site: ISite) => { 8 | if (USE_FAKE_DATA) return site.name as string; 9 | const urls = findValidUrlsFromSite(site); 10 | // Grab the first url to respond 11 | const res = await Promise.any( 12 | // http will redirect 13 | urls.map((url) => fetch(`http://${url}`, { method: "HEAD" })) 14 | ); 15 | return res?.url; 16 | }; 17 | 18 | export const useIsSiteOnline = (site: ISite) => { 19 | const { data, error } = useSWR(site?.id ? site : null, fetcher, { 20 | refreshInterval: 1_000, 21 | }); 22 | 23 | return { 24 | isOnline: data ? true : false, 25 | url: data, 26 | loading: !error && !data, 27 | error: error, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/hooks/useServers.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import { Server } from "../api/Server"; 3 | import { IServer } from "../types"; 4 | import { USE_FAKE_DATA } from "../config"; 5 | import { MockServer } from "../api/Mock"; 6 | 7 | export const useServers = () => { 8 | const { data, error } = useSWR("servers-list", USE_FAKE_DATA ? MockServer.getAll : Server.getAll); 9 | return { 10 | servers: data, 11 | loading: !error && !data, 12 | error: error, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/useSites.ts: -------------------------------------------------------------------------------- 1 | import useSWR from "swr"; 2 | import type { SWRConfiguration } from "swr"; 3 | import { IServer, ISite } from "../types"; 4 | import { Site } from "../api/Site"; 5 | import { unwrapToken } from "../lib/auth"; 6 | import { LocalStorage } from "@raycast/api"; 7 | import { USE_FAKE_DATA } from "../config"; 8 | import { MockSite } from "../api/Mock"; 9 | 10 | type key = [IServer["id"], IServer["api_token_key"]]; 11 | 12 | const fetcher = async ([serverId, tokenKey]: key) => { 13 | if (USE_FAKE_DATA) return MockSite.getAll(serverId); 14 | const cacheKey = `sites-${serverId}`; 15 | Site.getAll({ 16 | serverId, 17 | token: unwrapToken(tokenKey), 18 | }) 19 | .then((data) => LocalStorage.setItem(cacheKey, JSON.stringify(data))) 20 | .catch(() => LocalStorage.removeItem(cacheKey)); 21 | 22 | return await backupData(cacheKey); 23 | }; 24 | 25 | export const useSites = (server?: IServer, optons: Partial = {}) => { 26 | const { data, error } = useSWR(server?.id ? [server.id, server.api_token_key] : null, fetcher, optons); 27 | 28 | return { 29 | sites: data, 30 | loading: !error && !data, 31 | error: error, 32 | }; 33 | }; 34 | 35 | const backupData = async (cacheKey: string) => { 36 | const data = await LocalStorage.getItem(cacheKey); 37 | if (typeof data === "string") return JSON.parse(data); 38 | return data; 39 | }; 40 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { SWRConfig } from "swr"; 2 | import { cacheProvider as provider } from "./lib/cache"; 3 | import { ServersList } from "./components/servers/ServersList"; 4 | import { LaunchProps } from "@raycast/api"; 5 | 6 | interface Arguments { 7 | server: string; 8 | } 9 | 10 | const LaravelForge = (props: LaunchProps<{ arguments: Arguments }>) => { 11 | const { server } = props.arguments; 12 | return ( 13 | // This cache provider only seems to work on the intiial render and for servers only. 14 | // Elsewhere uses Localstorage (which requires mannual caching outside swr) 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default LaravelForge; 22 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { LaunchType, LocalStorage, Toast, environment, popToRoot, showToast } from "@raycast/api"; 2 | import fetch, { RequestInit } from "node-fetch"; 3 | import { clearCache } from "./cache"; 4 | 5 | const doTheFetch = async (url: string, options?: RequestInit) => { 6 | const isBackground = environment.launchType === LaunchType.Background; 7 | let res; 8 | try { 9 | res = await fetch(url, options); 10 | } catch (e) { 11 | if (e instanceof Error) { 12 | console.error({ error: e, url }); 13 | isBackground || showResetToast({ title: `Error ${res?.status}: ${e.message}` }); 14 | throw new Error(e.message); 15 | } 16 | } 17 | if (!res?.ok) { 18 | console.error({ status: res?.status, text: res?.statusText, url }); 19 | isBackground || showResetToast({ title: `Error ${res?.status}: ${res?.statusText}` }); 20 | throw new Error(res?.statusText); 21 | } 22 | return res; 23 | }; 24 | 25 | export const apiFetch = async (url: string, options?: RequestInit): Promise => { 26 | const res = await doTheFetch(url, options); 27 | if (!res?.ok) return {} as T; 28 | return (await res.json()) as T; 29 | }; 30 | 31 | export const apiFetchText = async (url: string, options?: RequestInit): Promise => { 32 | const res = await doTheFetch(url, options); 33 | if (!res?.ok) return "" as T; 34 | return (await res.text()) as T; 35 | }; 36 | 37 | const showResetToast = ({ title }: { title: string }) => 38 | showToast({ 39 | style: Toast.Style.Failure, 40 | title, 41 | primaryAction: { 42 | title: "Reset cache", 43 | onAction: async () => { 44 | // not working? 45 | await clearCache(); 46 | await LocalStorage.clear(); 47 | await showToast(Toast.Style.Success, "Cache cleared"); 48 | popToRoot({ clearSearchBar: true }); 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { getPreferenceValues } from "@raycast/api"; 2 | 3 | export const unwrapToken = (tokenKey: string) => { 4 | const token = getPreferenceValues()?.[tokenKey]; 5 | return token ? token.replace(/Bearer /, "") : ""; 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/cache.ts: -------------------------------------------------------------------------------- 1 | import { environment, trash } from "@raycast/api"; 2 | import { readFileSync } from "fs"; 3 | import { writeFile } from "fs/promises"; 4 | import { resolve } from "path"; 5 | import { Cache, State } from "swr"; 6 | 7 | const CACHE_KEY = "swr-cache"; 8 | 9 | export async function clearCache() { 10 | return await trash(resolve(environment.supportPath, CACHE_KEY)); 11 | } 12 | 13 | export function cacheProvider() { 14 | const path = resolve(environment.supportPath, CACHE_KEY); 15 | 16 | let map: Map; 17 | try { 18 | const cache = readFileSync(path, { encoding: "utf-8" }); 19 | map = new Map(cache ? JSON.parse(cache.toString()) : null); 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | } catch (e: any) { 22 | if (e?.code !== "ENOENT") { 23 | console.error("Failed reading cache", e); 24 | } 25 | map = new Map(); 26 | storeCache(); 27 | } 28 | 29 | async function storeCache() { 30 | try { 31 | const data = JSON.stringify(Array.from(map.entries())); 32 | await writeFile(path, data, { encoding: "utf-8" }); 33 | } catch (e) { 34 | console.error("Failed persisting cache", e); 35 | } 36 | } 37 | const cache: Cache = { 38 | get: (key: string) => map.get(key) as State, 39 | set: (key: string, value: unknown) => { 40 | map.set(key, value); 41 | storeCache(); 42 | }, 43 | delete: (key: string) => { 44 | const existed = map.delete(key); 45 | storeCache(); 46 | return existed; 47 | }, 48 | keys: () => map.keys(), 49 | }; 50 | 51 | return cache; 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/color.ts: -------------------------------------------------------------------------------- 1 | import { Color, Icon, Image } from "@raycast/api"; 2 | import { ISite } from "../types"; 3 | 4 | export const getServerColor = (provider: string): string => { 5 | // Colors pulled from their respective sites 6 | switch (provider) { 7 | case "ocean2": 8 | return "rgb(0, 105, 255)"; 9 | case "linode": 10 | return "#02b159"; 11 | case "vultr": 12 | return "#007bfc"; 13 | case "aws": 14 | return "#ec7211"; 15 | case "hetzner": 16 | return "#d50c2d"; 17 | case "custom": 18 | return "rgb(24, 182, 155)"; // Forge color 19 | } 20 | return "rgb(24, 182, 155)"; 21 | }; 22 | 23 | export const getDeplymentStateIcon = (status: string): { text: string; icon: Image.ImageLike } => { 24 | if (status === "failed") { 25 | return { 26 | icon: { source: Icon.MinusCircleFilled, tintColor: Color.Red }, 27 | text: "deployment failed", 28 | }; 29 | } 30 | if (status === "deploying") { 31 | const progressIcons = [ 32 | Icon.Circle, 33 | Icon.CircleProgress25, 34 | Icon.CircleProgress50, 35 | Icon.CircleProgress75, 36 | Icon.CircleProgress100, 37 | ]; 38 | // based on the time we can return a progress icon 39 | const timeNow = new Date().getTime(); 40 | const source = progressIcons[Math.floor((timeNow / 1000) % 5)]; 41 | return { 42 | icon: { source, tintColor: Color.Purple }, 43 | text: "deploying...", 44 | }; 45 | } 46 | return { 47 | icon: { source: Icon.CheckCircle, tintColor: Color.Green }, 48 | text: status, 49 | }; 50 | }; 51 | 52 | export const siteStatusState = (site: ISite, online: boolean) => { 53 | if (site.deployment_status === "failed") return getDeplymentStateIcon(site.deployment_status); 54 | if (!online) { 55 | return { 56 | icon: { source: Icon.XMarkCircle, tintColor: Color.Red }, 57 | text: "offline", 58 | }; 59 | } 60 | const status = getDeplymentStateIcon(site.deployment_status || "connected"); 61 | if (status.text === "finished") { 62 | return { ...status, text: "connected" }; 63 | } 64 | return status; 65 | }; 66 | -------------------------------------------------------------------------------- /src/lib/faker.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { IServer, ISite } from "../types"; 3 | 4 | export const createFakeServer = (count = 1): IServer[] => { 5 | const fakeServer = (): IServer => ({ 6 | id: faker.datatype.number(), 7 | api_token_key: faker.datatype.string(), 8 | ssh_user: faker.internet.userName(), 9 | credential_id: faker.datatype.string(), 10 | name: faker.company.name(), 11 | type: faker.datatype.string(), 12 | provider: faker.helpers.arrayElement(["ocean2", "linode", "vultr", "aws", "hetzner", "custom"]), 13 | provider_id: faker.datatype.string(), 14 | size: faker.datatype.string(), 15 | region: faker.datatype.string(), 16 | ubuntu_version: faker.datatype.string(), 17 | db_status: faker.datatype.string(), 18 | redis_status: faker.datatype.string(), 19 | php_version: faker.datatype.string(), 20 | php_cli_version: faker.datatype.string(), 21 | opcache_status: faker.datatype.string(), 22 | database_type: faker.datatype.string(), 23 | ip_address: faker.internet.ip(), 24 | ssh_port: faker.datatype.number(), 25 | private_ip_address: faker.internet.ip(), 26 | local_public_key: faker.datatype.string(), 27 | blackfire_status: faker.datatype.string(), 28 | papertrail_status: faker.datatype.string(), 29 | revoked: faker.datatype.boolean(), 30 | created_at: faker.date.past().toISOString(), 31 | is_ready: faker.datatype.boolean(), 32 | tags: [], 33 | keywords: faker.helpers.arrayElements([faker.internet.domainName(), faker.internet.domainName()]), 34 | network: [], 35 | }); 36 | return Array.from({ length: count }, fakeServer); 37 | }; 38 | 39 | export const createFakeSite = (serverId: IServer["id"], count = 1): ISite[] => { 40 | const fakeSite = (): ISite => ({ 41 | id: faker.datatype.number(), 42 | server_id: serverId, 43 | name: faker.internet.domainName(), 44 | aliases: [], 45 | directory: faker.datatype.string(), 46 | wildcards: faker.datatype.boolean(), 47 | status: faker.datatype.string(), 48 | repository: faker.internet.url(), 49 | repository_provider: faker.datatype.string(), 50 | repository_branch: faker.datatype.string(), 51 | repository_status: faker.datatype.string(), 52 | quick_deploy: faker.datatype.boolean(), 53 | deployment_status: faker.helpers.arrayElement(["deploying", "deployed", "failed", null]), 54 | is_online: faker.datatype.boolean(), 55 | project_type: faker.datatype.string(), 56 | php_version: faker.datatype.string(), 57 | app: faker.datatype.string(), 58 | app_status: faker.datatype.string(), 59 | slack_channel: faker.datatype.string(), 60 | telegram_chat_id: faker.datatype.string(), 61 | telegram_chatTitle: faker.datatype.string(), 62 | teams_webhook_url: faker.datatype.string(), 63 | discord_webhook_url: faker.datatype.string(), 64 | created_at: faker.date.past().toISOString(), 65 | telegram_secret: faker.datatype.string(), 66 | username: faker.internet.userName(), 67 | deployment_url: faker.internet.url(), 68 | is_secured: faker.datatype.boolean(), 69 | }); 70 | return Array.from({ length: count }, fakeSite); 71 | }; 72 | -------------------------------------------------------------------------------- /src/lib/url.ts: -------------------------------------------------------------------------------- 1 | import { ISite } from "../types"; 2 | 3 | export const findValidUrlsFromSite = (site: ISite) => { 4 | const urls = [...(site?.aliases ?? []), site?.name ?? ""] 5 | // filter out any invalid urls 6 | .filter((url) => { 7 | try { 8 | new URL("https://" + url); 9 | return true; 10 | } catch (error) { 11 | return false; 12 | } 13 | }); 14 | return urls; 15 | }; 16 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IServer { 2 | api_token_key: string; 3 | ssh_user: string; 4 | id: number; 5 | credential_id?: string | null; 6 | name?: string; 7 | type?: string; 8 | provider?: string; 9 | provider_id?: string | null; 10 | size?: string; 11 | region?: string; 12 | ubuntu_version?: string; 13 | db_status?: string | null; 14 | redis_status?: string | null; 15 | php_version?: string; 16 | opcache_status?: string | null; 17 | php_cli_version?: string; 18 | database_type?: string; 19 | ip_address?: string; 20 | ssh_port?: number; 21 | private_ip_address?: string; 22 | local_public_key?: string; 23 | blackfire_status?: string | null; 24 | papertrail_status?: string | null; 25 | revoked?: boolean; 26 | created_at?: string; 27 | is_ready?: boolean; 28 | tags?: string[]; 29 | keywords?: string[]; 30 | network?: string[]; 31 | } 32 | 33 | export interface ISite { 34 | id: number; 35 | server_id: number; 36 | name?: string; 37 | aliases?: string[]; 38 | directory?: string; 39 | wildcards?: boolean; 40 | status?: string; 41 | repository?: string; 42 | repository_provider?: string; 43 | repository_branch?: string; 44 | repository_status?: string; 45 | quick_deploy?: boolean; 46 | deployment_status?: string | null; 47 | is_online?: boolean; 48 | project_type?: string; 49 | php_version?: string; 50 | app?: string | null; 51 | app_status?: string | null; 52 | slack_channel?: string | null; 53 | telegram_chat_id?: string | null; 54 | telegram_chatTitle?: string | null; 55 | teams_webhook_url?: string | null; 56 | discord_webhook_url?: string | null; 57 | created_at?: string; 58 | telegram_secret?: string; 59 | username?: string; 60 | deployment_url?: string; 61 | is_secured?: boolean; 62 | tags?: string[]; 63 | } 64 | 65 | export type ConfigFile = "env" | "nginx"; 66 | 67 | export interface IDeployment { 68 | id: number; 69 | server_id?: number; 70 | site_id?: number; 71 | type?: number; 72 | commit_hash?: string; 73 | commit_author?: string; 74 | commit_message?: string; 75 | started_at?: string; 76 | ended_at?: string; 77 | status?: string; 78 | displayable_type?: string; 79 | } 80 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Node 16", 4 | "include": ["src/**/*"], 5 | "compilerOptions": { 6 | "lib": ["es2021"], 7 | "module": "commonjs", 8 | "target": "es2021", 9 | "strict": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "jsx": "react-jsx", 15 | "resolveJsonModule": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------