@francescomalatesta/laravel-forge-mcp 0.4.0 → 0.6.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 +24 -2
- package/dist/tools/certificates/create-certificate.js +116 -0
- package/dist/tools/certificates/delete-certificate.js +42 -0
- package/dist/tools/certificates/get-certificate.js +45 -0
- package/dist/tools/certificates/list-certificates.js +36 -0
- package/dist/tools/certificates/run-certificate-action.js +45 -0
- package/dist/tools/certificates/shared.js +47 -0
- package/dist/tools/domains/add-domain.js +61 -0
- package/dist/tools/domains/delete-domain.js +37 -0
- package/dist/tools/domains/get-domain.js +44 -0
- package/dist/tools/domains/list-domains.js +46 -0
- package/dist/tools/domains/run-domain-action.js +45 -0
- package/dist/tools/domains/shared.js +37 -0
- package/dist/tools/domains/update-domain.js +55 -0
- package/dist/tools/registry.js +46 -0
- package/dist/tools/servers/archive-server.js +40 -0
- package/dist/tools/servers/clear-server-log.js +45 -0
- package/dist/tools/servers/delete-server.js +44 -0
- package/dist/tools/servers/get-server-log.js +39 -0
- package/dist/tools/servers/get-server-network.js +33 -0
- package/dist/tools/servers/list-archived-servers.js +38 -0
- package/dist/tools/servers/run-server-action.js +29 -0
- package/dist/tools/servers/run-service-action.js +66 -0
- package/dist/tools/servers/server-logs.js +5 -0
- package/dist/tools/servers/shared.js +18 -0
- package/dist/tools/servers/unarchive-server.js +40 -0
- package/dist/tools/servers/update-server-network.js +46 -0
- package/dist/tools/servers/update-server.js +41 -0
- package/dist/tools/sites/get-site-nginx-config.js +10 -4
- package/dist/tools/sites/update-site-nginx-config.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,12 +89,34 @@ npm run build
|
|
|
89
89
|
| `forge_set_site_env_vars` | sites | Set or remove .env variables without exposing the file |
|
|
90
90
|
| `forge_get_site_environment` 🔑 | sites | Read the whole .env file |
|
|
91
91
|
| `forge_update_site_environment` 🔑 | sites | Replace the whole .env file |
|
|
92
|
-
| `forge_get_site_nginx_config` | sites | Read the
|
|
93
|
-
| `forge_update_site_nginx_config` | sites | Replace the
|
|
92
|
+
| `forge_get_site_nginx_config` | sites | Read the Nginx configuration of a site or one of its domains |
|
|
93
|
+
| `forge_update_site_nginx_config` | sites | Replace the Nginx configuration of a site or one of its domains |
|
|
94
94
|
| `forge_get_site_log` | sites | End of the application, Nginx access or Nginx error log |
|
|
95
95
|
| `forge_clear_site_log` | sites | Empty a site log |
|
|
96
96
|
| `forge_get_site_healthcheck` | sites | Healthcheck URL used after zero-downtime deployments |
|
|
97
97
|
| `forge_update_site_healthcheck` | sites | Set or remove the healthcheck URL |
|
|
98
|
+
| `forge_list_domains` | sites | Domains of a site with status and www redirection |
|
|
99
|
+
| `forge_get_domain` | sites | A domain with the DNS records to configure |
|
|
100
|
+
| `forge_add_domain` | sites | Add a domain (alias) and wait until it is enabled |
|
|
101
|
+
| `forge_update_domain` | sites | Change www redirection and wildcard subdomains |
|
|
102
|
+
| `forge_run_domain_action` | sites | Enable, disable or mark a domain as primary |
|
|
103
|
+
| `forge_delete_domain` | sites | Remove a domain |
|
|
104
|
+
| `forge_list_certificates` | sites | SSL certificates of a site or of one domain |
|
|
105
|
+
| `forge_get_certificate` | sites | A certificate, or the active one |
|
|
106
|
+
| `forge_create_certificate` | sites | Let's Encrypt, existing, CSR or cloned certificate, waiting until installed |
|
|
107
|
+
| `forge_run_certificate_action` | sites | Activate or deactivate a certificate |
|
|
108
|
+
| `forge_delete_certificate` | sites | Delete a certificate |
|
|
109
|
+
| `forge_run_service_action` | servers | Restart or stop Nginx, MySQL, Postgres, Redis, Supervisor; restart or reload PHP-FPM |
|
|
110
|
+
| `forge_run_server_action` | servers | Reboot or power-cycle a server |
|
|
111
|
+
| `forge_get_server_log` | servers | End of a server log (Nginx, PHP-FPM, MySQL, cron, daemons) |
|
|
112
|
+
| `forge_clear_server_log` | servers | Empty a server log |
|
|
113
|
+
| `forge_update_server` | servers | Rename, change IP addresses, timezone or tags |
|
|
114
|
+
| `forge_get_server_network` | servers | Servers that can reach each other on the private network |
|
|
115
|
+
| `forge_update_server_network` | servers | Set the server network |
|
|
116
|
+
| `forge_list_archived_servers` | servers | Archived servers |
|
|
117
|
+
| `forge_archive_server` | servers | Archive a server (Forge stops managing it) |
|
|
118
|
+
| `forge_unarchive_server` | servers | Restore an archived server |
|
|
119
|
+
| `forge_delete_server` | servers | Delete a server (optionally keeping it at the provider) |
|
|
98
120
|
| `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
|
|
99
121
|
| `forge_get_deployment` | deployments | A deployment with the end of its log |
|
|
100
122
|
| `forge_get_deployment_status` | deployments | Whether a deployment is running |
|
|
@@ -0,0 +1,116 @@
|
|
|
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 { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
6
|
+
import { DOMAIN_NOT_FOUND_HINT, domainPath, domainScopeInput } from '../domains/shared.js';
|
|
7
|
+
import { certificateOutput, certificatePhase, formatCertificate, readCertificate } from './shared.js';
|
|
8
|
+
const CHECK_WITH = 'forge_get_certificate';
|
|
9
|
+
export const createCertificate = defineTool({
|
|
10
|
+
name: 'forge_create_certificate',
|
|
11
|
+
title: 'Create certificate',
|
|
12
|
+
description: [
|
|
13
|
+
'Secure a domain with an SSL certificate. Types:',
|
|
14
|
+
'"letsencrypt" (free, issued and renewed automatically; the domain DNS must already point to the server, or use dns-01 verification);',
|
|
15
|
+
'"existing" (install your own `certificate` chain and `private_key`);',
|
|
16
|
+
'"csr" (generate a signing request to send to a certificate authority);',
|
|
17
|
+
'"clone" (copy certificate `clone_certificate_id` from another domain).',
|
|
18
|
+
'Waits until the certificate is issued and installed unless `wait` is false.',
|
|
19
|
+
].join(' '),
|
|
20
|
+
toolset: 'sites',
|
|
21
|
+
operations: ['organizations.servers.sites.domains.certificates.store', 'organizations.servers.sites.domains.certificates.show'],
|
|
22
|
+
permissions: ['site:manage-ssl', 'site:meta'],
|
|
23
|
+
readOnly: false,
|
|
24
|
+
destructive: false,
|
|
25
|
+
idempotent: false,
|
|
26
|
+
async: true,
|
|
27
|
+
notFoundHint: DOMAIN_NOT_FOUND_HINT,
|
|
28
|
+
inputSchema: {
|
|
29
|
+
...domainScopeInput,
|
|
30
|
+
type: z.enum(['letsencrypt', 'existing', 'csr', 'clone']).default('letsencrypt'),
|
|
31
|
+
enable: z.boolean().optional().describe('Activate the certificate once installed (existing and clone only; Let\'s Encrypt activates automatically).'),
|
|
32
|
+
verification_method: z.enum(['http-01', 'dns-01']).optional().describe('Let\'s Encrypt: domain verification method.'),
|
|
33
|
+
key_type: z.enum(['ecdsa', 'rsa']).optional().describe('Let\'s Encrypt: key type.'),
|
|
34
|
+
preferred_chain: z.enum(['ISRG Root X1']).optional().describe('Let\'s Encrypt: preferred chain.'),
|
|
35
|
+
certificate: z.string().min(1).optional().describe('Existing: the certificate chain (PEM).'),
|
|
36
|
+
private_key: z.string().min(1).optional().describe('Existing: the private key (PEM).'),
|
|
37
|
+
csr_domain: z.string().min(1).optional().describe('CSR: domain to generate the request for.'),
|
|
38
|
+
csr_sans: z.string().min(1).optional().describe('CSR: additional domains, comma-separated.'),
|
|
39
|
+
csr_country: z.string().min(1).optional(),
|
|
40
|
+
csr_state: z.string().min(1).optional(),
|
|
41
|
+
csr_city: z.string().min(1).optional(),
|
|
42
|
+
csr_organization: z.string().min(1).optional(),
|
|
43
|
+
csr_department: z.string().min(1).optional(),
|
|
44
|
+
clone_certificate_id: z.number().int().optional().describe('Clone: ID of the certificate to copy.'),
|
|
45
|
+
...waitInput(180),
|
|
46
|
+
},
|
|
47
|
+
outputSchema: {
|
|
48
|
+
...operationOutput,
|
|
49
|
+
certificate: certificateOutput.nullable(),
|
|
50
|
+
},
|
|
51
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
52
|
+
const body = buildBody(args);
|
|
53
|
+
const base = `${domainPath(organization(args.organization), args.server, args.site, args.domain)}/certificates`;
|
|
54
|
+
const response = await client.post(base, { body, signal });
|
|
55
|
+
const initial = response.data?.data ? flattenSingle(response.data) : undefined;
|
|
56
|
+
const action = `create the ${args.type} certificate`;
|
|
57
|
+
if (!initial || !args.wait) {
|
|
58
|
+
return {
|
|
59
|
+
structured: { status: 'queued', check_with: initial ? CHECK_WITH : 'forge_list_certificates', certificate: initial ? formatCertificate(initial) : null },
|
|
60
|
+
summary: `Forge accepted the request to ${action}. Follow it with ${initial ? `${CHECK_WITH} (certificate ${initial.id})` : 'forge_list_certificates'}.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const result = await waitFor({
|
|
64
|
+
initial,
|
|
65
|
+
poll: () => readCertificate(client, `${base}/${encodeURIComponent(initial.id)}`, signal),
|
|
66
|
+
phase: certificatePhase,
|
|
67
|
+
describe: (certificate) => `Certificate ${certificate.id}: request ${certificate.request_status}, ${certificate.status}`,
|
|
68
|
+
timeoutSeconds: args.timeout_seconds,
|
|
69
|
+
context: { sleep, progress },
|
|
70
|
+
});
|
|
71
|
+
const done = outcome(result, {
|
|
72
|
+
action,
|
|
73
|
+
checkWith: CHECK_WITH,
|
|
74
|
+
timeoutSeconds: args.timeout_seconds,
|
|
75
|
+
detail: result.status === 'failed' && args.type === 'letsencrypt'
|
|
76
|
+
? "Let's Encrypt usually fails when the domain DNS does not point to the server: check it with forge_get_domain, and the error with forge_list_server_events."
|
|
77
|
+
: undefined,
|
|
78
|
+
});
|
|
79
|
+
return { structured: { ...done.structured, certificate: formatCertificate(result.value ?? initial) }, summary: done.summary };
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
/** Maps the flat tool arguments to the nested request body of the chosen certificate type. */
|
|
83
|
+
function buildBody(args) {
|
|
84
|
+
const pickDefined = (entries) => Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined));
|
|
85
|
+
switch (args.type) {
|
|
86
|
+
case 'letsencrypt':
|
|
87
|
+
return {
|
|
88
|
+
type: 'letsencrypt',
|
|
89
|
+
letsencrypt: pickDefined({ verification_method: args.verification_method, key_type: args.key_type, preferred_chain: args.preferred_chain }),
|
|
90
|
+
};
|
|
91
|
+
case 'existing':
|
|
92
|
+
if (!args.certificate || !args.private_key) {
|
|
93
|
+
throw new ToolInputError('An "existing" certificate needs both `certificate` and `private_key`.');
|
|
94
|
+
}
|
|
95
|
+
return pickDefined({ type: 'existing', enable: args.enable, existing: { certificate: args.certificate, key: args.private_key } });
|
|
96
|
+
case 'csr':
|
|
97
|
+
if (!args.csr_domain)
|
|
98
|
+
throw new ToolInputError('A "csr" certificate needs `csr_domain`.');
|
|
99
|
+
return {
|
|
100
|
+
type: 'csr',
|
|
101
|
+
csr: pickDefined({
|
|
102
|
+
domain: args.csr_domain,
|
|
103
|
+
sans: args.csr_sans,
|
|
104
|
+
country: args.csr_country,
|
|
105
|
+
state: args.csr_state,
|
|
106
|
+
city: args.csr_city,
|
|
107
|
+
organization: args.csr_organization,
|
|
108
|
+
department: args.csr_department,
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
case 'clone':
|
|
112
|
+
if (args.clone_certificate_id === undefined)
|
|
113
|
+
throw new ToolInputError('A "clone" certificate needs `clone_certificate_id`.');
|
|
114
|
+
return pickDefined({ type: 'clone', enable: args.enable, clone: { certificate_id: args.clone_certificate_id } });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { domainPath, domainScopeInput } from '../domains/shared.js';
|
|
4
|
+
import { CERTIFICATE_NOT_FOUND_HINT, certificateInput, readCertificate } from './shared.js';
|
|
5
|
+
const CHECK_WITH = 'forge_list_certificates';
|
|
6
|
+
export const deleteCertificate = defineTool({
|
|
7
|
+
name: 'forge_delete_certificate',
|
|
8
|
+
title: 'Delete certificate',
|
|
9
|
+
description: 'Delete a certificate from a domain. If it is the active one, the domain is no longer served over HTTPS.',
|
|
10
|
+
toolset: 'sites',
|
|
11
|
+
operations: [
|
|
12
|
+
'organizations.servers.sites.domains.certificates.destroy',
|
|
13
|
+
'organizations.servers.sites.domains.certificates.show',
|
|
14
|
+
],
|
|
15
|
+
permissions: ['site:manage-ssl', 'site:meta'],
|
|
16
|
+
readOnly: false,
|
|
17
|
+
destructive: true,
|
|
18
|
+
idempotent: true,
|
|
19
|
+
async: true,
|
|
20
|
+
notFoundHint: CERTIFICATE_NOT_FOUND_HINT,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
...domainScopeInput,
|
|
23
|
+
certificate: certificateInput,
|
|
24
|
+
...waitInput(60),
|
|
25
|
+
},
|
|
26
|
+
outputSchema: operationOutput,
|
|
27
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
28
|
+
const path = `${domainPath(organization(args.organization), args.server, args.site, args.domain)}/certificates/${encodeURIComponent(String(args.certificate))}`;
|
|
29
|
+
await client.delete(path, { signal });
|
|
30
|
+
const action = `delete certificate ${args.certificate}`;
|
|
31
|
+
if (!args.wait)
|
|
32
|
+
return queued(action, CHECK_WITH);
|
|
33
|
+
const result = await waitFor({
|
|
34
|
+
poll: () => orGone(() => readCertificate(client, path, signal)),
|
|
35
|
+
phase: (certificate) => (certificate === null ? 'completed' : 'pending'),
|
|
36
|
+
describe: (certificate) => `Certificate is ${certificate?.status ?? 'deleting'}`,
|
|
37
|
+
timeoutSeconds: args.timeout_seconds,
|
|
38
|
+
context: { sleep, progress },
|
|
39
|
+
});
|
|
40
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ForgeApiError } from '../../forge/errors.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { DOMAIN_NOT_FOUND_HINT, domainPath, domainScopeInput } from '../domains/shared.js';
|
|
5
|
+
import { certificateInput, certificateOutput, formatCertificate, readCertificate } from './shared.js';
|
|
6
|
+
export const getCertificate = defineTool({
|
|
7
|
+
name: 'forge_get_certificate',
|
|
8
|
+
title: 'Get certificate',
|
|
9
|
+
description: 'Get a certificate of a domain, or the active one (the certificate currently served) when `certificate` is omitted.',
|
|
10
|
+
toolset: 'sites',
|
|
11
|
+
operations: [
|
|
12
|
+
'organizations.servers.sites.domains.certificates.show',
|
|
13
|
+
'organizations.servers.sites.domains.certificates.active',
|
|
14
|
+
],
|
|
15
|
+
permissions: ['site:meta'],
|
|
16
|
+
readOnly: true,
|
|
17
|
+
notFoundHint: DOMAIN_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
...domainScopeInput,
|
|
20
|
+
certificate: certificateInput.optional().describe('Certificate ID; omit to get the active certificate.'),
|
|
21
|
+
},
|
|
22
|
+
outputSchema: {
|
|
23
|
+
certificate: certificateOutput.nullable().describe('The certificate, or null when the domain has no active certificate.'),
|
|
24
|
+
},
|
|
25
|
+
async handler(args, { client, organization, signal }) {
|
|
26
|
+
const base = `${domainPath(organization(args.organization), args.server, args.site, args.domain)}/certificates`;
|
|
27
|
+
if (args.certificate !== undefined) {
|
|
28
|
+
const certificate = formatCertificate(await readCertificate(client, `${base}/${encodeURIComponent(String(args.certificate))}`, signal));
|
|
29
|
+
return { structured: { certificate }, summary: `Certificate ${certificate.id} (${certificate.type}) is ${certificate.status}.` };
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const certificate = formatCertificate(await readCertificate(client, `${base}/active`, signal));
|
|
33
|
+
return { structured: { certificate }, summary: `The active certificate is ${certificate.id} (${certificate.type}), ${certificate.status}.` };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof ForgeApiError && error.status === 404) {
|
|
37
|
+
return {
|
|
38
|
+
structured: { certificate: null },
|
|
39
|
+
summary: 'The domain has no active certificate (or the domain does not exist). Secure it with forge_create_certificate.',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
|
|
5
|
+
import { domainInput } from '../domains/shared.js';
|
|
6
|
+
import { certificateOutput, formatCertificate } from './shared.js';
|
|
7
|
+
export const listCertificates = defineTool({
|
|
8
|
+
name: 'forge_list_certificates',
|
|
9
|
+
title: 'List certificates',
|
|
10
|
+
description: 'List the SSL certificates of a site across all its domains, or of one domain with `domain`. Shows type, status and which certificate is active.',
|
|
11
|
+
toolset: 'sites',
|
|
12
|
+
operations: ['organizations.servers.sites.certificates.index', 'organizations.servers.sites.domains.certificates.index'],
|
|
13
|
+
permissions: ['site:meta'],
|
|
14
|
+
readOnly: true,
|
|
15
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
...siteScopeInput,
|
|
18
|
+
domain: domainInput.optional().describe('Only list the certificates of this domain.'),
|
|
19
|
+
},
|
|
20
|
+
outputSchema: {
|
|
21
|
+
certificates: z.array(certificateOutput),
|
|
22
|
+
},
|
|
23
|
+
async handler(args, { client, organization, signal }) {
|
|
24
|
+
const base = sitePath(organization(args.organization), args.server, args.site);
|
|
25
|
+
const path = args.domain === undefined ? `${base}/certificates` : `${base}/domains/${encodeURIComponent(String(args.domain))}/certificates`;
|
|
26
|
+
const response = await client.get(path, { signal });
|
|
27
|
+
const certificates = flattenCollection(response.data).items.map(formatCertificate);
|
|
28
|
+
const active = certificates.filter((certificate) => certificate.active).length;
|
|
29
|
+
return {
|
|
30
|
+
structured: { certificates },
|
|
31
|
+
summary: certificates.length === 0
|
|
32
|
+
? 'No certificates found: secure the domain with forge_create_certificate.'
|
|
33
|
+
: `Found ${certificates.length} certificate(s), ${active} active.`,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { domainPath, domainScopeInput } from '../domains/shared.js';
|
|
5
|
+
import { CERTIFICATE_NOT_FOUND_HINT, certificateInput, certificatePhase, readCertificate } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_get_certificate';
|
|
7
|
+
export const runCertificateAction = defineTool({
|
|
8
|
+
name: 'forge_run_certificate_action',
|
|
9
|
+
title: 'Activate or deactivate a certificate',
|
|
10
|
+
description: 'Activate ("enable") a certificate so the domain serves it, or deactivate it ("disable").',
|
|
11
|
+
toolset: 'sites',
|
|
12
|
+
operations: [
|
|
13
|
+
'organizations.servers.sites.domains.certificates.actions.store',
|
|
14
|
+
'organizations.servers.sites.domains.certificates.show',
|
|
15
|
+
],
|
|
16
|
+
permissions: ['site:meta'],
|
|
17
|
+
readOnly: false,
|
|
18
|
+
destructive: false,
|
|
19
|
+
idempotent: true,
|
|
20
|
+
async: true,
|
|
21
|
+
notFoundHint: CERTIFICATE_NOT_FOUND_HINT,
|
|
22
|
+
inputSchema: {
|
|
23
|
+
...domainScopeInput,
|
|
24
|
+
certificate: certificateInput,
|
|
25
|
+
action: z.enum(['enable', 'disable']),
|
|
26
|
+
...waitInput(60),
|
|
27
|
+
},
|
|
28
|
+
outputSchema: operationOutput,
|
|
29
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
30
|
+
const path = `${domainPath(organization(args.organization), args.server, args.site, args.domain)}/certificates/${encodeURIComponent(String(args.certificate))}`;
|
|
31
|
+
await client.post(`${path}/actions`, { body: { action: args.action }, signal });
|
|
32
|
+
const action = `${args.action} certificate ${args.certificate}`;
|
|
33
|
+
if (!args.wait)
|
|
34
|
+
return queued(action, CHECK_WITH);
|
|
35
|
+
const wanted = args.action === 'enable';
|
|
36
|
+
const result = await waitFor({
|
|
37
|
+
poll: () => readCertificate(client, path, signal),
|
|
38
|
+
phase: (certificate) => (certificate.active === wanted ? certificatePhase(certificate) : 'pending'),
|
|
39
|
+
describe: (certificate) => `Certificate ${certificate.id} is ${certificate.status}`,
|
|
40
|
+
timeoutSeconds: args.timeout_seconds,
|
|
41
|
+
context: { sleep, progress },
|
|
42
|
+
});
|
|
43
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { phaseOf } from '../shared/async.js';
|
|
4
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
5
|
+
export const certificateInput = idInput('Certificate ID. Use forge_list_certificates to find it.');
|
|
6
|
+
export const CERTIFICATE_NOT_FOUND_HINT = 'Check the domain ID with forge_list_domains and the certificate ID with forge_list_certificates.';
|
|
7
|
+
const CERTIFICATE_FIELDS = [
|
|
8
|
+
'id',
|
|
9
|
+
'type',
|
|
10
|
+
'status',
|
|
11
|
+
'request_status',
|
|
12
|
+
'active',
|
|
13
|
+
'verification_method',
|
|
14
|
+
'key_type',
|
|
15
|
+
'preferred_chain',
|
|
16
|
+
'created_at',
|
|
17
|
+
'updated_at',
|
|
18
|
+
];
|
|
19
|
+
export const certificateOutput = z.looseObject({
|
|
20
|
+
id: z.string().describe('Certificate ID.'),
|
|
21
|
+
type: z.string().nullable().describe('letsencrypt, csr or existing.'),
|
|
22
|
+
status: z.string().nullable().describe('installing, installed, failed, renewing, ...'),
|
|
23
|
+
request_status: z.string().nullable().describe('verifying, creating or created.'),
|
|
24
|
+
active: z.boolean().nullable().describe('Whether the certificate is the one served for the domain.'),
|
|
25
|
+
verification_method: z.string().nullable(),
|
|
26
|
+
key_type: z.string().nullable(),
|
|
27
|
+
preferred_chain: z.string().nullable(),
|
|
28
|
+
created_at: z.string().nullable(),
|
|
29
|
+
updated_at: z.string().nullable(),
|
|
30
|
+
});
|
|
31
|
+
export function formatCertificate(flat) {
|
|
32
|
+
return pick(flat, CERTIFICATE_FIELDS);
|
|
33
|
+
}
|
|
34
|
+
export async function readCertificate(client, path, signal) {
|
|
35
|
+
const response = await client.get(path, { signal });
|
|
36
|
+
return flattenSingle(response.data);
|
|
37
|
+
}
|
|
38
|
+
const FAILED = ['failed', 'failed-unknown', 'failed-runner'];
|
|
39
|
+
const TRANSITIONAL = ['installing', 'removing', 'restarting', 'stopping', 'starting', 'syncing', 'updating', 'disabling', 'enabling', 'restoring', 'deleting', 'renewing'];
|
|
40
|
+
/** A certificate is settled once requested (`request_status: created`) and no longer transitional. */
|
|
41
|
+
export function certificatePhase(certificate) {
|
|
42
|
+
if (FAILED.includes(String(certificate.status)))
|
|
43
|
+
return 'failed';
|
|
44
|
+
if (certificate.request_status !== 'created')
|
|
45
|
+
return 'pending';
|
|
46
|
+
return phaseOf(certificate.status, { pending: TRANSITIONAL, failed: FAILED });
|
|
47
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
|
|
6
|
+
import { WWW_REDIRECT_TYPES, domainOutput, domainPhase, formatDomain, readDomain } from './shared.js';
|
|
7
|
+
const CHECK_WITH = 'forge_get_domain';
|
|
8
|
+
export const addDomain = defineTool({
|
|
9
|
+
name: 'forge_add_domain',
|
|
10
|
+
title: 'Add domain',
|
|
11
|
+
description: 'Add a domain (alias) to a site. Waits until Forge enables it unless `wait` is false; if it stays pending, the DNS records are probably missing: check them with forge_get_domain. Secure it afterwards with forge_create_certificate.',
|
|
12
|
+
toolset: 'sites',
|
|
13
|
+
operations: ['organizations.servers.sites.domains.store', 'organizations.servers.sites.domains.show'],
|
|
14
|
+
permissions: ['site:meta'],
|
|
15
|
+
readOnly: false,
|
|
16
|
+
destructive: false,
|
|
17
|
+
idempotent: false,
|
|
18
|
+
async: true,
|
|
19
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
20
|
+
inputSchema: {
|
|
21
|
+
...siteScopeInput,
|
|
22
|
+
name: z.string().min(1).describe('Domain name, e.g. "www.example.com".'),
|
|
23
|
+
www_redirect_type: z.enum(WWW_REDIRECT_TYPES).default('none').describe('Redirect www to non-www ("from-www"), the opposite ("to-www") or neither.'),
|
|
24
|
+
allow_wildcard_subdomains: z.boolean().default(false),
|
|
25
|
+
...waitInput(120),
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
...operationOutput,
|
|
29
|
+
domain: domainOutput.nullable(),
|
|
30
|
+
},
|
|
31
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
32
|
+
const base = `${sitePath(organization(args.organization), args.server, args.site)}/domains`;
|
|
33
|
+
const response = await client.post(base, {
|
|
34
|
+
body: { name: args.name, www_redirect_type: args.www_redirect_type, allow_wildcard_subdomains: args.allow_wildcard_subdomains },
|
|
35
|
+
signal,
|
|
36
|
+
});
|
|
37
|
+
const initial = response.data?.data ? flattenSingle(response.data) : undefined;
|
|
38
|
+
const action = `add the domain ${args.name}`;
|
|
39
|
+
if (!initial || !args.wait) {
|
|
40
|
+
return {
|
|
41
|
+
structured: { status: 'queued', check_with: initial ? CHECK_WITH : 'forge_list_domains', domain: initial ? formatDomain(initial) : null },
|
|
42
|
+
summary: `Forge accepted the request to ${action}. Follow it with ${initial ? `${CHECK_WITH} (domain ${initial.id})` : 'forge_list_domains'}.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const result = await waitFor({
|
|
46
|
+
initial,
|
|
47
|
+
poll: () => readDomain(client, `${base}/${encodeURIComponent(initial.id)}`, signal),
|
|
48
|
+
phase: (domain) => domainPhase(domain.status),
|
|
49
|
+
describe: (domain) => `Domain ${domain.name} is ${domain.status}`,
|
|
50
|
+
timeoutSeconds: args.timeout_seconds,
|
|
51
|
+
context: { sleep, progress },
|
|
52
|
+
});
|
|
53
|
+
const done = outcome(result, {
|
|
54
|
+
action,
|
|
55
|
+
checkWith: CHECK_WITH,
|
|
56
|
+
timeoutSeconds: args.timeout_seconds,
|
|
57
|
+
detail: result.status === 'in_progress' ? 'Check the DNS records with forge_get_domain.' : undefined,
|
|
58
|
+
});
|
|
59
|
+
return { structured: { ...done.structured, domain: formatDomain(result.value ?? initial) }, summary: done.summary };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { DOMAIN_NOT_FOUND_HINT, domainPath, domainScopeInput, readDomain } from './shared.js';
|
|
4
|
+
const CHECK_WITH = 'forge_list_domains';
|
|
5
|
+
export const deleteDomain = defineTool({
|
|
6
|
+
name: 'forge_delete_domain',
|
|
7
|
+
title: 'Delete domain',
|
|
8
|
+
description: 'Remove a domain (alias) from a site, together with its certificates. The site stops answering on that domain.',
|
|
9
|
+
toolset: 'sites',
|
|
10
|
+
operations: ['organizations.servers.sites.domains.destroy', 'organizations.servers.sites.domains.show'],
|
|
11
|
+
permissions: ['site:meta'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: true,
|
|
14
|
+
idempotent: true,
|
|
15
|
+
async: true,
|
|
16
|
+
notFoundHint: DOMAIN_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
...domainScopeInput,
|
|
19
|
+
...waitInput(60),
|
|
20
|
+
},
|
|
21
|
+
outputSchema: operationOutput,
|
|
22
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
23
|
+
const path = domainPath(organization(args.organization), args.server, args.site, args.domain);
|
|
24
|
+
await client.delete(path, { signal });
|
|
25
|
+
const action = `delete domain ${args.domain}`;
|
|
26
|
+
if (!args.wait)
|
|
27
|
+
return queued(action, CHECK_WITH);
|
|
28
|
+
const result = await waitFor({
|
|
29
|
+
poll: () => orGone(() => readDomain(client, path, signal)),
|
|
30
|
+
phase: (domain) => (domain === null ? 'completed' : 'pending'),
|
|
31
|
+
describe: (domain) => `Domain is ${domain?.status ?? 'removing'}`,
|
|
32
|
+
timeoutSeconds: args.timeout_seconds,
|
|
33
|
+
context: { sleep, progress },
|
|
34
|
+
});
|
|
35
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -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 { pick } from '../shared/schemas.js';
|
|
5
|
+
import { DOMAIN_NOT_FOUND_HINT, domainOutput, domainPath, domainScopeInput, formatDomain, readDomain } from './shared.js';
|
|
6
|
+
const dnsRecord = z.looseObject({
|
|
7
|
+
type: z.string().nullable().describe('A, CNAME or TXT.'),
|
|
8
|
+
name: z.string().nullable(),
|
|
9
|
+
value: z.string().nullable(),
|
|
10
|
+
ttl: z.number().int().nullable(),
|
|
11
|
+
});
|
|
12
|
+
export const getDomain = defineTool({
|
|
13
|
+
name: 'forge_get_domain',
|
|
14
|
+
title: 'Get domain',
|
|
15
|
+
description: 'Get a domain of a site with the DNS records that must exist at the DNS provider for it to work (and for Let\'s Encrypt to issue certificates). Use it when a domain stays "pending" or "connecting".',
|
|
16
|
+
toolset: 'sites',
|
|
17
|
+
operations: ['organizations.servers.sites.domains.show', 'organizations.servers.sites.domains.configurations'],
|
|
18
|
+
permissions: ['site:meta'],
|
|
19
|
+
readOnly: true,
|
|
20
|
+
notFoundHint: DOMAIN_NOT_FOUND_HINT,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
...domainScopeInput,
|
|
23
|
+
include_dns: z.boolean().default(true).describe('Also return the DNS records to configure.'),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: {
|
|
26
|
+
domain: domainOutput,
|
|
27
|
+
dns_records: z.array(dnsRecord).nullable().describe('DNS records to configure, or null when not requested.'),
|
|
28
|
+
},
|
|
29
|
+
async handler(args, { client, organization, signal }) {
|
|
30
|
+
const path = domainPath(organization(args.organization), args.server, args.site, args.domain);
|
|
31
|
+
const [domain, dns] = await Promise.all([
|
|
32
|
+
readDomain(client, path, signal),
|
|
33
|
+
args.include_dns ? client.get(`${path}/configurations`, { signal }) : Promise.resolve(undefined),
|
|
34
|
+
]);
|
|
35
|
+
const records = dns
|
|
36
|
+
? flattenCollection(dns.data).items.map((record) => pick(record, ['type', 'name', 'value', 'ttl']))
|
|
37
|
+
: null;
|
|
38
|
+
const formatted = formatDomain(domain);
|
|
39
|
+
return {
|
|
40
|
+
structured: { domain: formatted, dns_records: records },
|
|
41
|
+
summary: `Domain ${formatted.name} is ${formatted.status}.${records && records.length > 0 ? ` It needs ${records.length} DNS record(s) at the DNS provider.` : ''}`,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { paginationInput, paginationOutput, paginationSummary } from '../shared/schemas.js';
|
|
5
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
|
|
6
|
+
import { domainOutput, formatDomain } from './shared.js';
|
|
7
|
+
const STATUSES = ['pending', 'connecting', 'enabled', 'removing', 'securing', 'updating', 'disabling', 'disabled', 'enabling'];
|
|
8
|
+
export const listDomains = defineTool({
|
|
9
|
+
name: 'forge_list_domains',
|
|
10
|
+
title: 'List domains',
|
|
11
|
+
description: 'List the domains of a site (the primary domain and its aliases) with their status and www redirection. Use forge_get_domain for the DNS records to configure.',
|
|
12
|
+
toolset: 'sites',
|
|
13
|
+
operations: ['organizations.servers.sites.domains.index'],
|
|
14
|
+
permissions: ['site:meta'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
...siteScopeInput,
|
|
19
|
+
status: z.enum(STATUSES).optional().describe('Filter by status.'),
|
|
20
|
+
type: z.enum(['primary', 'alias']).optional().describe('Filter by type.'),
|
|
21
|
+
sort: z.array(z.enum(['name', '-name', 'created_at', '-created_at'])).min(1).optional(),
|
|
22
|
+
...paginationInput,
|
|
23
|
+
},
|
|
24
|
+
outputSchema: {
|
|
25
|
+
domains: z.array(domainOutput),
|
|
26
|
+
...paginationOutput,
|
|
27
|
+
},
|
|
28
|
+
async handler(args, { client, organization, signal }) {
|
|
29
|
+
const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/domains`, {
|
|
30
|
+
query: {
|
|
31
|
+
filter: { status: args.status, type: args.type },
|
|
32
|
+
sort: args.sort,
|
|
33
|
+
page: { size: args.page_size, cursor: args.cursor },
|
|
34
|
+
},
|
|
35
|
+
signal,
|
|
36
|
+
});
|
|
37
|
+
const page = flattenCollection(response.data);
|
|
38
|
+
const domains = page.items.map(formatDomain);
|
|
39
|
+
return {
|
|
40
|
+
structured: { domains, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
41
|
+
summary: domains.length === 0
|
|
42
|
+
? 'No domains found for the site.'
|
|
43
|
+
: `Found ${domains.length} domain(s): ${domains.map((d) => `${d.name} (${d.status})`).join(', ')}.${paginationSummary(page.nextCursor)}`,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
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 { DOMAIN_NOT_FOUND_HINT, domainPath, domainScopeInput, readDomain } from './shared.js';
|
|
5
|
+
const CHECK_WITH = 'forge_get_domain';
|
|
6
|
+
/** What the domain looks like once each action is done. */
|
|
7
|
+
const DONE = {
|
|
8
|
+
enable: (domain) => domain.status === 'enabled',
|
|
9
|
+
disable: (domain) => domain.status === 'disabled',
|
|
10
|
+
'mark-as-primary': (domain) => domain.type === 'primary',
|
|
11
|
+
};
|
|
12
|
+
export const runDomainAction = defineTool({
|
|
13
|
+
name: 'forge_run_domain_action',
|
|
14
|
+
title: 'Enable, disable or make a domain primary',
|
|
15
|
+
description: 'Run an action on a domain: "enable" or "disable" it, or "mark-as-primary" to make it the main domain of the site (the previous primary becomes an alias).',
|
|
16
|
+
toolset: 'sites',
|
|
17
|
+
operations: ['organizations.servers.sites.domains.actions.store', 'organizations.servers.sites.domains.show'],
|
|
18
|
+
permissions: ['site:meta'],
|
|
19
|
+
readOnly: false,
|
|
20
|
+
destructive: false,
|
|
21
|
+
idempotent: true,
|
|
22
|
+
async: true,
|
|
23
|
+
notFoundHint: DOMAIN_NOT_FOUND_HINT,
|
|
24
|
+
inputSchema: {
|
|
25
|
+
...domainScopeInput,
|
|
26
|
+
action: z.enum(['enable', 'disable', 'mark-as-primary']),
|
|
27
|
+
...waitInput(60),
|
|
28
|
+
},
|
|
29
|
+
outputSchema: operationOutput,
|
|
30
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
31
|
+
const path = domainPath(organization(args.organization), args.server, args.site, args.domain);
|
|
32
|
+
await client.post(`${path}/actions`, { body: { action: args.action }, signal });
|
|
33
|
+
const action = `${args.action.replace(/-/g, ' ')} domain ${args.domain}`;
|
|
34
|
+
if (!args.wait)
|
|
35
|
+
return queued(action, CHECK_WITH);
|
|
36
|
+
const result = await waitFor({
|
|
37
|
+
poll: () => readDomain(client, path, signal),
|
|
38
|
+
phase: (domain) => (DONE[args.action](domain) ? 'completed' : 'pending'),
|
|
39
|
+
describe: (domain) => `Domain ${domain.name} is ${domain.status}`,
|
|
40
|
+
timeoutSeconds: args.timeout_seconds,
|
|
41
|
+
context: { sleep, progress },
|
|
42
|
+
});
|
|
43
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { phaseOf } from '../shared/async.js';
|
|
4
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
5
|
+
import { siteScopeInput, sitePath } from '../shared/site-scope.js';
|
|
6
|
+
export const domainInput = idInput('Domain ID. Use forge_list_domains to find it.');
|
|
7
|
+
/** Arguments identifying a domain of a site. */
|
|
8
|
+
export const domainScopeInput = {
|
|
9
|
+
...siteScopeInput,
|
|
10
|
+
domain: domainInput,
|
|
11
|
+
};
|
|
12
|
+
export function domainPath(org, server, site, domain) {
|
|
13
|
+
return `${sitePath(org, server, site)}/domains/${encodeURIComponent(String(domain))}`;
|
|
14
|
+
}
|
|
15
|
+
export const DOMAIN_NOT_FOUND_HINT = 'Check the domain ID with forge_list_domains.';
|
|
16
|
+
export const WWW_REDIRECT_TYPES = ['from-www', 'to-www', 'none'];
|
|
17
|
+
const DOMAIN_FIELDS = ['id', 'name', 'type', 'status', 'www_redirect_type', 'allow_wildcard_subdomains', 'created_at'];
|
|
18
|
+
export const domainOutput = z.looseObject({
|
|
19
|
+
id: z.string().describe('Domain ID, used as the `domain` argument.'),
|
|
20
|
+
name: z.string().nullable(),
|
|
21
|
+
type: z.string().nullable().describe('primary or alias.'),
|
|
22
|
+
status: z.string().nullable().describe('pending, connecting, enabled, securing, disabled, ...'),
|
|
23
|
+
www_redirect_type: z.string().nullable(),
|
|
24
|
+
allow_wildcard_subdomains: z.boolean().nullable(),
|
|
25
|
+
created_at: z.string().nullable(),
|
|
26
|
+
});
|
|
27
|
+
export function formatDomain(flat) {
|
|
28
|
+
return pick(flat, DOMAIN_FIELDS);
|
|
29
|
+
}
|
|
30
|
+
export async function readDomain(client, path, signal) {
|
|
31
|
+
const response = await client.get(path, { signal });
|
|
32
|
+
return flattenSingle(response.data);
|
|
33
|
+
}
|
|
34
|
+
/** Domains have no failed status: anything not transitional is settled. */
|
|
35
|
+
export function domainPhase(status) {
|
|
36
|
+
return phaseOf(status, { pending: ['pending', 'connecting', 'removing', 'securing', 'updating', 'disabling', 'enabling'] });
|
|
37
|
+
}
|