@francescomalatesta/laravel-forge-mcp 0.8.0 → 0.10.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 CHANGED
@@ -136,6 +136,18 @@ npm run build
136
136
  | `forge_update_database_user` | databases | Change a user's password or databases |
137
137
  | `forge_delete_database_user` | databases | Delete a database user |
138
138
  | `forge_update_database_root_password` | databases | Change the database root password |
139
+ | `forge_list_backup_configurations` | databases | Scheduled database backups: storage, schedule, retention, next run |
140
+ | `forge_create_backup_configuration` | databases | Schedule backups of databases (by ID or name) to a storage provider |
141
+ | `forge_update_backup_configuration` | databases | Change storage, databases, schedule or retention |
142
+ | `forge_delete_backup_configuration` | databases | Stop scheduled backups |
143
+ | `forge_list_backups` | databases | Backups of a configuration with status and size |
144
+ | `forge_create_backup` | databases | Run a backup now and wait until it finishes |
145
+ | `forge_delete_backup` | databases | Delete a backup |
146
+ | `forge_restore_backup` | databases | Restore a database from a backup (overwrites it) |
147
+ | `forge_list_storage_providers` | storage | S3, Spaces, Hetzner, OVH, Scaleway or S3-compatible storage for backups |
148
+ | `forge_create_storage_provider` | storage | Add a storage provider (credentials are never returned) |
149
+ | `forge_update_storage_provider` | storage | Change name, location or credentials |
150
+ | `forge_delete_storage_provider` | storage | Remove a storage provider that no backup uses |
139
151
  | `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
140
152
  | `forge_get_deployment` | deployments | A deployment with the end of its log |
141
153
  | `forge_get_deployment_status` | deployments | Whether a deployment is running |
@@ -152,9 +164,18 @@ npm run build
152
164
  | `forge_delete_deploy_key` | deployments | Remove the deploy key |
153
165
  | `forge_get_deploy_hook` 🔑 | deployments | The deployment trigger URL |
154
166
  | `forge_regenerate_deploy_hook` 🔑 | deployments | Generate a new deployment trigger URL |
167
+ | `forge_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
168
+ | `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
169
+ | `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
170
+ | `forge_run_site_command` | commands | Run a command in the site directory and return its output |
171
+ | `forge_list_site_commands` | commands | Commands run on a site with status and exit code |
172
+ | `forge_get_site_command` | commands | A command run with the end of its output |
173
+ | `forge_delete_site_command` | commands | Remove a command run from the history |
155
174
 
