├── .prettierrc
├── .eslintrc.json
├── .github
└── FUNDING.yml
├── assets
├── command-icon.png
└── forge-icon-64.png
├── metadata
├── laravel-forge-1.png
├── laravel-forge-2.png
└── laravel-forge-3.png
├── src
├── config.ts
├── components
│ ├── EmptyView.tsx
│ ├── configs
│ │ ├── EnvFile.tsx
│ │ └── NginxFile.tsx
│ ├── actions
│ │ ├── SiteCommands.tsx
│ │ └── ServerCommands.tsx
│ ├── sites
│ │ ├── SitesList.tsx
│ │ ├── DeployHistory.tsx
│ │ └── SiteSingle.tsx
│ └── servers
│ │ ├── ServersList.tsx
│ │ └── ServerSingle.tsx
├── lib
│ ├── auth.ts
│ ├── url.ts
│ ├── cache.ts
│ ├── api.ts
│ ├── color.ts
│ └── faker.ts
├── api
│ ├── Mock.ts
│ ├── Site.ts
│ └── Server.ts
├── hooks
│ ├── useServers.ts
│ ├── useAllSites.ts
│ ├── useDeployments.ts
│ ├── useConfig.ts
│ ├── useIsSiteOnline.ts
│ ├── useDeploymentOutput.ts
│ └── useSites.ts
├── index.tsx
├── types.ts
└── check-deploy-status.tsx
├── .gitignore
├── tsconfig.json
├── README.md
├── CHANGELOG.md
└── package.json
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "singleQuote": false
4 | }
5 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/assets/command-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/HEAD/assets/command-icon.png
--------------------------------------------------------------------------------
/assets/forge-icon-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/HEAD/assets/forge-icon-64.png
--------------------------------------------------------------------------------
/metadata/laravel-forge-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/HEAD/metadata/laravel-forge-1.png
--------------------------------------------------------------------------------
/metadata/laravel-forge-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/HEAD/metadata/laravel-forge-2.png
--------------------------------------------------------------------------------
/metadata/laravel-forge-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KevinBatdorf/laravel-forge-raycast/HEAD/metadata/laravel-forge-3.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/src/components/EmptyView.tsx:
--------------------------------------------------------------------------------
1 | import { List } from "@raycast/api";
2 |
3 | export const EmptyView = ({ title }: { title: string }) => (
4 |
5 |
6 |
7 | );
8 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------