Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions extensions/laravel-forge/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# Laravel Forge Changelog

## [Fix] - 2026-08-22
- Show active deployments again in the menu bar: the Forge API reports "Deploying" while the extension compared against lowercase "deploying"
## [Fix] - {PR_MERGE_DATE}

- Show active deployments in the menu bar again, including ones waiting at pending or failed during the build
- Show the last deploy's outcome in the site list and in AI Chat answers, even after the deploy ends
- New get-site and get-server AI tools return the full details of one site or server
- Restart the database under one option — Forge acts on whichever engine the server runs
- Services only offer the actions Forge accepts; there is no start
- AI tools ask for an exact site or server name, or the id from list-sites, and suggest the closest matches otherwise
- AI tools can no longer read env files or credentials, which hold secrets
- Searching sites also matches aliases and server names, and a miss lists every site instead of answering with nothing

## [AI Tools] - 2026-08-20
- Ask Laravel Forge from AI Chat: what is deploying, why a deploy failed, a site's Nginx config or logs, whether a site is up
Expand Down
251 changes: 135 additions & 116 deletions extensions/laravel-forge/package-lock.json

Large diffs are not rendered by default.

45 changes: 31 additions & 14 deletions extensions/laravel-forge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,63 +39,78 @@
}
],
"ai": {
"instructions": "Deployments belong to sites, not servers.\nFailed deploy: deployment-status names the failing site, then deployment-log. If nothing is marked failed, take the id from deployment-history.\nSite down or erroring: site-online, then site-config for nginx-error-log, then application-log.\nServer trouble, like provisioning or a service that will not start: server-events.\nWhich server, PHP version, repository or branch a site uses: list-sites.\nA site's env file is not available here.\nSite names are domains and also answer on their aliases. If a lookup returns several names, ask which one.\nOnly deploy, restart or reboot when asked. Quick deploy being off means Forge will not deploy on push by itself."
"instructions": "Deployments belong to sites, not servers.\nFailed deploy: deployment-status names the site, then deployment-log. If nothing is marked failed, take the id from deployment-history.\nSite down or erroring: site-online, then site-config for nginx-error-log, then application-log.\nServer trouble, like provisioning or a service that will not start: server-events.\nWhich server, PHP version, repository or branch a site uses: list-sites.\nA site's env file is not available here.\nOnly deploy, restart or reboot when asked. Quick deploy off means Forge will not deploy on push by itself.\nLook a site or server up first and pass the id it returns, as a string: \"2882133\", never 2882133. Given a partial name, search for it with list-sites rather than asking the user which site they mean.\nThese tools return a short row on purpose. For any other Forge setting or field, call probe-api and read the attributes. Do not infer a setting from a config file, and do not say Forge does not know until you have probed."
},
"tools": [
{
"name": "list-servers",
"title": "List Servers",
"description": "List every Laravel Forge server across the configured accounts, with provider, region, IP address and connection status"
"description": "List your Forge servers."
},
{
"name": "list-sites",
"title": "List Sites",
"description": "List the sites Laravel Forge manages, across every server or on one named server, with repository, branch and deployment status"
"description": "Search your Forge sites by part of a name, or list them all, or list one server's."
},
{
"name": "get-site",
"title": "Get a Site",
"description": "Everything Forge knows about one site. Pass the id or exact name from list-sites."
},
{
"name": "get-server",
"title": "Get a Server",
"description": "Everything Forge knows about one server, and the sites on it."
},
{
"name": "deployment-status",
"title": "Check Deployment Status",
"description": "Show which Laravel Forge sites are deploying right now and which ones have a failed deployment. Start here to work out which site a question about a failed deploy is about"
"description": "Which sites are deploying now and which have a failed deploy."
},
{
"name": "deployment-history",
"title": "Get Deployment History",
"description": "List recent deployments of a Laravel Forge site with their status, commit and timings. Use it to find an earlier failed deployment once the current status is no longer failed"
"description": "Recent deployments of a site, with status, commit and timings."
},
{
"name": "deployment-log",
"title": "Read Deployment Log",
"description": "Read the output of a Laravel Forge site's latest deployment, or of a specific deployment, to find out why it failed"
"description": "Output of a site's latest deploy, or of one deploy by id."
},
{
"name": "server-events",
"title": "Get Server Events",
"description": "List recent Laravel Forge events for a server, or read the command output of one event"
"description": "Recent events on a server, or the output of one event."
},
{
"name": "site-online",
"title": "Check If a Site Is Online",
"description": "Check whether a site hosted on Laravel Forge responds over HTTP, reporting the status code and treating a 4xx or 5xx as unhealthy"
"description": "Whether a site answers over HTTP."
},
{
"name": "site-config",
"title": "Read Site Config or Logs",
"description": "Read a Laravel Forge site's nginx config, application log, nginx error log or nginx access log"
"description": "Read a site's nginx config, application log, nginx error log or access log."
},
{
"name": "deploy-site",
"title": "Deploy a Site",
"description": "Run the deployment script for a Laravel Forge site, deploying the latest commit on its branch"
"description": "Deploy a site's latest commit. Pass the id from list-sites."
},
{
"name": "restart-service",
"title": "Restart a Service",
"description": "Start, stop or restart php, nginx, mysql or redis on a Laravel Forge server, named directly or by a site running on it"
"description": "Restart, reload or stop php, nginx, the database or redis on a server. Pass the id from list-servers."
},
{
"name": "reboot-server",
"title": "Reboot a Server",
"description": "Reboot a Laravel Forge server, named directly or by a site running on it, taking every site on it down until it comes back"
"description": "Reboot a server. Every site on it goes down until it returns. Pass the id from list-servers."
},
{
"name": "probe-api",
"title": "Probe the Forge API",
"description": "Read any Forge API path with a GET. Use it for anything the other tools do not return."
}
],
"preferences": [
Expand Down Expand Up @@ -135,7 +150,8 @@
}
],
"dependencies": {
"@raycast/api": "^1.104.24",
"@raycast/api": "^2.0.5",
"@raycast/utils": "^2.3.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unused utility dependency added

@raycast/utils is newly declared but is not imported anywhere under src/, adding unnecessary installation and maintenance overhead. Remove it from the manifest and lockfile unless the extension uses it.

Rule Used: What: Every dependency listed in package.json must... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/laravel-forge/package.json
Line: 154

Comment:
**Unused utility dependency added**

`@raycast/utils` is newly declared but is not imported anywhere under `src/`, adding unnecessary installation and maintenance overhead. Remove it from the manifest and lockfile unless the extension uses it.

**Rule Used:** What: Every dependency listed in package.json must... ([source](https://app.greptile.com/raycast/-/custom-context?memory=bffc60eb-f9f2-4219-b804-76e29e267d43))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

"date-fns": "^4.4.0",
"lodash": "^4.17.21",
"run-applescript": "^7.1.0",
Expand All @@ -159,6 +175,7 @@
"publish": "npx @raycast/api@latest publish"
},
"platforms": [
"macOS"
"macOS",
"Windows"
]
}
24 changes: 20 additions & 4 deletions extensions/laravel-forge/src/api/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@ import { IEvent, IServer, ISite } from "../types";
import { flatten, getCollection, getResource, postAction } from "../lib/forge";
import { Site } from "./Site";

export type ServiceAction = "reboot" | "start" | "stop";
export type ServiceAction = "reboot" | "reload" | "stop";
export type Service = "php" | "nginx" | "database" | "redis";

// Forge has no start: a stopped service comes back with reboot, and anything else 422s
export const SERVICE_ACTIONS: Record<Service, ServiceAction[]> = {
php: ["reboot", "reload"],
nginx: ["reboot", "stop"],
database: ["reboot", "stop"],
redis: ["reboot"],
};

// Forge's mysql and postgres endpoints both act on whichever engine the server runs
export const databaseService = (server: IServer) => {
if (!server.database_type) throw new Error(`${server.name ?? server.id} has no database installed.`);
return server.database_type.startsWith("postgres") ? "postgres" : "mysql";
};

type RunAction = {
server: IServer;
token: string;
action?: ServiceAction;
service?: string;
service?: Service;
};

type ServerWithToken = { server: IServer; token: string };
Expand All @@ -37,8 +52,9 @@ export const Server = {
},

async runAction({ server, token, action = "reboot", service }: RunAction) {
const endpoint = service
? `orgs/${server.org_slug}/servers/${server.id}/services/${service}/actions`
const slug = service === "database" ? databaseService(server) : service;
const endpoint = slug
? `orgs/${server.org_slug}/servers/${server.id}/services/${slug}/actions`
: `orgs/${server.org_slug}/servers/${server.id}/actions`;
// PHP runs one pool per installed version, so the action has to name one
const payload = service === "php" ? { action, version: server.php_version } : { action };
Expand Down
4 changes: 3 additions & 1 deletion extensions/laravel-forge/src/api/Site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ export const Site = {
};

// The API sends "Deploying" despite its documented lowercase status enums.
export const deploymentStatus = (status?: string | null) => status?.toLowerCase() ?? null;

export const sortAndFilterSites = (sites: ISite[]) =>
sortBy(sites ?? [], "name").map((site) => ({
...site,
deployment_status: site.deployment_status?.toLowerCase() ?? null,
deployment_status: deploymentStatus(site.deployment_status),
})) as ISite[];
26 changes: 16 additions & 10 deletions extensions/laravel-forge/src/components/servers/ServerSingle.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { ActionPanel, List, Icon, Action, showToast, Toast } from "@raycast/api";
import { Server, ServiceAction } from "../../api/Server";
import { SERVICE_ACTIONS, Server, Service, ServiceAction } from "../../api/Server";
import { IServer } from "../../types";
import { unwrapToken } from "../../lib/auth";
import { SitesList } from "../sites/SitesList";
import { ServerEvents } from "./ServerEvents";

const serviceActions: { action: ServiceAction; verb: string; running: string }[] = [
{ action: "reboot", verb: "Reboot", running: "Rebooting" },
{ action: "start", verb: "Start", running: "Starting" },
{ action: "stop", verb: "Stop", running: "Stopping" },
const verbs: Record<ServiceAction, { verb: string; running: string }> = {
reboot: { verb: "Reboot", running: "Rebooting" },
reload: { verb: "Reload", running: "Reloading" },
stop: { verb: "Stop", running: "Stopping" },
};

const services: { key: Service; label: string }[] = [
{ key: "database", label: "Database" },
{ key: "nginx", label: "Nginx" },
{ key: "php", label: "PHP" },
];

export const ServerSingle = ({ server }: { server: IServer }) => {
Expand Down Expand Up @@ -109,23 +115,23 @@ export const ServerSingle = ({ server }: { server: IServer }) => {
</ActionPanel>
}
/>
{Object.entries({ mysql: "MySQL", nginx: "Nginx", postgres: "Postgres", php: "PHP" }).map(([key, label]) => {
{services.map(({ key, label }) => {
return (
<List.Item
id={key}
key={key}
title={label}
icon={Icon.ArrowClockwise}
accessories={[{ text: "reboot, start or stop" }]}
accessories={[{ text: SERVICE_ACTIONS[key].join(" or ") }]}
actions={
<ActionPanel>
{serviceActions.map(({ action, verb, running }) => (
{SERVICE_ACTIONS[key].map((action) => (
<Action
key={action}
icon={action === "stop" ? Icon.Stop : Icon.ArrowClockwise}
title={`${verb} ${label}`}
title={`${verbs[action].verb} ${label}`}
onAction={async () => {
showToast(Toast.Style.Animated, `${running} ${label}...`);
showToast(Toast.Style.Animated, `${verbs[action].running} ${label}...`);
await Server.runAction({ server, token, action, service: key }).catch(() => {
showToast(Toast.Style.Failure, `Failed to ${action} ${label}`);
});
Expand Down
24 changes: 20 additions & 4 deletions extensions/laravel-forge/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { LaunchType, LocalStorage, Toast, captureException, environment, popToRo
import { clearCache } from "./cache";

const doTheFetch = async (url: string, options?: RequestInit) => {
const isBackground = environment.launchType === LaunchType.Background;
// A tool's thrown message already reaches the model; a toast on top is noise
const silent = environment.launchType === LaunchType.Background || environment.entryPointType === "tool";
let res;
try {
res = await fetch(url, options);
} catch (e) {
if (e instanceof Error) {
console.error({ error: e, url });
captureException(e);
if (!isBackground) showResetToast({ title: `Error ${res?.status}: ${e.message}` });
if (!silent) showResetToast({ title: `Error ${res?.status}: ${e.message}` });
throw new Error(e.message);
}
}
Expand All @@ -20,9 +21,24 @@ const doTheFetch = async (url: string, options?: RequestInit) => {
const title = rejectedToken
? "Forge rejected the API token. Create a v2 token with the scopes you need."
: `Error ${res?.status}: ${res?.statusText}`;
if (!isBackground) showResetToast({ title });
if (!silent) showResetToast({ title });
captureException(new Error(`${res?.status} ${res?.statusText}: ${url}`));
throw new Error(res?.statusText);
// Forge explains itself in the body: a 400 names the filters it does allow
const detail = await res
?.text()
.then((body) => {
try {
return String((JSON.parse(body) as { message?: string }).message ?? body);
} catch {
return body;
}
})
.catch(() => "");
throw new Error(
[`${res?.status ?? "network"} ${res?.statusText || "request failed"}: ${url}`, detail?.slice(0, 300)]
.filter(Boolean)
.join(" — "),
);
}
return res;
};
Expand Down
2 changes: 1 addition & 1 deletion extensions/laravel-forge/src/lib/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const getCollection = async (path: string, token: string, { pages = PAGE_
if (!cursor) break;
}

return { items, included };
return { items, included, nextCursor: cursor };
};

export const getResource = async (path: string, token: string) => {
Expand Down
9 changes: 6 additions & 3 deletions extensions/laravel-forge/src/tools/deploy-site.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { Tool } from "@raycast/api";
import { Site } from "../api/Site";
import { repositoryLabel } from "../lib/url";
import { findSite } from "./helpers";
import { findSite, resolveForConfirmation } from "./helpers";

type Input = {
/**
* Name of the site to deploy, as shown in Forge (for example "example.com").
* The site's id as a string, for example "2882133", or its exact name. Names repeat across
* servers, so a partial name is refused.
*/
site: string;
};

export const confirmation: Tool.Confirmation<Input> = async ({ site }) => {
const { site: found, server } = await findSite(site);
const match = await resolveForConfirmation(() => findSite(site));
if (!match) return { message: `Deploy "${site}"?` };
const { site: found, server } = match;
return {
message: `Deploy ${found.name}?`,
info: [
Expand Down
2 changes: 1 addition & 1 deletion extensions/laravel-forge/src/tools/deployment-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { findSite } from "./helpers";

type Input = {
/**
* Name of the site, as shown in Forge (for example "example.com").
* The site's id as a string, for example "2882133", or its exact name.
*/
site: string;
};
Expand Down
4 changes: 2 additions & 2 deletions extensions/laravel-forge/src/tools/deployment-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { findSite, tail } from "./helpers";

type Input = {
/**
* Name of the site, as shown in Forge (for example "example.com").
* The site's id as a string, for example "2882133", or its exact name.
*/
site: string;
/**
* Id of a specific deployment. Leave empty for the most recent deployment.
* Id of one deployment. Leave empty for the latest.
*/
deploymentId?: number;
};
Expand Down
24 changes: 15 additions & 9 deletions extensions/laravel-forge/src/tools/deployment-status.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { allSites } from "./helpers";
import { allSites, siteDeploymentStatus } from "./helpers";

// A deploy waits at pending before queued, and a broken build lands on failed-build
const IN_FLIGHT = ["pending", "queued", "deploying"];
const FAILED = ["failed", "failed-build"];

export default async function tool() {
const sites = await allSites();
return {
deploying: sites
.filter(({ site }) => site.deployment_status === "deploying")
.map(({ site, server }) => ({ site: site.name, server: server.name })),
failed: sites
.filter(({ site }) => site.deployment_status === "failed")
.map(({ site, server }) => ({ site: site.name, server: server.name })),
};
const listed = (wanted: string[]) =>
sites
.filter((match) => wanted.includes(siteDeploymentStatus(match.site) ?? ""))
.map((match) => ({
site: match.site.name,
server: match.server.name,
status: siteDeploymentStatus(match.site),
}));

return { deploying: listed(IN_FLIGHT), failed: listed(FAILED) };
}
Loading
Loading