@francescomalatesta/laravel-forge-mcp 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/tools/heartbeats/create-heartbeat.js +39 -0
- package/dist/tools/heartbeats/delete-heartbeat.js +29 -0
- package/dist/tools/heartbeats/list-heartbeats.js +44 -0
- package/dist/tools/heartbeats/shared.js +36 -0
- package/dist/tools/heartbeats/update-heartbeat.js +48 -0
- package/dist/tools/load-balancing/get-load-balancer.js +29 -0
- package/dist/tools/load-balancing/shared.js +27 -0
- package/dist/tools/load-balancing/update-load-balancer.js +61 -0
- package/dist/tools/monitors/create-monitor.js +62 -0
- package/dist/tools/monitors/delete-monitor.js +41 -0
- package/dist/tools/monitors/list-monitors.js +57 -0
- package/dist/tools/monitors/shared.js +28 -0
- package/dist/tools/nginx-templates/create-nginx-template.js +35 -0
- package/dist/tools/nginx-templates/delete-nginx-template.js +28 -0
- package/dist/tools/nginx-templates/list-nginx-templates.js +50 -0
- package/dist/tools/nginx-templates/shared.js +25 -0
- package/dist/tools/nginx-templates/update-nginx-template.js +42 -0
- package/dist/tools/package-credentials/delete-package-credentials.js +41 -0
- package/dist/tools/package-credentials/list-package-credentials.js +47 -0
- package/dist/tools/package-credentials/set-package-credentials.js +73 -0
- package/dist/tools/package-credentials/shared.js +36 -0
- package/dist/tools/registry.js +34 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,11 @@ npm run build
|
|
|
106
106
|
| `forge_create_certificate` | sites | Let's Encrypt, existing, CSR or cloned certificate, waiting until installed |
|
|
107
107
|
| `forge_run_certificate_action` | sites | Activate or deactivate a certificate |
|
|
108
108
|
| `forge_delete_certificate` | sites | Delete a certificate |
|
|
109
|
+
| `forge_list_package_credentials` | sites | Credentials for private Composer repositories and npm registries (secrets hidden) |
|
|
110
|
+
| `forge_set_package_credentials` | sites | Add or replace Composer or npm credentials |
|
|
111
|
+
| `forge_delete_package_credentials` | sites | Remove Composer or npm credentials |
|
|
112
|
+
| `forge_get_load_balancer` | sites | Servers a load-balanced site sends traffic to |
|
|
113
|
+
| `forge_update_load_balancer` | sites | Replace the nodes and balancing method, take nodes out of rotation |
|
|
109
114
|
| `forge_run_service_action` | servers | Restart or stop Nginx, MySQL, Postgres, Redis, Supervisor; restart or reload PHP-FPM |
|
|
110
115
|
| `forge_run_server_action` | servers | Reboot or power-cycle a server |
|
|
111
116
|
| `forge_get_server_log` | servers | End of a server log (Nginx, PHP-FPM, MySQL, cron, daemons) |
|
|
@@ -113,6 +118,10 @@ npm run build
|
|
|
113
118
|
| `forge_update_server` | servers | Rename, change IP addresses, timezone or tags |
|
|
114
119
|
| `forge_get_server_network` | servers | Servers that can reach each other on the private network |
|
|
115
120
|
| `forge_update_server_network` | servers | Set the server network |
|
|
121
|
+
| `forge_list_nginx_templates` | servers | Custom Nginx templates for new sites |
|
|
122
|
+
| `forge_create_nginx_template` | servers | Save an Nginx template |
|
|
123
|
+
| `forge_update_nginx_template` | servers | Rename or replace an Nginx template |
|
|
124
|
+
| `forge_delete_nginx_template` | servers | Delete an Nginx template |
|
|
116
125
|
| `forge_list_archived_servers` | servers | Archived servers |
|
|
117
126
|
| `forge_archive_server` | servers | Archive a server (Forge stops managing it) |
|
|
118
127
|
| `forge_unarchive_server` | servers | Restore an archived server |
|
|
@@ -192,6 +201,13 @@ npm run build
|
|
|
192
201
|
| `forge_delete_ssh_key` | security | Revoke an SSH key |
|
|
193
202
|
| `forge_get_server_public_key` | security | The server's own public key (for Git providers, other servers) |
|
|
194
203
|
| `forge_regenerate_server_key` | security | Replace the server's own key pair |
|
|
204
|
+
| `forge_list_heartbeats` | monitoring | Heartbeats of a site and whether they are missing |
|
|
205
|
+
| `forge_create_heartbeat` | monitoring | Alert when a scheduled task stops pinging |
|
|
206
|
+
| `forge_update_heartbeat` | monitoring | Change name, schedule or grace period |
|
|
207
|
+
| `forge_delete_heartbeat` | monitoring | Delete a heartbeat |
|
|
208
|
+
| `forge_list_monitors` | monitoring | CPU, disk and memory monitors and their alert state |
|
|
209
|
+
| `forge_create_monitor` | monitoring | Alert by email when a metric crosses a threshold |
|
|
210
|
+
| `forge_delete_monitor` | monitoring | Remove a monitor |
|
|
195
211
|
| `forge_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
|
|
196
212
|
| `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
|
|
197
213
|
| `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
|
|
5
|
+
import { REDACTED } from '../shared/secrets.js';
|
|
6
|
+
import { cronInput, formatHeartbeat, frequencyInput, gracePeriodInput, heartbeatOutput, heartbeatsPath, scheduleBody } from './shared.js';
|
|
7
|
+
export const createHeartbeat = defineTool({
|
|
8
|
+
name: 'forge_create_heartbeat',
|
|
9
|
+
title: 'Create heartbeat',
|
|
10
|
+
description: 'Create a heartbeat: Forge alerts when the job does not request its ping URL on schedule (e.g. `->thenPing($url)` on a Laravel scheduled task). Pass `frequency` or `cron`.',
|
|
11
|
+
toolset: 'monitoring',
|
|
12
|
+
operations: ['organizations.servers.sites.heartbeats.store'],
|
|
13
|
+
permissions: ['site:manage-heartbeats'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: false,
|
|
16
|
+
idempotent: false,
|
|
17
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
...siteScopeInput,
|
|
20
|
+
name: z.string().min(1).max(255).describe('Name, e.g. "Nightly reports".'),
|
|
21
|
+
frequency: frequencyInput.optional(),
|
|
22
|
+
cron: cronInput.optional(),
|
|
23
|
+
grace_period: gracePeriodInput.default(5),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: {
|
|
26
|
+
heartbeat: heartbeatOutput,
|
|
27
|
+
},
|
|
28
|
+
async handler(args, { client, config, organization, signal }) {
|
|
29
|
+
const response = await client.post(heartbeatsPath(organization(args.organization), args.server, args.site), {
|
|
30
|
+
body: { name: args.name, grace_period: args.grace_period, ...scheduleBody(args.frequency, args.cron) },
|
|
31
|
+
signal,
|
|
32
|
+
});
|
|
33
|
+
const heartbeat = formatHeartbeat(flattenSingle(response.data), config.allowSecrets);
|
|
34
|
+
return {
|
|
35
|
+
structured: { heartbeat },
|
|
36
|
+
summary: `Created heartbeat ${heartbeat.name} (ID ${heartbeat.id}).${heartbeat.ping_url === REDACTED ? ' Its ping URL is hidden: enable FORGE_ALLOW_SECRETS or copy it from the Forge dashboard.' : ` Ping URL: ${heartbeat.ping_url}`}`,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
4
|
+
import { HEARTBEAT_NOT_FOUND_HINT, heartbeatInput, heartbeatsPath } from './shared.js';
|
|
5
|
+
export const deleteHeartbeat = defineTool({
|
|
6
|
+
name: 'forge_delete_heartbeat',
|
|
7
|
+
title: 'Delete heartbeat',
|
|
8
|
+
description: 'Delete a heartbeat: Forge stops alerting when the job does not ping.',
|
|
9
|
+
toolset: 'monitoring',
|
|
10
|
+
operations: ['organizations.servers.sites.heartbeats.destroy'],
|
|
11
|
+
permissions: ['site:manage-heartbeats'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: true,
|
|
14
|
+
idempotent: true,
|
|
15
|
+
notFoundHint: HEARTBEAT_NOT_FOUND_HINT,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
...siteScopeInput,
|
|
18
|
+
heartbeat: heartbeatInput,
|
|
19
|
+
},
|
|
20
|
+
outputSchema: {
|
|
21
|
+
deleted: z.boolean(),
|
|
22
|
+
},
|
|
23
|
+
async handler(args, { client, organization, signal }) {
|
|
24
|
+
await client.delete(`${heartbeatsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.heartbeat))}`, {
|
|
25
|
+
signal,
|
|
26
|
+
});
|
|
27
|
+
return { structured: { deleted: true }, summary: `Deleted heartbeat ${args.heartbeat}.` };
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { paginationInput, paginationOutput, paginationSummary } from '../shared/schemas.js';
|
|
6
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
|
|
7
|
+
import { formatHeartbeat, heartbeatInput, heartbeatOutput, heartbeatsPath } from './shared.js';
|
|
8
|
+
export const listHeartbeats = defineTool({
|
|
9
|
+
name: 'forge_list_heartbeats',
|
|
10
|
+
title: 'List heartbeats',
|
|
11
|
+
description: 'List the heartbeats of a site (scheduled tasks that must ping Forge regularly) with their status: missing means the job stopped running. Get one with `heartbeat`.',
|
|
12
|
+
toolset: 'monitoring',
|
|
13
|
+
operations: ['organizations.servers.sites.heartbeats.index', 'organizations.servers.sites.heartbeats.show'],
|
|
14
|
+
permissions: ['server:view'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
...siteScopeInput,
|
|
19
|
+
heartbeat: heartbeatInput.optional().describe('Return only this heartbeat.'),
|
|
20
|
+
...paginationInput,
|
|
21
|
+
},
|
|
22
|
+
outputSchema: {
|
|
23
|
+
heartbeats: z.array(heartbeatOutput),
|
|
24
|
+
...paginationOutput,
|
|
25
|
+
},
|
|
26
|
+
async handler(args, { client, config, organization, signal }) {
|
|
27
|
+
const base = heartbeatsPath(organization(args.organization), args.server, args.site);
|
|
28
|
+
const format = (flat) => formatHeartbeat(flat, config.allowSecrets);
|
|
29
|
+
if (args.heartbeat !== undefined) {
|
|
30
|
+
const heartbeat = format(await readResource(client, `${base}/${encodeURIComponent(String(args.heartbeat))}`, signal));
|
|
31
|
+
return { structured: { heartbeats: [heartbeat], next_cursor: null, has_more: false }, summary: `Heartbeat ${heartbeat.name} is ${heartbeat.status}.` };
|
|
32
|
+
}
|
|
33
|
+
const response = await client.get(base, { query: { page: { size: args.page_size, cursor: args.cursor } }, signal });
|
|
34
|
+
const page = flattenCollection(response.data);
|
|
35
|
+
const heartbeats = page.items.map(format);
|
|
36
|
+
const missing = heartbeats.filter((heartbeat) => heartbeat.status === 'missing');
|
|
37
|
+
return {
|
|
38
|
+
structured: { heartbeats, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
39
|
+
summary: heartbeats.length === 0
|
|
40
|
+
? 'No heartbeats found.'
|
|
41
|
+
: `Found ${heartbeats.length} heartbeat(s)${missing.length > 0 ? `; missing: ${missing.map((h) => h.name).join(', ')}` : ''}.${paginationSummary(page.nextCursor)}`,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ToolInputError } from '../errors.js';
|
|
3
|
+
import { redact } from '../shared/secrets.js';
|
|
4
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
5
|
+
import { sitePath } from '../shared/site-scope.js';
|
|
6
|
+
export function heartbeatsPath(org, server, site) {
|
|
7
|
+
return `${sitePath(org, server, site)}/heartbeats`;
|
|
8
|
+
}
|
|
9
|
+
export const heartbeatInput = idInput('Heartbeat ID. Use forge_list_heartbeats to find it.');
|
|
10
|
+
export const HEARTBEAT_NOT_FOUND_HINT = 'Check the heartbeat ID with forge_list_heartbeats.';
|
|
11
|
+
/** Minutes between expected pings (-1 = custom cron). */
|
|
12
|
+
const FREQUENCIES = [1, 5, 10, 30, 60, 1440, 10080, 312480];
|
|
13
|
+
const GRACE_PERIODS = [1, 2, 5, 10, 30, 60];
|
|
14
|
+
export const frequencyInput = z.literal(FREQUENCIES).describe('Minutes between expected pings: 1, 5, 10, 30, 60, 1440 (daily), 10080 (weekly) or 312480. Use `cron` instead for other schedules.');
|
|
15
|
+
export const cronInput = z.string().min(1).describe('Cron expression of the expected pings, e.g. "0 3 * * *" (instead of `frequency`).');
|
|
16
|
+
export const gracePeriodInput = z.literal(GRACE_PERIODS).describe('Minutes of delay tolerated before the heartbeat is reported missing: 1, 2, 5, 10, 30 or 60.');
|
|
17
|
+
/** Maps `frequency` / `cron` to the API fields (frequency -1 means custom). */
|
|
18
|
+
export function scheduleBody(frequency, cron) {
|
|
19
|
+
if ((frequency === undefined) === (cron === undefined))
|
|
20
|
+
throw new ToolInputError('Pass either `frequency` or `cron`.');
|
|
21
|
+
return cron === undefined ? { frequency } : { frequency: -1, custom_frequency: cron };
|
|
22
|
+
}
|
|
23
|
+
const FIELDS = ['id', 'name', 'status', 'frequency', 'custom_frequency', 'grace_period', 'ping_url'];
|
|
24
|
+
export const heartbeatOutput = z.looseObject({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
name: z.string().nullable(),
|
|
27
|
+
status: z.string().nullable().describe('pending (no ping yet), beating or missing.'),
|
|
28
|
+
frequency: z.number().nullable().describe('Minutes between expected pings; -1 = custom cron.'),
|
|
29
|
+
custom_frequency: z.string().nullable(),
|
|
30
|
+
grace_period: z.number().nullable(),
|
|
31
|
+
ping_url: z.string().nullable().describe('URL the job must request after each run (hidden unless secrets are allowed).'),
|
|
32
|
+
});
|
|
33
|
+
/** The ping URL lets anyone report the job as alive: it is a secret. */
|
|
34
|
+
export function formatHeartbeat(flat, allowSecrets) {
|
|
35
|
+
return redact(pick(flat, FIELDS), ['ping_url'], allowSecrets);
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { ToolInputError } from '../errors.js';
|
|
5
|
+
import { readResource } from '../shared/read.js';
|
|
6
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
7
|
+
import { cronInput, formatHeartbeat, frequencyInput, gracePeriodInput, HEARTBEAT_NOT_FOUND_HINT, heartbeatInput, heartbeatOutput, heartbeatsPath, scheduleBody, } from './shared.js';
|
|
8
|
+
export const updateHeartbeat = defineTool({
|
|
9
|
+
name: 'forge_update_heartbeat',
|
|
10
|
+
title: 'Update heartbeat',
|
|
11
|
+
description: 'Change the name, schedule (`frequency` or `cron`) or grace period of a heartbeat. Fields not passed keep their value.',
|
|
12
|
+
toolset: 'monitoring',
|
|
13
|
+
operations: ['organizations.servers.sites.heartbeats.update', 'organizations.servers.sites.heartbeats.show'],
|
|
14
|
+
permissions: ['site:manage-heartbeats', 'server:view'],
|
|
15
|
+
readOnly: false,
|
|
16
|
+
destructive: false,
|
|
17
|
+
idempotent: true,
|
|
18
|
+
notFoundHint: HEARTBEAT_NOT_FOUND_HINT,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
...siteScopeInput,
|
|
21
|
+
heartbeat: heartbeatInput,
|
|
22
|
+
name: z.string().min(1).max(255).optional().describe('New name.'),
|
|
23
|
+
frequency: frequencyInput.optional(),
|
|
24
|
+
cron: cronInput.optional(),
|
|
25
|
+
grace_period: gracePeriodInput.optional(),
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
heartbeat: heartbeatOutput,
|
|
29
|
+
},
|
|
30
|
+
async handler(args, { client, config, organization, signal }) {
|
|
31
|
+
if (args.name === undefined && args.frequency === undefined && args.cron === undefined && args.grace_period === undefined) {
|
|
32
|
+
throw new ToolInputError('Pass at least one setting to change.');
|
|
33
|
+
}
|
|
34
|
+
const path = `${heartbeatsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.heartbeat))}`;
|
|
35
|
+
const current = await readResource(client, path, signal);
|
|
36
|
+
const schedule = args.frequency !== undefined || args.cron !== undefined
|
|
37
|
+
? scheduleBody(args.frequency, args.cron)
|
|
38
|
+
: current.frequency === -1
|
|
39
|
+
? { frequency: -1, custom_frequency: current.custom_frequency }
|
|
40
|
+
: { frequency: current.frequency };
|
|
41
|
+
const response = await client.put(path, {
|
|
42
|
+
body: { name: args.name ?? current.name, grace_period: args.grace_period ?? current.grace_period, ...schedule },
|
|
43
|
+
signal,
|
|
44
|
+
});
|
|
45
|
+
const heartbeat = formatHeartbeat(flattenSingle(response.data), config.allowSecrets);
|
|
46
|
+
return { structured: { heartbeat }, summary: `Updated heartbeat ${heartbeat.name}.` };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
4
|
+
import { nodeOutput, nodesPath, readNodes } from './shared.js';
|
|
5
|
+
export const getLoadBalancer = defineTool({
|
|
6
|
+
name: 'forge_get_load_balancer',
|
|
7
|
+
title: 'Get load balancer nodes',
|
|
8
|
+
description: 'List the servers a load-balanced site sends traffic to, with weight, port and backup/down flags.',
|
|
9
|
+
toolset: 'sites',
|
|
10
|
+
operations: ['organizations.servers.sites.load-balancing-nodes.index'],
|
|
11
|
+
permissions: ['site:manage-project'],
|
|
12
|
+
readOnly: true,
|
|
13
|
+
notFoundHint: 'Check the load balancer server and site IDs with forge_list_sites.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
...siteScopeInput,
|
|
16
|
+
},
|
|
17
|
+
outputSchema: {
|
|
18
|
+
nodes: z.array(nodeOutput),
|
|
19
|
+
},
|
|
20
|
+
async handler(args, { client, organization, signal }) {
|
|
21
|
+
const nodes = await readNodes(client, nodesPath(organization(args.organization), args.server, args.site), signal);
|
|
22
|
+
return {
|
|
23
|
+
structured: { nodes },
|
|
24
|
+
summary: nodes.length === 0
|
|
25
|
+
? 'This site has no load balancing nodes.'
|
|
26
|
+
: `Traffic goes to server(s) ${nodes.map((n) => `${n.server_id} (weight ${n.weight}${n.down ? ', down' : ''}${n.backup ? ', backup' : ''})`).join(', ')}.`,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { sitePath } from '../shared/site-scope.js';
|
|
4
|
+
export function nodesPath(org, server, site) {
|
|
5
|
+
return `${sitePath(org, server, site)}/load-balancing-nodes`;
|
|
6
|
+
}
|
|
7
|
+
export const nodeOutput = z.looseObject({
|
|
8
|
+
server_id: z.string().nullable().describe('Server receiving traffic.'),
|
|
9
|
+
port: z.number().nullable(),
|
|
10
|
+
weight: z.number().nullable().describe('Relative share of traffic.'),
|
|
11
|
+
backup: z.boolean().nullable().describe('Only used when the other servers are unavailable.'),
|
|
12
|
+
down: z.boolean().nullable().describe('Out of rotation.'),
|
|
13
|
+
});
|
|
14
|
+
export function formatNode(flat) {
|
|
15
|
+
return {
|
|
16
|
+
server_id: flat.server_id === undefined || flat.server_id === null ? null : String(flat.server_id),
|
|
17
|
+
port: flat.port ?? null,
|
|
18
|
+
weight: flat.weight ?? null,
|
|
19
|
+
backup: flat.backup ?? null,
|
|
20
|
+
down: flat.down ?? null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Every node of the load balancer (one page of 100 covers any realistic pool). */
|
|
24
|
+
export async function readNodes(client, path, signal) {
|
|
25
|
+
const response = await client.get(path, { query: { page: { size: 100 } }, signal });
|
|
26
|
+
return flattenCollection(response.data).items.map(formatNode);
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
4
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
5
|
+
import { nodeOutput, nodesPath, readNodes } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_get_load_balancer';
|
|
7
|
+
const node = z.object({
|
|
8
|
+
server_id: z.number().int().describe('Server receiving traffic.'),
|
|
9
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
10
|
+
weight: z.number().int().min(1).default(1).describe('Relative share of traffic.'),
|
|
11
|
+
backup: z.boolean().default(false).describe('Only used when the other servers are unavailable.'),
|
|
12
|
+
down: z.boolean().default(false).describe('Take this server out of rotation (e.g. for maintenance).'),
|
|
13
|
+
});
|
|
14
|
+
export const updateLoadBalancer = defineTool({
|
|
15
|
+
name: 'forge_update_load_balancer',
|
|
16
|
+
title: 'Update load balancer nodes',
|
|
17
|
+
description: 'Replace the servers a load-balanced site sends traffic to, with the balancing method. Set `down: true` on a node to take it out of rotation. List every node that should stay.',
|
|
18
|
+
toolset: 'sites',
|
|
19
|
+
operations: ['organizations.servers.sites.load-balancing-nodes.update', 'organizations.servers.sites.load-balancing-nodes.index'],
|
|
20
|
+
permissions: ['site:manage-project'],
|
|
21
|
+
readOnly: false,
|
|
22
|
+
destructive: true,
|
|
23
|
+
idempotent: true,
|
|
24
|
+
async: true,
|
|
25
|
+
notFoundHint: 'Check the load balancer server and site IDs with forge_list_sites.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
...siteScopeInput,
|
|
28
|
+
nodes: z.array(node).min(1).describe('Every server that should receive traffic (replaces the list).'),
|
|
29
|
+
method: z.enum(['round_robin', 'least_conn', 'ip_hash']).default('round_robin').describe('Balancing method; ip_hash keeps each client on the same server.'),
|
|
30
|
+
keepalive_max_connections: z.number().int().min(0).max(256).optional().describe('Idle keepalive connections kept open to each server.'),
|
|
31
|
+
...waitInput(60),
|
|
32
|
+
},
|
|
33
|
+
outputSchema: {
|
|
34
|
+
...operationOutput,
|
|
35
|
+
nodes: z.array(nodeOutput).nullable(),
|
|
36
|
+
},
|
|
37
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
38
|
+
const path = nodesPath(organization(args.organization), args.server, args.site);
|
|
39
|
+
await client.put(path, {
|
|
40
|
+
body: { balancer_method: args.method, balancer_keepalive_max_connections: args.keepalive_max_connections, balancing: args.nodes },
|
|
41
|
+
signal,
|
|
42
|
+
});
|
|
43
|
+
const action = 'update the load balancer';
|
|
44
|
+
if (!args.wait) {
|
|
45
|
+
const accepted = queued(action, CHECK_WITH);
|
|
46
|
+
return { ...accepted, structured: { ...accepted.structured, nodes: null } };
|
|
47
|
+
}
|
|
48
|
+
const key = (n) => `${n.server_id}:${n.weight}:${n.backup}:${n.down}`;
|
|
49
|
+
const expected = args.nodes.map(key).sort().join();
|
|
50
|
+
const matches = (nodes) => nodes.map(key).sort().join() === expected;
|
|
51
|
+
const result = await waitFor({
|
|
52
|
+
poll: () => readNodes(client, path, signal),
|
|
53
|
+
phase: (nodes) => (matches(nodes) ? 'completed' : 'pending'),
|
|
54
|
+
describe: () => 'Waiting for the nodes to be updated',
|
|
55
|
+
timeoutSeconds: args.timeout_seconds,
|
|
56
|
+
context: { sleep, progress },
|
|
57
|
+
});
|
|
58
|
+
const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
59
|
+
return { structured: { ...done.structured, nodes: result.value ?? null }, summary: done.summary };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
5
|
+
import { readResource } from '../shared/read.js';
|
|
6
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
7
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
8
|
+
import { formatMonitor, MONITOR_TYPES, monitorOutput, monitorPhase, monitorsPath } from './shared.js';
|
|
9
|
+
const CHECK_WITH = 'forge_list_monitors';
|
|
10
|
+
export const createMonitor = defineTool({
|
|
11
|
+
name: 'forge_create_monitor',
|
|
12
|
+
title: 'Create monitor',
|
|
13
|
+
description: 'Alert by email when a server metric crosses a threshold, e.g. disk ≥ 80 (%), used_memory ≥ 90 (%), cpu_load ≥ 4 for 5 minutes. Waits until the monitor is installed unless `wait` is false.',
|
|
14
|
+
toolset: 'monitoring',
|
|
15
|
+
operations: ['organizations.servers.monitors.store', 'organizations.servers.monitors.show'],
|
|
16
|
+
permissions: ['server:create-monitors', 'server:view'],
|
|
17
|
+
readOnly: false,
|
|
18
|
+
destructive: false,
|
|
19
|
+
idempotent: false,
|
|
20
|
+
async: true,
|
|
21
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
22
|
+
inputSchema: {
|
|
23
|
+
organization: organizationInput,
|
|
24
|
+
server: serverInput,
|
|
25
|
+
type: z.enum(MONITOR_TYPES).describe('Metric: cpu_load (load average), disk (% used), free_memory or used_memory (%).'),
|
|
26
|
+
operator: z.enum(['gte', 'lte']).default('gte').describe('gte: alert at or above the threshold; lte: at or below.'),
|
|
27
|
+
threshold: z.number().describe('Threshold, e.g. 80.'),
|
|
28
|
+
minutes: z.number().int().min(1).optional().describe('Minutes the condition must last before alerting.'),
|
|
29
|
+
notify: z.string().email().describe('Email to notify.'),
|
|
30
|
+
...waitInput(60),
|
|
31
|
+
},
|
|
32
|
+
outputSchema: {
|
|
33
|
+
...operationOutput,
|
|
34
|
+
monitor: monitorOutput.nullable(),
|
|
35
|
+
},
|
|
36
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
37
|
+
const base = monitorsPath(organization(args.organization), args.server);
|
|
38
|
+
const response = await client.post(base, {
|
|
39
|
+
body: { type: args.type, operator: args.operator, threshold: args.threshold, minutes: args.minutes, notify: args.notify },
|
|
40
|
+
signal,
|
|
41
|
+
});
|
|
42
|
+
const initial = response.data?.data ? flattenSingle(response.data) : undefined;
|
|
43
|
+
const action = `monitor ${args.type} ${args.operator} ${args.threshold}`;
|
|
44
|
+
if (!initial || !args.wait) {
|
|
45
|
+
return {
|
|
46
|
+
structured: { status: 'queued', check_with: CHECK_WITH, monitor: initial ? formatMonitor(initial) : null },
|
|
47
|
+
summary: `Forge accepted the request to ${action}. Check it with ${CHECK_WITH}.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const result = await waitFor({
|
|
51
|
+
initial,
|
|
52
|
+
poll: () => readResource(client, `${base}/${encodeURIComponent(initial.id)}`, signal),
|
|
53
|
+
phase: (monitor) => monitorPhase(monitor.status),
|
|
54
|
+
describe: (monitor) => `Monitor is ${monitor.status}`,
|
|
55
|
+
timeoutSeconds: args.timeout_seconds,
|
|
56
|
+
context: { sleep, progress },
|
|
57
|
+
});
|
|
58
|
+
const monitor = formatMonitor(result.value ?? initial);
|
|
59
|
+
const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: `Monitor ID: ${monitor.id}.` });
|
|
60
|
+
return { structured: { ...done.structured, monitor }, summary: done.summary };
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { readResource } from '../shared/read.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { monitorInput, monitorsPath } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_list_monitors';
|
|
7
|
+
export const deleteMonitor = defineTool({
|
|
8
|
+
name: 'forge_delete_monitor',
|
|
9
|
+
title: 'Delete monitor',
|
|
10
|
+
description: 'Remove a monitor from a server: its alerts stop.',
|
|
11
|
+
toolset: 'monitoring',
|
|
12
|
+
operations: ['organizations.servers.monitors.destroy', 'organizations.servers.monitors.show'],
|
|
13
|
+
permissions: ['server:delete-monitors', 'server:view'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: true,
|
|
16
|
+
idempotent: true,
|
|
17
|
+
async: true,
|
|
18
|
+
notFoundHint: 'Check the monitor ID with forge_list_monitors.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
organization: organizationInput,
|
|
21
|
+
server: serverInput,
|
|
22
|
+
monitor: monitorInput,
|
|
23
|
+
...waitInput(60),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: operationOutput,
|
|
26
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
27
|
+
const path = `${monitorsPath(organization(args.organization), args.server)}/${encodeURIComponent(String(args.monitor))}`;
|
|
28
|
+
await client.delete(path, { signal });
|
|
29
|
+
const action = `delete monitor ${args.monitor}`;
|
|
30
|
+
if (!args.wait)
|
|
31
|
+
return queued(action, CHECK_WITH);
|
|
32
|
+
const result = await waitFor({
|
|
33
|
+
poll: () => orGone(() => readResource(client, path, signal)),
|
|
34
|
+
phase: (monitor) => (monitor === null ? 'completed' : 'pending'),
|
|
35
|
+
describe: (monitor) => `Monitor is ${monitor?.status ?? 'removing'}`,
|
|
36
|
+
timeoutSeconds: args.timeout_seconds,
|
|
37
|
+
context: { sleep, progress },
|
|
38
|
+
});
|
|
39
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
7
|
+
import { formatMonitor, MONITOR_TYPES, monitorInput, monitorOutput, monitorsPath } from './shared.js';
|
|
8
|
+
const SORT = ['state', '-state', 'status', '-status', 'created_at', '-created_at', 'updated_at', '-updated_at'];
|
|
9
|
+
export const listMonitors = defineTool({
|
|
10
|
+
name: 'forge_list_monitors',
|
|
11
|
+
title: 'List monitors',
|
|
12
|
+
description: 'List the resource monitors of a server (CPU load, disk, memory thresholds) and whether they are in ALERT, or get one with `monitor`.',
|
|
13
|
+
toolset: 'monitoring',
|
|
14
|
+
operations: ['organizations.servers.monitors.index', 'organizations.servers.monitors.show'],
|
|
15
|
+
permissions: ['server:view'],
|
|
16
|
+
readOnly: true,
|
|
17
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
organization: organizationInput,
|
|
20
|
+
server: serverInput,
|
|
21
|
+
monitor: monitorInput.optional().describe('Return only this monitor.'),
|
|
22
|
+
state: z.enum(['OK', 'ALERT', 'UNKNOWN']).optional().describe('Filter by state.'),
|
|
23
|
+
type: z.enum(MONITOR_TYPES).optional().describe('Filter by metric.'),
|
|
24
|
+
status: z.string().min(1).optional().describe('Filter by installation status.'),
|
|
25
|
+
notify: z.string().min(1).optional().describe('Filter by notified email.'),
|
|
26
|
+
sort: z.array(z.enum(SORT)).min(1).optional(),
|
|
27
|
+
...paginationInput,
|
|
28
|
+
},
|
|
29
|
+
outputSchema: {
|
|
30
|
+
monitors: z.array(monitorOutput),
|
|
31
|
+
...paginationOutput,
|
|
32
|
+
},
|
|
33
|
+
async handler(args, { client, organization, signal }) {
|
|
34
|
+
const base = monitorsPath(organization(args.organization), args.server);
|
|
35
|
+
if (args.monitor !== undefined) {
|
|
36
|
+
const monitor = formatMonitor(await readResource(client, `${base}/${encodeURIComponent(String(args.monitor))}`, signal));
|
|
37
|
+
return { structured: { monitors: [monitor], next_cursor: null, has_more: false }, summary: `Monitor ${monitor.type} is ${monitor.state}.` };
|
|
38
|
+
}
|
|
39
|
+
const response = await client.get(base, {
|
|
40
|
+
query: {
|
|
41
|
+
filter: { state: args.state, type: args.type, status: args.status, notify: args.notify },
|
|
42
|
+
sort: args.sort,
|
|
43
|
+
page: { size: args.page_size, cursor: args.cursor },
|
|
44
|
+
},
|
|
45
|
+
signal,
|
|
46
|
+
});
|
|
47
|
+
const page = flattenCollection(response.data);
|
|
48
|
+
const monitors = page.items.map(formatMonitor);
|
|
49
|
+
const alerting = monitors.filter((monitor) => monitor.state === 'ALERT');
|
|
50
|
+
return {
|
|
51
|
+
structured: { monitors, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
52
|
+
summary: monitors.length === 0
|
|
53
|
+
? 'No monitors found.'
|
|
54
|
+
: `Found ${monitors.length} monitor(s)${alerting.length > 0 ? `; in ALERT: ${alerting.map((m) => `${m.type} ${m.operator} ${m.threshold} (${m.id})`).join(', ')}` : ', none in alert'}.${paginationSummary(page.nextCursor)}`,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { phaseOf } from '../shared/async.js';
|
|
3
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
4
|
+
import { serverPath } from '../servers/shared.js';
|
|
5
|
+
export function monitorsPath(org, server) {
|
|
6
|
+
return `${serverPath(org, server)}/monitors`;
|
|
7
|
+
}
|
|
8
|
+
export const monitorInput = idInput('Monitor ID. Use forge_list_monitors to find it.');
|
|
9
|
+
export const MONITOR_TYPES = ['cpu_load', 'disk', 'free_memory', 'used_memory'];
|
|
10
|
+
const FIELDS = ['id', 'type', 'operator', 'threshold', 'minutes', 'notify', 'state', 'state_changed_at', 'status', 'created_at'];
|
|
11
|
+
export const monitorOutput = z.looseObject({
|
|
12
|
+
id: z.string(),
|
|
13
|
+
type: z.string().nullable().describe('cpu_load, disk (% used), free_memory or used_memory (%).'),
|
|
14
|
+
operator: z.string().nullable().describe('gte (alert at or above the threshold) or lte (at or below).'),
|
|
15
|
+
threshold: z.number().nullable(),
|
|
16
|
+
minutes: z.number().nullable().describe('Minutes the condition must last before alerting.'),
|
|
17
|
+
notify: z.string().nullable().describe('Email notified on alerts.'),
|
|
18
|
+
state: z.string().nullable().describe('OK, ALERT or UNKNOWN.'),
|
|
19
|
+
state_changed_at: z.string().nullable(),
|
|
20
|
+
status: z.string().nullable().describe('Installation status, e.g. installing, installed.'),
|
|
21
|
+
created_at: z.string().nullable(),
|
|
22
|
+
});
|
|
23
|
+
export function formatMonitor(flat) {
|
|
24
|
+
return pick(flat, FIELDS);
|
|
25
|
+
}
|
|
26
|
+
export function monitorPhase(status) {
|
|
27
|
+
return phaseOf(status, { pending: ['installing', 'updating', 'enabling', 'syncing', 'removing'], failed: ['failed', 'failed-unknown', 'failed-runner'] });
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
6
|
+
import { formatNginxTemplate, nginxTemplateContentInput, nginxTemplateOutput, nginxTemplatesPath } from './shared.js';
|
|
7
|
+
export const createNginxTemplate = defineTool({
|
|
8
|
+
name: 'forge_create_nginx_template',
|
|
9
|
+
title: 'Create Nginx template',
|
|
10
|
+
description: 'Save a custom Nginx template on a server, to use when creating sites (`nginx_template_id` in forge_create_site).',
|
|
11
|
+
toolset: 'servers',
|
|
12
|
+
operations: ['organizations.servers.nginx.templates.store'],
|
|
13
|
+
permissions: ['server:manage-nginx-templates'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: false,
|
|
16
|
+
idempotent: false,
|
|
17
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
organization: organizationInput,
|
|
20
|
+
server: serverInput,
|
|
21
|
+
name: z.string().min(1).describe('Template name.'),
|
|
22
|
+
content: nginxTemplateContentInput,
|
|
23
|
+
},
|
|
24
|
+
outputSchema: {
|
|
25
|
+
template: nginxTemplateOutput,
|
|
26
|
+
},
|
|
27
|
+
async handler(args, { client, organization, signal }) {
|
|
28
|
+
const response = await client.post(nginxTemplatesPath(organization(args.organization), args.server), {
|
|
29
|
+
body: { name: args.name, content: args.content },
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
const template = formatNginxTemplate(flattenSingle(response.data), false);
|
|
33
|
+
return { structured: { template }, summary: `Created Nginx template ${template.name} (ID ${template.id}).` };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
4
|
+
import { NGINX_TEMPLATE_NOT_FOUND_HINT, nginxTemplateInput, nginxTemplatePath } from './shared.js';
|
|
5
|
+
export const deleteNginxTemplate = defineTool({
|
|
6
|
+
name: 'forge_delete_nginx_template',
|
|
7
|
+
title: 'Delete Nginx template',
|
|
8
|
+
description: 'Delete an Nginx template. Sites created from it keep their configuration.',
|
|
9
|
+
toolset: 'servers',
|
|
10
|
+
operations: ['organizations.servers.nginx.templates.destroy'],
|
|
11
|
+
permissions: ['server:manage-nginx-templates'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: true,
|
|
14
|
+
idempotent: true,
|
|
15
|
+
notFoundHint: NGINX_TEMPLATE_NOT_FOUND_HINT,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
organization: organizationInput,
|
|
18
|
+
server: serverInput,
|
|
19
|
+
template: nginxTemplateInput,
|
|
20
|
+
},
|
|
21
|
+
outputSchema: {
|
|
22
|
+
deleted: z.boolean(),
|
|
23
|
+
},
|
|
24
|
+
async handler(args, { client, organization, signal }) {
|
|
25
|
+
await client.delete(nginxTemplatePath(organization(args.organization), args.server, args.template), { signal });
|
|
26
|
+
return { structured: { deleted: true }, summary: `Deleted Nginx template ${args.template}.` };
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { organizationInput, paginationInput, paginationOutput, paginationSummary, responseFormatInput, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
7
|
+
import { formatNginxTemplate, nginxTemplateInput, nginxTemplateOutput, nginxTemplatePath, nginxTemplatesPath } from './shared.js';
|
|
8
|
+
const SORT = ['name', '-name', 'created_at', '-created_at', 'updated_at', '-updated_at'];
|
|
9
|
+
export const listNginxTemplates = defineTool({
|
|
10
|
+
name: 'forge_list_nginx_templates',
|
|
11
|
+
title: 'List Nginx templates',
|
|
12
|
+
description: 'List the Nginx templates of a server (custom server blocks used when creating sites, see `nginx_template_id` in forge_create_site), or read one with its content using `template`.',
|
|
13
|
+
toolset: 'servers',
|
|
14
|
+
operations: ['organizations.servers.nginx.templates.index', 'organizations.servers.nginx.templates.show'],
|
|
15
|
+
permissions: ['server:view'],
|
|
16
|
+
readOnly: true,
|
|
17
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
organization: organizationInput,
|
|
20
|
+
server: serverInput,
|
|
21
|
+
template: nginxTemplateInput.optional().describe('Return only this template, with its content.'),
|
|
22
|
+
name: z.string().min(1).optional().describe('Filter by name.'),
|
|
23
|
+
sort: z.array(z.enum(SORT)).min(1).optional(),
|
|
24
|
+
response_format: responseFormatInput.describe('"detailed" also returns the content of every template.'),
|
|
25
|
+
...paginationInput,
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
templates: z.array(nginxTemplateOutput),
|
|
29
|
+
...paginationOutput,
|
|
30
|
+
},
|
|
31
|
+
async handler(args, { client, organization, signal }) {
|
|
32
|
+
const org = organization(args.organization);
|
|
33
|
+
if (args.template !== undefined) {
|
|
34
|
+
const template = formatNginxTemplate(await readResource(client, nginxTemplatePath(org, args.server, args.template), signal), true);
|
|
35
|
+
return { structured: { templates: [template], next_cursor: null, has_more: false }, summary: `Nginx template ${template.name}.` };
|
|
36
|
+
}
|
|
37
|
+
const response = await client.get(nginxTemplatesPath(org, args.server), {
|
|
38
|
+
query: { filter: { name: args.name }, sort: args.sort, page: { size: args.page_size, cursor: args.cursor } },
|
|
39
|
+
signal,
|
|
40
|
+
});
|
|
41
|
+
const page = flattenCollection(response.data);
|
|
42
|
+
const templates = page.items.map((item) => formatNginxTemplate(item, args.response_format === 'detailed'));
|
|
43
|
+
return {
|
|
44
|
+
structured: { templates, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
45
|
+
summary: templates.length === 0
|
|
46
|
+
? 'No Nginx templates found.'
|
|
47
|
+
: `Found ${templates.length} Nginx template(s): ${templates.map((t) => `${t.name} (${t.id})`).join(', ')}.${paginationSummary(page.nextCursor)}`,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
3
|
+
import { serverPath } from '../servers/shared.js';
|
|
4
|
+
export function nginxTemplatesPath(org, server) {
|
|
5
|
+
return `${serverPath(org, server)}/nginx/templates`;
|
|
6
|
+
}
|
|
7
|
+
export function nginxTemplatePath(org, server, template) {
|
|
8
|
+
return `${nginxTemplatesPath(org, server)}/${encodeURIComponent(String(template))}`;
|
|
9
|
+
}
|
|
10
|
+
export const nginxTemplateInput = idInput('Nginx template ID. Use forge_list_nginx_templates to find it.');
|
|
11
|
+
export const NGINX_TEMPLATE_NOT_FOUND_HINT = 'Check the template ID with forge_list_nginx_templates.';
|
|
12
|
+
export const nginxTemplateContentInput = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.describe('Nginx server block; Forge replaces variables such as {{DOMAINS}}, {{PATH}}, {{PORT}} and {{PROXY_PASS}} when it creates a site.');
|
|
16
|
+
export const nginxTemplateOutput = z.looseObject({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
name: z.string().nullable(),
|
|
19
|
+
content: z.string().nullable().optional().describe('Template content (only when reading one template or with response_format "detailed").'),
|
|
20
|
+
created_at: z.string().nullable(),
|
|
21
|
+
updated_at: z.string().nullable(),
|
|
22
|
+
});
|
|
23
|
+
export function formatNginxTemplate(flat, withContent) {
|
|
24
|
+
return pick(flat, withContent ? ['id', 'name', 'content', 'created_at', 'updated_at'] : ['id', 'name', 'created_at', 'updated_at']);
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { ToolInputError } from '../errors.js';
|
|
5
|
+
import { readResource } from '../shared/read.js';
|
|
6
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
7
|
+
import { formatNginxTemplate, NGINX_TEMPLATE_NOT_FOUND_HINT, nginxTemplateContentInput, nginxTemplateInput, nginxTemplateOutput, nginxTemplatePath } from './shared.js';
|
|
8
|
+
export const updateNginxTemplate = defineTool({
|
|
9
|
+
name: 'forge_update_nginx_template',
|
|
10
|
+
title: 'Update Nginx template',
|
|
11
|
+
description: 'Rename an Nginx template or replace its content. Existing sites are not changed: the template is used for new sites only.',
|
|
12
|
+
toolset: 'servers',
|
|
13
|
+
operations: ['organizations.servers.nginx.templates.update', 'organizations.servers.nginx.templates.show'],
|
|
14
|
+
permissions: ['server:manage-nginx-templates', 'server:view'],
|
|
15
|
+
readOnly: false,
|
|
16
|
+
destructive: false,
|
|
17
|
+
idempotent: true,
|
|
18
|
+
notFoundHint: NGINX_TEMPLATE_NOT_FOUND_HINT,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
organization: organizationInput,
|
|
21
|
+
server: serverInput,
|
|
22
|
+
template: nginxTemplateInput,
|
|
23
|
+
name: z.string().min(1).optional().describe('New name.'),
|
|
24
|
+
content: nginxTemplateContentInput.optional().describe('New content (replaces the whole template).'),
|
|
25
|
+
},
|
|
26
|
+
outputSchema: {
|
|
27
|
+
template: nginxTemplateOutput,
|
|
28
|
+
},
|
|
29
|
+
async handler(args, { client, organization, signal }) {
|
|
30
|
+
if (args.name === undefined && args.content === undefined)
|
|
31
|
+
throw new ToolInputError('Pass `name` and/or `content`.');
|
|
32
|
+
const path = nginxTemplatePath(organization(args.organization), args.server, args.template);
|
|
33
|
+
// Both fields are required by the API: keep the current value of the one not changed.
|
|
34
|
+
const current = args.name === undefined || args.content === undefined ? await readResource(client, path, signal) : undefined;
|
|
35
|
+
const response = await client.put(path, {
|
|
36
|
+
body: { name: args.name ?? current?.name, content: args.content ?? current?.content },
|
|
37
|
+
signal,
|
|
38
|
+
});
|
|
39
|
+
const template = formatNginxTemplate(flattenSingle(response.data), false);
|
|
40
|
+
return { structured: { template }, summary: `Updated Nginx template ${template.name}.` };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { readResource } from '../shared/read.js';
|
|
4
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
5
|
+
import { credentialKeyInput, credentialOperations, credentialPath, managerInput } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_list_package_credentials';
|
|
7
|
+
export const deletePackageCredentials = defineTool({
|
|
8
|
+
name: 'forge_delete_package_credentials',
|
|
9
|
+
title: 'Delete package credentials',
|
|
10
|
+
description: 'Remove the credentials of a private Composer repository or npm registry: installing its packages will fail.',
|
|
11
|
+
toolset: 'sites',
|
|
12
|
+
operations: [...credentialOperations('destroy'), ...credentialOperations('show')],
|
|
13
|
+
permissions: ['server:manage-packages', 'server:view'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: true,
|
|
16
|
+
idempotent: true,
|
|
17
|
+
async: true,
|
|
18
|
+
notFoundHint: 'Check the repository with forge_list_package_credentials.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
...siteScopeInput,
|
|
21
|
+
manager: managerInput,
|
|
22
|
+
repository: credentialKeyInput,
|
|
23
|
+
...waitInput(60),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: operationOutput,
|
|
26
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
27
|
+
const path = credentialPath(organization(args.organization), args.server, args.site, args.manager, args.repository);
|
|
28
|
+
await client.delete(path, { signal });
|
|
29
|
+
const action = `delete the ${args.manager} credentials for ${args.repository}`;
|
|
30
|
+
if (!args.wait)
|
|
31
|
+
return queued(action, CHECK_WITH);
|
|
32
|
+
const result = await waitFor({
|
|
33
|
+
poll: () => orGone(() => readResource(client, path, signal)),
|
|
34
|
+
phase: (credential) => (credential === null ? 'completed' : 'pending'),
|
|
35
|
+
describe: () => 'Waiting for the credentials to be removed',
|
|
36
|
+
timeoutSeconds: args.timeout_seconds,
|
|
37
|
+
context: { sleep, progress },
|
|
38
|
+
});
|
|
39
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { paginationInput, paginationOutput, paginationSummary } from '../shared/schemas.js';
|
|
6
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
|
|
7
|
+
import { credentialKeyInput, credentialOperations, credentialOutput, credentialPath, credentialsPath, formatCredential, managerInput, } from './shared.js';
|
|
8
|
+
export const listPackageCredentials = defineTool({
|
|
9
|
+
name: 'forge_list_package_credentials',
|
|
10
|
+
title: 'List package credentials',
|
|
11
|
+
description: 'List the credentials a site uses to install private Composer or npm packages (auth.json / .npmrc), or get one with `repository`. Passwords and tokens are hidden unless secrets are allowed.',
|
|
12
|
+
toolset: 'sites',
|
|
13
|
+
operations: [...credentialOperations('index'), ...credentialOperations('show')],
|
|
14
|
+
permissions: ['server:view'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
...siteScopeInput,
|
|
19
|
+
manager: managerInput,
|
|
20
|
+
repository: credentialKeyInput.optional().describe('Return only the credentials of this repository or registry.'),
|
|
21
|
+
...paginationInput,
|
|
22
|
+
},
|
|
23
|
+
outputSchema: {
|
|
24
|
+
credentials: z.array(credentialOutput),
|
|
25
|
+
...paginationOutput,
|
|
26
|
+
},
|
|
27
|
+
async handler(args, { client, config, organization, signal }) {
|
|
28
|
+
const org = organization(args.organization);
|
|
29
|
+
const format = (flat) => formatCredential(args.manager, flat, config.allowSecrets);
|
|
30
|
+
if (args.repository !== undefined) {
|
|
31
|
+
const credential = format(await readResource(client, credentialPath(org, args.server, args.site, args.manager, args.repository), signal));
|
|
32
|
+
return { structured: { credentials: [credential], next_cursor: null, has_more: false }, summary: `Credentials for ${credential.repository}.` };
|
|
33
|
+
}
|
|
34
|
+
const response = await client.get(credentialsPath(org, args.server, args.site, args.manager), {
|
|
35
|
+
query: { page: { size: args.page_size, cursor: args.cursor } },
|
|
36
|
+
signal,
|
|
37
|
+
});
|
|
38
|
+
const page = flattenCollection(response.data);
|
|
39
|
+
const credentials = page.items.map(format);
|
|
40
|
+
return {
|
|
41
|
+
structured: { credentials, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
42
|
+
summary: credentials.length === 0
|
|
43
|
+
? `No ${args.manager} credentials found.`
|
|
44
|
+
: `Found ${args.manager} credentials for: ${credentials.map((c) => c.repository).join(', ')}.${paginationSummary(page.nextCursor)}`,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { ToolInputError } from '../errors.js';
|
|
4
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
5
|
+
import { readResource } from '../shared/read.js';
|
|
6
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
|
|
7
|
+
import { credentialKeyInput, credentialOperations, credentialPath, credentialsPath, managerInput } from './shared.js';
|
|
8
|
+
const CHECK_WITH = 'forge_list_package_credentials';
|
|
9
|
+
export const setPackageCredentials = defineTool({
|
|
10
|
+
name: 'forge_set_package_credentials',
|
|
11
|
+
title: 'Set package credentials',
|
|
12
|
+
description: 'Add or replace the credentials a site uses for a private Composer repository (`username` + `password`, e.g. Laravel Nova or Spark) or npm registry (`token`, optional `scopes`). Secrets are never returned.',
|
|
13
|
+
toolset: 'sites',
|
|
14
|
+
operations: [...credentialOperations('store'), ...credentialOperations('update'), ...credentialOperations('show')],
|
|
15
|
+
permissions: ['server:manage-packages', 'server:view'],
|
|
16
|
+
readOnly: false,
|
|
17
|
+
destructive: false,
|
|
18
|
+
idempotent: true,
|
|
19
|
+
async: true,
|
|
20
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
...siteScopeInput,
|
|
23
|
+
manager: managerInput,
|
|
24
|
+
repository: credentialKeyInput,
|
|
25
|
+
username: z.string().min(1).optional().describe('Composer only.'),
|
|
26
|
+
password: z.string().min(1).optional().describe('Composer only (never returned).'),
|
|
27
|
+
token: z.string().min(1).optional().describe('npm only (never returned).'),
|
|
28
|
+
scopes: z.array(z.string().min(1)).optional().describe('npm only: scopes served by the registry, e.g. ["@acme"].'),
|
|
29
|
+
...waitInput(60),
|
|
30
|
+
},
|
|
31
|
+
outputSchema: {
|
|
32
|
+
...operationOutput,
|
|
33
|
+
created: z.boolean().describe('Whether the credentials were added (false: replaced).'),
|
|
34
|
+
},
|
|
35
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
36
|
+
const composer = args.manager === 'composer';
|
|
37
|
+
if (composer && (!args.username || !args.password || args.token || args.scopes)) {
|
|
38
|
+
throw new ToolInputError('Composer credentials need `username` and `password` (no `token` or `scopes`).');
|
|
39
|
+
}
|
|
40
|
+
if (!composer && (!args.token || args.username || args.password)) {
|
|
41
|
+
throw new ToolInputError('npm credentials need `token` and optionally `scopes` (no `username` or `password`).');
|
|
42
|
+
}
|
|
43
|
+
const org = organization(args.organization);
|
|
44
|
+
const path = credentialPath(org, args.server, args.site, args.manager, args.repository);
|
|
45
|
+
const body = composer
|
|
46
|
+
? { repository: args.repository, username: args.username, password: args.password }
|
|
47
|
+
: { registry: args.repository, token: args.token, scopes: args.scopes };
|
|
48
|
+
const exists = (await orGone(() => readResource(client, path, signal))) !== null;
|
|
49
|
+
if (exists)
|
|
50
|
+
await client.put(path, { body, signal });
|
|
51
|
+
else
|
|
52
|
+
await client.post(credentialsPath(org, args.server, args.site, args.manager), { body, signal });
|
|
53
|
+
const action = `${exists ? 'replace' : 'add'} the ${args.manager} credentials for ${args.repository}`;
|
|
54
|
+
if (!args.wait) {
|
|
55
|
+
const accepted = queued(action, CHECK_WITH);
|
|
56
|
+
return { ...accepted, structured: { ...accepted.structured, created: !exists } };
|
|
57
|
+
}
|
|
58
|
+
// Compared here without ever returning the secret.
|
|
59
|
+
const matches = (current) => current !== null &&
|
|
60
|
+
(composer
|
|
61
|
+
? current.username === args.username && current.password === args.password
|
|
62
|
+
: current.token === args.token && (args.scopes === undefined || JSON.stringify(current.scopes ?? []) === JSON.stringify(args.scopes)));
|
|
63
|
+
const result = await waitFor({
|
|
64
|
+
poll: () => orGone(() => readResource(client, path, signal)),
|
|
65
|
+
phase: (current) => (matches(current) ? 'completed' : 'pending'),
|
|
66
|
+
describe: () => `Waiting for the ${args.manager} credentials to be saved`,
|
|
67
|
+
timeoutSeconds: args.timeout_seconds,
|
|
68
|
+
context: { sleep, progress },
|
|
69
|
+
});
|
|
70
|
+
const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
71
|
+
return { structured: { ...done.structured, created: !exists }, summary: done.summary };
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { redact } from '../shared/secrets.js';
|
|
3
|
+
import { sitePath } from '../shared/site-scope.js';
|
|
4
|
+
export const MANAGERS = ['composer', 'npm'];
|
|
5
|
+
export const managerInput = z.enum(MANAGERS).describe('composer (private Composer repositories) or npm (private npm registries).');
|
|
6
|
+
export const credentialKeyInput = z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.describe('Composer repository host (e.g. "repo.packagist.com") or npm registry (e.g. "npm.pkg.github.com").');
|
|
10
|
+
export function credentialsPath(org, server, site, manager) {
|
|
11
|
+
return `${sitePath(org, server, site)}/${manager}/credentials`;
|
|
12
|
+
}
|
|
13
|
+
export function credentialPath(org, server, site, manager, key) {
|
|
14
|
+
return `${credentialsPath(org, server, site, manager)}/${encodeURIComponent(key)}`;
|
|
15
|
+
}
|
|
16
|
+
export function credentialOperations(verb) {
|
|
17
|
+
return MANAGERS.map((manager) => `organizations.servers.sites.${manager}.credentials.${verb}`);
|
|
18
|
+
}
|
|
19
|
+
export const credentialOutput = z.looseObject({
|
|
20
|
+
manager: z.enum(MANAGERS),
|
|
21
|
+
repository: z.string().nullable().describe('Composer repository or npm registry.'),
|
|
22
|
+
username: z.string().nullable().describe('Composer only.'),
|
|
23
|
+
scopes: z.array(z.string()).nullable().describe('npm only: package scopes served by the registry.'),
|
|
24
|
+
secret: z.string().nullable().describe('Password (Composer) or token (npm), hidden unless secrets are allowed.'),
|
|
25
|
+
});
|
|
26
|
+
export function formatCredential(manager, flat, allowSecrets) {
|
|
27
|
+
const composer = manager === 'composer';
|
|
28
|
+
const secret = (composer ? flat.password : flat.token);
|
|
29
|
+
return redact({
|
|
30
|
+
manager,
|
|
31
|
+
repository: (composer ? flat.repository : flat.registry) ?? null,
|
|
32
|
+
username: composer ? (flat.username ?? null) : null,
|
|
33
|
+
scopes: composer ? null : (flat.scopes ?? null),
|
|
34
|
+
secret: secret ?? null,
|
|
35
|
+
}, ['secret'], allowSecrets);
|
|
36
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -60,7 +60,23 @@ import { listFirewallRules } from './firewall/list-firewall-rules.js';
|
|
|
60
60
|
import { disableSiteIntegration } from './integrations/disable-site-integration.js';
|
|
61
61
|
import { enableSiteIntegration } from './integrations/enable-site-integration.js';
|
|
62
62
|
import { getSiteIntegrations } from './integrations/get-site-integrations.js';
|
|
63
|
+
import { createHeartbeat } from './heartbeats/create-heartbeat.js';
|
|
64
|
+
import { deleteHeartbeat } from './heartbeats/delete-heartbeat.js';
|
|
65
|
+
import { listHeartbeats } from './heartbeats/list-heartbeats.js';
|
|
66
|
+
import { updateHeartbeat } from './heartbeats/update-heartbeat.js';
|
|
67
|
+
import { getLoadBalancer } from './load-balancing/get-load-balancer.js';
|
|
68
|
+
import { updateLoadBalancer } from './load-balancing/update-load-balancer.js';
|
|
69
|
+
import { createMonitor } from './monitors/create-monitor.js';
|
|
70
|
+
import { deleteMonitor } from './monitors/delete-monitor.js';
|
|
71
|
+
import { listMonitors } from './monitors/list-monitors.js';
|
|
72
|
+
import { createNginxTemplate } from './nginx-templates/create-nginx-template.js';
|
|
73
|
+
import { deleteNginxTemplate } from './nginx-templates/delete-nginx-template.js';
|
|
74
|
+
import { listNginxTemplates } from './nginx-templates/list-nginx-templates.js';
|
|
75
|
+
import { updateNginxTemplate } from './nginx-templates/update-nginx-template.js';
|
|
63
76
|
import { listOrganizations } from './organizations/list-organizations.js';
|
|
77
|
+
import { deletePackageCredentials } from './package-credentials/delete-package-credentials.js';
|
|
78
|
+
import { listPackageCredentials } from './package-credentials/list-package-credentials.js';
|
|
79
|
+
import { setPackageCredentials } from './package-credentials/set-package-credentials.js';
|
|
64
80
|
import { getPhpConfig } from './php/get-php-config.js';
|
|
65
81
|
import { getPhpSettings } from './php/get-php-settings.js';
|
|
66
82
|
import { installPhpVersion } from './php/install-php-version.js';
|
|
@@ -160,6 +176,12 @@ export const ALL_TOOLS = [
|
|
|
160
176
|
createCertificate,
|
|
161
177
|
runCertificateAction,
|
|
162
178
|
deleteCertificate,
|
|
179
|
+
// sites: packages and load balancing
|
|
180
|
+
listPackageCredentials,
|
|
181
|
+
setPackageCredentials,
|
|
182
|
+
deletePackageCredentials,
|
|
183
|
+
getLoadBalancer,
|
|
184
|
+
updateLoadBalancer,
|
|
163
185
|
// servers
|
|
164
186
|
runServiceAction,
|
|
165
187
|
runServerAction,
|
|
@@ -168,6 +190,10 @@ export const ALL_TOOLS = [
|
|
|
168
190
|
updateServer,
|
|
169
191
|
getServerNetwork,
|
|
170
192
|
updateServerNetwork,
|
|
193
|
+
listNginxTemplates,
|
|
194
|
+
createNginxTemplate,
|
|
195
|
+
updateNginxTemplate,
|
|
196
|
+
deleteNginxTemplate,
|
|
171
197
|
listArchivedServers,
|
|
172
198
|
archiveServer,
|
|
173
199
|
unarchiveServer,
|
|
@@ -254,6 +280,14 @@ export const ALL_TOOLS = [
|
|
|
254
280
|
deleteSshKey,
|
|
255
281
|
getServerPublicKey,
|
|
256
282
|
regenerateServerKey,
|
|
283
|
+
// monitoring
|
|
284
|
+
listHeartbeats,
|
|
285
|
+
createHeartbeat,
|
|
286
|
+
updateHeartbeat,
|
|
287
|
+
deleteHeartbeat,
|
|
288
|
+
listMonitors,
|
|
289
|
+
createMonitor,
|
|
290
|
+
deleteMonitor,
|
|
257
291
|
// integrations
|
|
258
292
|
getSiteIntegrations,
|
|
259
293
|
enableSiteIntegration,
|