156
175
  🔑 Reads or replaces secrets: registered only when `FORGE_ALLOW_SECRETS=true`. Secret fields returned by other tools (e.g. a site's `deployment_url`) are hidden unless secrets are allowed.
157
176
 
177
+ The `commands` toolset runs arbitrary shell commands with the privileges of the site user: enable it explicitly (e.g. `FORGE_TOOLSETS=default,commands`) only when you need it. Note that `all` includes it.
178
+
158
179
  ### Background operations
159
180
 
160
181
  Forge runs most changes in the background. Tools that start one say so in their description and return a `status`: `completed` or `failed` when they waited for the result, `in_progress` if it was still running at the timeout, `queued` when they returned right away. `check_with` names the tool that shows the current state. By default these tools wait for the outcome (sending progress notifications); pass `wait: false` to return immediately, or tune `timeout_seconds`.
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
6
+ import { databaseRefsInput, resolveDatabaseIds } from '../databases/shared.js';
7
+ import { backupConfigurationOutput, backupConfigurationPhase, backupConfigurationsPath, formatBackupConfiguration, newestFirst, resolveStorageProviderId, scheduleBody, scheduleInput, storageProviderRefInput, } from './shared.js';
8
+ const CHECK_WITH = 'forge_list_backup_configurations';
9
+ export const createBackupConfiguration = defineTool({
10
+ name: 'forge_create_backup_configuration',
11
+ title: 'Create backup configuration',
12
+ description: 'Schedule database backups on a server to a storage provider (S3, Spaces, …): which databases, how often, how many to keep. Waits until the configuration is installed unless `wait` is false. Run a backup immediately with forge_create_backup.',
13
+ toolset: 'databases',
14
+ operations: [
15
+ 'organizations.servers.database.backups.store',
16
+ 'organizations.servers.database.backups.index',
17
+ 'organizations.servers.database.schemas.index',
18
+ 'organizations.storage-providers.index',
19
+ ],
20
+ permissions: ['server:create-backups', 'server:view', 'storage:manage'],
21
+ readOnly: false,
22
+ destructive: false,
23
+ idempotent: false,
24
+ async: true,
25
+ notFoundHint: SERVER_NOT_FOUND_HINT,
26
+ inputSchema: {
27
+ organization: organizationInput,
28
+ server: serverInput,
29
+ storage_provider: storageProviderRefInput,
30
+ databases: databaseRefsInput.min(1).describe('Databases to back up, by ID or name, e.g. [12, "shop"].'),
31
+ retention: z.number().int().min(1).max(8760).describe('Number of backups to keep (older ones are deleted).'),
32
+ ...scheduleInput,
33
+ name: z.string().min(1).max(255).optional().describe('Name of the configuration.'),
34
+ include_new_databases: z.boolean().optional().describe('Also back up databases created later on this server.'),
35
+ bucket: z.string().min(1).optional().describe("Bucket, if different from the storage provider's."),
36
+ directory: z.string().min(1).optional().describe('Directory inside the bucket.'),
37
+ notification_email: z.string().email().optional().describe('Email notified when a backup fails.'),
38
+ ...waitInput(120),
39
+ },
40
+ outputSchema: {
41
+ ...operationOutput,
42
+ backup_configuration: backupConfigurationOutput.nullable().describe('The new configuration once it shows up.'),
43
+ },
44
+ async handler(args, { client, organization, signal, sleep, progress }) {
45
+ const schedule = scheduleBody(args);
46
+ const org = organization(args.organization);
47
+ const storageProviderId = await resolveStorageProviderId(client, org, args.storage_provider, signal);
48
+ const databaseIds = await resolveDatabaseIds(client, org, args.server, args.databases, signal);
49
+ const base = backupConfigurationsPath(org, args.server);
50
+ // Forge returns no body: remember the existing configurations to spot the new one.
51
+ const existing = args.wait ? new Set((await newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
52
+ await client.post(base, {
53
+ body: {
54
+ storage_provider_id: storageProviderId,
55
+ name: args.name,
56
+ bucket: args.bucket,
57
+ directory: args.directory,
58
+ ...schedule,
59
+ include_new_databases: args.include_new_databases,
60
+ database_ids: databaseIds,
61
+ retention: args.retention,
62
+ notification_email: args.notification_email,
63
+ },
64
+ signal,
65
+ });
66
+ const action = `create the backup configuration${args.name ? ` ${args.name}` : ''}`;
67
+ if (!existing) {
68
+ return {
69
+ structured: { status: 'queued', check_with: CHECK_WITH, backup_configuration: null },
70
+ summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
71
+ };
72
+ }
73
+ const result = await waitFor({
74
+ poll: async () => (await newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
75
+ phase: (configuration) => (configuration ? backupConfigurationPhase(configuration.status) : 'pending'),
76
+ describe: (configuration) => (configuration ? `Backup configuration is ${configuration.status}` : 'Waiting for the configuration to appear'),
77
+ timeoutSeconds: args.timeout_seconds,
78
+ context: { sleep, progress },
79
+ });
80
+ const configuration = result.value ? formatBackupConfiguration(result.value) : null;
81
+ const done = outcome(result, {
82
+ action,
83
+ checkWith: CHECK_WITH,
84
+ timeoutSeconds: args.timeout_seconds,
85
+ detail: configuration ? `Configuration ID: ${configuration.id}; next run: ${configuration.next_run_time}.` : undefined,
86
+ });
87
+ return { structured: { ...done.structured, backup_configuration: configuration }, summary: done.summary };
88
+ },
89
+ });
@@ -0,0 +1,56 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
3
+ import { organizationInput, serverInput } from '../shared/schemas.js';
4
+ import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupOutput, backupPhase, backupsPath, formatBackup, newestFirst, } from './shared.js';
5
+ const CHECK_WITH = 'forge_list_backups';
6
+ export const createBackup = defineTool({
7
+ name: 'forge_create_backup',
8
+ title: 'Run backup now',
9
+ description: 'Back up the databases of a backup configuration now, outside its schedule (e.g. before a risky deployment or migration). Waits until the backup finishes unless `wait` is false.',
10
+ toolset: 'databases',
11
+ operations: ['organizations.servers.database.backups.instances.store', 'organizations.servers.database.backups.instances.index'],
12
+ permissions: ['server:create-backups'],
13
+ readOnly: false,
14
+ destructive: false,
15
+ idempotent: false,
16
+ async: true,
17
+ notFoundHint: BACKUP_CONFIGURATION_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ organization: organizationInput,
20
+ server: serverInput,
21
+ backup_configuration: backupConfigurationInput,
22
+ ...waitInput(300),
23
+ },
24
+ outputSchema: {
25
+ ...operationOutput,
26
+ backup: backupOutput.nullable().describe('The new backup once it shows up.'),
27
+ },
28
+ async handler(args, { client, organization, signal, sleep, progress }) {
29
+ const base = backupsPath(organization(args.organization), args.server, args.backup_configuration);
30
+ // Forge returns no body: remember the existing backups to spot the new one.
31
+ const existing = args.wait ? new Set((await newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
32
+ await client.post(base, { signal });
33
+ const action = `back up configuration ${args.backup_configuration}`;
34
+ if (!existing) {
35
+ return {
36
+ structured: { status: 'queued', check_with: CHECK_WITH, backup: null },
37
+ summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
38
+ };
39
+ }
40
+ const result = await waitFor({
41
+ poll: async () => (await newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
42
+ phase: (backup) => (backup ? backupPhase(backup.status) : 'pending'),
43
+ describe: (backup) => (backup ? `Backup is ${backup.status}` : 'Waiting for the backup to start'),
44
+ timeoutSeconds: args.timeout_seconds,
45
+ context: { sleep, progress },
46
+ });
47
+ const backup = result.value ? formatBackup(result.value) : null;
48
+ const done = outcome(result, {
49
+ action,
50
+ checkWith: CHECK_WITH,
51
+ timeoutSeconds: args.timeout_seconds,
52
+ detail: backup ? `Backup ID: ${backup.id}.` : undefined,
53
+ });
54
+ return { structured: { ...done.structured, backup }, summary: done.summary };
55
+ },
56
+ });
@@ -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 { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupConfigurationPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_backup_configurations';
7
+ export const deleteBackupConfiguration = defineTool({
8
+ name: 'forge_delete_backup_configuration',
9
+ title: 'Delete backup configuration',
10
+ description: 'Stop scheduled backups by deleting a backup configuration. The databases are not touched.',
11
+ toolset: 'databases',
12
+ operations: ['organizations.servers.database.backups.destroy', 'organizations.servers.database.backups.show'],
13
+ permissions: ['server:delete-backups', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: BACKUP_CONFIGURATION_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ backup_configuration: backupConfigurationInput,
23
+ ...waitInput(60),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal, sleep, progress }) {
27
+ const path = backupConfigurationPath(organization(args.organization), args.server, args.backup_configuration);
28
+ await client.delete(path, { signal });
29
+ const action = `delete backup configuration ${args.backup_configuration}`;
30
+ if (!args.wait)
31
+ return queued(action, CHECK_WITH);
32
+ const result = await waitFor({
33
+ poll: () => orGone(() => readResource(client, path, signal)),
34
+ phase: (configuration) => (configuration === null ? 'completed' : 'pending'),
35
+ describe: (configuration) => `Backup configuration is ${configuration?.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,42 @@
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 { backupConfigurationInput, backupInput, backupsPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_backups';
7
+ export const deleteBackup = defineTool({
8
+ name: 'forge_delete_backup',
9
+ title: 'Delete backup',
10
+ description: 'Delete a backup from storage. It can no longer be restored.',
11
+ toolset: 'databases',
12
+ operations: ['organizations.servers.database.backups.instances.destroy', 'organizations.servers.database.backups.instances.show'],
13
+ permissions: ['server:delete-backups', 'server:create-backups'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: 'Check the backup configuration ID with forge_list_backup_configurations and the backup ID with forge_list_backups.',
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ backup_configuration: backupConfigurationInput,
23
+ backup: backupInput,
24
+ ...waitInput(60),
25
+ },
26
+ outputSchema: operationOutput,
27
+ async handler(args, { client, organization, signal, sleep, progress }) {
28
+ const path = `${backupsPath(organization(args.organization), args.server, args.backup_configuration)}/${encodeURIComponent(String(args.backup))}`;
29
+ await client.delete(path, { signal });
30
+ const action = `delete backup ${args.backup}`;
31
+ if (!args.wait)
32
+ return queued(action, CHECK_WITH);
33
+ const result = await waitFor({
34
+ poll: () => orGone(() => readResource(client, path, signal)),
35
+ phase: (backup) => (backup === null ? 'completed' : 'pending'),
36
+ describe: () => 'Waiting for the backup to be deleted',
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,55 @@
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 { backupConfigurationInput, backupConfigurationOutput, backupConfigurationPath, backupConfigurationsPath, formatBackupConfiguration, } from './shared.js';
8
+ const SORT = ['name', '-name', 'created_at', '-created_at', 'updated_at', '-updated_at'];
9
+ export const listBackupConfigurations = defineTool({
10
+ name: 'forge_list_backup_configurations',
11
+ title: 'List backup configurations',
12
+ description: 'List the database backup configurations of a server (schedule, storage, databases, retention, next run), or get one with `backup_configuration`. Use forge_list_backups to see the backups it produced.',
13
+ toolset: 'databases',
14
+ operations: ['organizations.servers.database.backups.index', 'organizations.servers.database.backups.show'],
15
+ permissions: ['server:view'],
16
+ readOnly: true,
17
+ notFoundHint: SERVER_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ organization: organizationInput,
20
+ server: serverInput,
21
+ backup_configuration: backupConfigurationInput.optional().describe('Return only this backup configuration.'),
22
+ name: z.string().min(1).optional().describe('Filter by name.'),
23
+ status: z.string().min(1).optional().describe('Filter by status, e.g. "installed".'),
24
+ sort: z.array(z.enum(SORT)).min(1).optional(),
25
+ ...paginationInput,
26
+ },
27
+ outputSchema: {
28
+ backup_configurations: z.array(backupConfigurationOutput),
29
+ ...paginationOutput,
30
+ },
31
+ async handler(args, { client, organization, signal }) {
32
+ const org = organization(args.organization);
33
+ if (args.backup_configuration !== undefined) {
34
+ const configuration = formatBackupConfiguration(await readResource(client, backupConfigurationPath(org, args.server, args.backup_configuration), signal));
35
+ return {
36
+ structured: { backup_configurations: [configuration], next_cursor: null, has_more: false },
37
+ summary: `Backup configuration ${configuration.name} is ${configuration.status}; schedule: ${configuration.displayable_schedule}, next run: ${configuration.next_run_time}.`,
38
+ };
39
+ }
40
+ const response = await client.get(backupConfigurationsPath(org, args.server), {
41
+ query: { filter: { name: args.name, status: args.status }, sort: args.sort, page: { size: args.page_size, cursor: args.cursor } },
42
+ signal,
43
+ });
44
+ const page = flattenCollection(response.data);
45
+ const configurations = page.items.map(formatBackupConfiguration);
46
+ return {
47
+ structured: { backup_configurations: configurations, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
48
+ summary: configurations.length === 0
49
+ ? 'No backup configurations found. Create one with forge_create_backup_configuration.'
50
+ : `Found ${configurations.length} backup configuration(s): ${configurations
51
+ .map((c) => `${c.name} (${c.id}, ${c.displayable_schedule})`)
52
+ .join(', ')}.${paginationSummary(page.nextCursor)}`,
53
+ };
54
+ },
55
+ });
@@ -0,0 +1,49 @@
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 { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupInput, backupOutput, backupsPath, formatBackup, } from './shared.js';
7
+ const SORT = ['created_at', '-created_at', 'updated_at', '-updated_at'];
8
+ export const listBackups = defineTool({
9
+ name: 'forge_list_backups',
10
+ title: 'List backups',
11
+ description: 'List the backups produced by a backup configuration (status, size, completion time), or get one with `backup`. Restore one with forge_restore_backup.',
12
+ toolset: 'databases',
13
+ operations: ['organizations.servers.database.backups.instances.index', 'organizations.servers.database.backups.instances.show'],
14
+ permissions: ['server:create-backups'],
15
+ readOnly: true,
16
+ notFoundHint: BACKUP_CONFIGURATION_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ organization: organizationInput,
19
+ server: serverInput,
20
+ backup_configuration: backupConfigurationInput,
21
+ backup: backupInput.optional().describe('Return only this backup.'),
22
+ status: z.string().min(1).optional().describe('Filter by status, e.g. "finished".'),
23
+ sort: z.array(z.enum(SORT)).min(1).optional().describe('Defaults to newest first.'),
24
+ ...paginationInput,
25
+ },
26
+ outputSchema: {
27
+ backups: z.array(backupOutput),
28
+ ...paginationOutput,
29
+ },
30
+ async handler(args, { client, organization, signal }) {
31
+ const base = backupsPath(organization(args.organization), args.server, args.backup_configuration);
32
+ if (args.backup !== undefined) {
33
+ const backup = formatBackup(await readResource(client, `${base}/${encodeURIComponent(String(args.backup))}`, signal));
34
+ return { structured: { backups: [backup], next_cursor: null, has_more: false }, summary: `Backup ${backup.id} is ${backup.status}.` };
35
+ }
36
+ const response = await client.get(base, {
37
+ query: { filter: { status: args.status }, sort: args.sort ?? ['-created_at'], page: { size: args.page_size, cursor: args.cursor } },
38
+ signal,
39
+ });
40
+ const page = flattenCollection(response.data);
41
+ const backups = page.items.map(formatBackup);
42
+ return {
43
+ structured: { backups, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
44
+ summary: backups.length === 0
45
+ ? 'No backups found. Run one now with forge_create_backup.'
46
+ : `Found ${backups.length} backup(s); the first is ${backups[0].id} (${backups[0].status}).${paginationSummary(page.nextCursor)}`,
47
+ };
48
+ },
49
+ });
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, queued } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { resolveDatabaseIds } from '../databases/shared.js';
6
+ import { backupConfigurationInput, backupInput, backupsPath } from './shared.js';
7
+ export const restoreBackup = defineTool({
8
+ name: 'forge_restore_backup',
9
+ title: 'Restore backup',
10
+ description: 'Restore a database from a backup. This OVERWRITES the current content of the database with the backup: data written since the backup is lost. Confirm with the user first and consider running forge_create_backup before.',
11
+ toolset: 'databases',
12
+ operations: ['organizations.servers.database.backups.instances.restores.store', 'organizations.servers.database.schemas.index'],
13
+ permissions: ['server:create-backups', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: false,
17
+ async: true,
18
+ notFoundHint: 'Check the backup configuration ID with forge_list_backup_configurations and the backup ID with forge_list_backups.',
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ backup_configuration: backupConfigurationInput,
23
+ backup: backupInput,
24
+ database: z
25
+ .union([z.number().int().nonnegative(), z.string().min(1)])
26
+ .describe('Database to restore, by ID or name (it must be included in the backup).'),
27
+ },
28
+ outputSchema: operationOutput,
29
+ async handler(args, { client, organization, signal }) {
30
+ const org = organization(args.organization);
31
+ const [databaseId] = await resolveDatabaseIds(client, org, args.server, [args.database], signal);
32
+ const path = `${backupsPath(org, args.server, args.backup_configuration)}/${encodeURIComponent(String(args.backup))}/restores`;
33
+ await client.post(path, { body: { database_id: databaseId }, signal });
34
+ // The API exposes no restore status: Forge records the outcome as a server event.
35
+ return queued(`restore database ${args.database} from backup ${args.backup}`, 'forge_list_server_events');
36
+ },
37
+ });
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { phaseOf } from '../shared/async.js';
5
+ import { idInput, pick } from '../shared/schemas.js';
6
+ import { serverPath } from '../servers/shared.js';
7
+ import { storageProvidersPath } from '../storage/shared.js';
8
+ export function backupConfigurationsPath(org, server) {
9
+ return `${serverPath(org, server)}/database/backups`;
10
+ }
11
+ export function backupConfigurationPath(org, server, configuration) {
12
+ return `${backupConfigurationsPath(org, server)}/${encodeURIComponent(String(configuration))}`;
13
+ }
14
+ export function backupsPath(org, server, configuration) {
15
+ return `${backupConfigurationPath(org, server, configuration)}/instances`;
16
+ }
17
+ export const backupConfigurationInput = idInput('Backup configuration ID. Use forge_list_backup_configurations to find it.');
18
+ export const backupInput = idInput('Backup ID. Use forge_list_backups to find it.');
19
+ export const BACKUP_CONFIGURATION_NOT_FOUND_HINT = 'Check the server ID with forge_list_servers and the backup configuration ID with forge_list_backup_configurations.';
20
+ export const storageProviderRefInput = z
21
+ .union([z.number().int().nonnegative(), z.string().min(1)])
22
+ .describe('Storage provider (S3, Spaces, …) by ID or name. Use forge_list_storage_providers (storage toolset) to find it.');
23
+ export const FREQUENCIES = ['hourly', 'daily', 'weekly', 'custom'];
24
+ /** Backups start on the hour or half hour (server time). */
25
+ export const BACKUP_TIMES = Array.from({ length: 48 }, (_, i) => `${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 ? '30' : '00'}`);
26
+ export const scheduleInput = {
27
+ frequency: z.enum(FREQUENCIES).describe('hourly, daily (at `time`), weekly (on `day` at `time`) or custom (`cron`).'),
28
+ day: z.number().int().min(0).max(6).optional().describe('Weekly backups only: day of the week, cron numbering (0 = Sunday … 6 = Saturday).'),
29
+ time: z.enum(BACKUP_TIMES).optional().describe('Daily and weekly backups only: time of day, on the hour or half hour, e.g. "03:30".'),
30
+ cron: z.string().min(1).optional().describe('Custom frequency only: cron expression, e.g. "0 */6 * * *".'),
31
+ };
32
+ /** Validates that schedule fields match the frequency and maps them to the request body. */
33
+ export function scheduleBody({ frequency, day, time, cron }) {
34
+ if (frequency === 'custom' && cron === undefined)
35
+ throw new ToolInputError('A custom frequency needs a `cron` expression.');
36
+ if (frequency !== 'custom' && cron !== undefined)
37
+ throw new ToolInputError('`cron` is only used with frequency "custom".');
38
+ if (frequency !== 'weekly' && day !== undefined)
39
+ throw new ToolInputError('`day` is only used with frequency "weekly".');
40
+ if ((frequency === 'hourly' || frequency === 'custom') && time !== undefined) {
41
+ throw new ToolInputError('`time` is only used with frequency "daily" or "weekly".');
42
+ }
43
+ return { frequency, day: day === undefined ? undefined : String(day), time, cron };
44
+ }
45
+ const CONFIGURATION_FIELDS = [
46
+ 'id',
47
+ 'name',
48
+ 'status',
49
+ 'storage_provider_id',
50
+ 'provider',
51
+ 'bucket',
52
+ 'directory',
53
+ 'displayable_schedule',
54
+ 'next_run_time',
55
+ 'schedule',
56
+ 'day_of_week',
57
+ 'time',
58
+ 'cron_schedule',
59
+ 'database_ids',
60
+ 'include_new_databases',
61
+ 'retention',
62
+ 'notify_email',
63
+ ];
64
+ export const backupConfigurationOutput = z.looseObject({
65
+ id: z.string(),
66
+ name: z.string().nullable(),
67
+ status: z.string().nullable().describe('e.g. installing, installed, removing.'),
68
+ storage_provider_id: z.number().nullable(),
69
+ provider: z.string().nullable(),
70
+ bucket: z.string().nullable(),
71
+ directory: z.string().nullable(),
72
+ displayable_schedule: z.string().nullable(),
73
+ next_run_time: z.string().nullable(),
74
+ schedule: z.string().nullable(),
75
+ day_of_week: z.number().nullable(),
76
+ time: z.string().nullable(),
77
+ cron_schedule: z.string().nullable(),
78
+ database_ids: z.array(z.unknown()).nullable(),
79
+ include_new_databases: z.boolean().nullable(),
80
+ retention: z.number().nullable().describe('Number of backups kept.'),
81
+ notify_email: z.string().nullable(),
82
+ });
83
+ export function formatBackupConfiguration(flat) {
84
+ return pick(flat, CONFIGURATION_FIELDS);
85
+ }
86
+ export function backupConfigurationPhase(status) {
87
+ return phaseOf(status, { pending: ['installing', 'updating', 'removing'], failed: ['failed'] });
88
+ }
89
+ export const backupOutput = z.looseObject({
90
+ id: z.string(),
91
+ status: z.string().nullable().describe('"finished" when the backup succeeded.'),
92
+ is_partial: z.unknown().nullable().describe('Whether some databases could not be backed up.'),
93
+ size: z.number().nullable().describe('Size in bytes.'),
94
+ finished_at: z.number().nullable().describe('Unix timestamp.'),
95
+ });
96
+ export function formatBackup(flat) {
97
+ return pick(flat, ['id', 'status', 'is_partial', 'size', 'finished_at']);
98
+ }
99
+ /** Only "finished" means the backup succeeded: any other value is still running. */
100
+ export function backupPhase(status) {
101
+ return phaseOf(status, { completed: ['finished'], failed: ['failed'] });
102
+ }
103
+ /** First page of a collection, newest first: used to spot items created by a write that returns no body. */
104
+ export async function newestFirst(client, path, signal) {
105
+ const response = await client.get(path, { query: { sort: ['-created_at'], page: { size: 100 } }, signal });
106
+ return flattenCollection(response.data).items;
107
+ }
108
+ /** Resolves a storage provider ID or name to its ID. */
109
+ export async function resolveStorageProviderId(client, org, ref, signal) {
110
+ if (typeof ref === 'number' || /^\d+$/.test(ref))
111
+ return Number(ref);
112
+ const response = await client.get(storageProvidersPath(org), { query: { page: { size: 100 } }, signal });
113
+ const match = flattenCollection(response.data).items.find((provider) => provider.name === ref);
114
+ if (!match) {
115
+ throw new ToolInputError(`Storage provider "${ref}" not found. Check the name with forge_list_storage_providers (storage toolset).`);
116
+ }
117
+ return Number(match.id);
118
+ }