@jumpgroup/laravel-tools 3.3.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.
Files changed (78) hide show
  1. package/.claude/settings.local.json +59 -0
  2. package/README.md +378 -0
  3. package/bin/groups/cache.js +52 -0
  4. package/bin/groups/database.js +105 -0
  5. package/bin/groups/forge.js +272 -0
  6. package/bin/groups/local.js +78 -0
  7. package/bin/groups/media.js +110 -0
  8. package/bin/tools.js +23 -0
  9. package/docs/Changelog.md +267 -0
  10. package/docs/TODO.md +167 -0
  11. package/docs/releases/release_0.0.1.md +116 -0
  12. package/docs/releases/release_0.0.2.md +88 -0
  13. package/docs/releases/release_0.0.3.md +58 -0
  14. package/docs/releases/release_0.0.4.md +128 -0
  15. package/docs/releases/release_0.0.5.md +77 -0
  16. package/docs/releases/release_0.0.6.md +80 -0
  17. package/docs/releases/release_1.0.0.md +61 -0
  18. package/docs/releases/release_1.0.1.md +18 -0
  19. package/docs/releases/release_1.0.2.md +18 -0
  20. package/docs/releases/release_1.0.3.md +19 -0
  21. package/docs/releases/release_1.1.0.md +18 -0
  22. package/docs/releases/release_1.1.1.md +17 -0
  23. package/docs/releases/release_1.1.2.md +18 -0
  24. package/docs/releases/release_1.1.3.md +21 -0
  25. package/docs/releases/release_1.1.4.md +18 -0
  26. package/docs/releases/release_1.1.5.md +18 -0
  27. package/docs/releases/release_1.1.6.md +21 -0
  28. package/docs/releases/release_1.1.7.md +17 -0
  29. package/docs/releases/release_2.0.0.md +192 -0
  30. package/docs/releases/release_2.0.1.md +53 -0
  31. package/docs/releases/release_2.0.2.md +55 -0
  32. package/docs/releases/release_2.0.3.md +69 -0
  33. package/docs/releases/release_2.1.0.md +59 -0
  34. package/docs/releases/release_2.2.0.md +83 -0
  35. package/docs/releases/release_2.2.1.md +36 -0
  36. package/docs/releases/release_2.2.2.md +57 -0
  37. package/docs/releases/release_2.2.3.md +39 -0
  38. package/docs/releases/release_2.2.4.md +75 -0
  39. package/docs/releases/release_2.2.5.md +69 -0
  40. package/docs/releases/release_3.0.0.md +87 -0
  41. package/docs/releases/release_3.0.1.md +65 -0
  42. package/docs/releases/release_3.1.0.md +90 -0
  43. package/docs/releases/release_3.2.0.md +74 -0
  44. package/docs/releases/release_3.3.0.md +72 -0
  45. package/package.json +35 -0
  46. package/src/aws/bucket.js +287 -0
  47. package/src/aws/cloudfront.js +433 -0
  48. package/src/aws/config.js +39 -0
  49. package/src/aws/iam.js +189 -0
  50. package/src/cache.js +49 -0
  51. package/src/database.js +315 -0
  52. package/src/forge/client.js +43 -0
  53. package/src/forge/config.js +33 -0
  54. package/src/forge/provisioning.js +191 -0
  55. package/src/forge/servers.js +27 -0
  56. package/src/forge/sites.js +93 -0
  57. package/src/google/groupMembers.js +35 -0
  58. package/src/google/utilities.js +39 -0
  59. package/src/local/doctor.js +214 -0
  60. package/src/local/setup.js +398 -0
  61. package/src/media.js +143 -0
  62. package/src/stub/docker/mysql/my.cnf +6 -0
  63. package/src/stub/docker/php/local.ini +4 -0
  64. package/src/stub/docker/traefik/dynamic_conf.yml +4 -0
  65. package/src/stub/docker/traefik/traefik.yml +24 -0
  66. package/src/stub/docker-compose/php8.0/docker-compose.yml +78 -0
  67. package/src/stub/docker-compose/php8.1/docker-compose.yml +78 -0
  68. package/src/stub/docker-compose/php8.2/docker-compose.yml +78 -0
  69. package/src/stub/docker-compose/php8.3/docker-compose.yml +78 -0
  70. package/src/stub/docker-compose/php8.4/docker-compose.yml +78 -0
  71. package/src/stub/docker-compose.yml +78 -0
  72. package/src/utilities/command.js +137 -0
  73. package/src/utilities/dateUtils.js +7 -0
  74. package/src/utilities/fileUtils.js +36 -0
  75. package/src/utilities/google-drive.js +69 -0
  76. package/src/utilities/pathUtils.js +15 -0
  77. package/src/utilities/userInput.js +28 -0
  78. package/src/utilities/utilities.js +57 -0
@@ -0,0 +1,39 @@
1
+ import { fromIni } from '@aws-sdk/credential-provider-ini';
2
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
3
+
4
+ const DEFAULT_REGION = 'eu-central-1';
5
+ const DEFAULT_PROFILE = 'default';
6
+ const CLOUDFRONT_REGION = 'us-east-1';
7
+
8
+ export const getAwsProfile = () => process.env.AWS_PROFILE || DEFAULT_PROFILE;
9
+
10
+ export const getAwsRegion = () =>
11
+ process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || DEFAULT_REGION;
12
+
13
+ export const getCloudFrontRegion = () =>
14
+ process.env.AWS_CLOUDFRONT_REGION || CLOUDFRONT_REGION;
15
+
16
+ export const getAwsCredentials = () =>
17
+ fromIni({
18
+ profile: getAwsProfile(),
19
+ });
20
+
21
+ let cachedAccountId = null;
22
+ export const getAwsAccountId = async () => {
23
+ if (cachedAccountId) {
24
+ return cachedAccountId;
25
+ }
26
+
27
+ const sts = new STSClient({
28
+ region: getCloudFrontRegion(),
29
+ credentials: getAwsCredentials(),
30
+ });
31
+
32
+ const data = await sts.send(new GetCallerIdentityCommand({}));
33
+ if (!data.Account) {
34
+ throw new Error('Impossibile determinare AWS account ID.');
35
+ }
36
+
37
+ cachedAccountId = data.Account;
38
+ return cachedAccountId;
39
+ };
package/src/aws/iam.js ADDED
@@ -0,0 +1,189 @@
1
+ import {
2
+ IAMClient,
3
+ GetUserCommand,
4
+ CreateUserCommand,
5
+ PutUserPolicyCommand,
6
+ CreateAccessKeyCommand,
7
+ ListAccessKeysCommand,
8
+ DeleteAccessKeyCommand,
9
+ TagUserCommand,
10
+ } from '@aws-sdk/client-iam';
11
+
12
+ import { getAwsCredentials } from './config.js';
13
+ import { getAppName } from '../utilities/command.js';
14
+
15
+ const createIamClient = () =>
16
+ new IAMClient({
17
+ region: 'us-east-1',
18
+ endpoint: 'https://iam.amazonaws.com',
19
+ credentials: getAwsCredentials(),
20
+ });
21
+
22
+ const MEDIA_POLICY_NAME = 'MediaBucketAccessPolicy';
23
+
24
+ const getLegacyAndPreferredUsernames = (projectName) => ({
25
+ preferred: `${projectName}-media`,
26
+ legacy: `media-user.${projectName}`,
27
+ });
28
+
29
+ const userExists = async (client, username) => {
30
+ try {
31
+ await client.send(new GetUserCommand({ UserName: username }));
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ };
37
+
38
+ const ensureUser = async (client, projectName, options = {}) => {
39
+ const dryRun = Boolean(options.dryRun);
40
+ const { preferred, legacy } = getLegacyAndPreferredUsernames(projectName);
41
+
42
+ if (await userExists(client, preferred)) {
43
+ return { username: preferred, exists: true };
44
+ }
45
+
46
+ if (await userExists(client, legacy)) {
47
+ return { username: legacy, exists: true };
48
+ }
49
+
50
+ if (dryRun) {
51
+ console.log(`🧪 [dry-run] Creo IAM user ${preferred}`);
52
+ return { username: preferred, exists: false };
53
+ }
54
+
55
+ await client.send(new CreateUserCommand({ UserName: preferred }));
56
+ return { username: preferred, exists: false };
57
+ };
58
+
59
+ const rotateAccessKeysSafely = async (client, username, options = {}) => {
60
+ const dryRun = Boolean(options.dryRun);
61
+ const userExists = options.userExists !== false;
62
+
63
+ if (dryRun && !userExists) {
64
+ console.log(`🧪 [dry-run] Creo nuova access key per ${username}`);
65
+ return {
66
+ AccessKeyId: `DRYRUNACCESSKEY-${username}`,
67
+ SecretAccessKey: 'DRYRUN-SECRET',
68
+ };
69
+ }
70
+
71
+ const existing = await client.send(new ListAccessKeysCommand({ UserName: username }));
72
+ const keys = existing.AccessKeyMetadata || [];
73
+
74
+ if (keys.length >= 2) {
75
+ throw new Error(
76
+ `L'utente IAM ${username} ha giĆ  2 access key. Rotazione non-safe: elimina manualmente una key e rilancia.`
77
+ );
78
+ }
79
+
80
+ if (dryRun) {
81
+ console.log(
82
+ `🧪 [dry-run] Creo nuova access key per ${username}${keys.length === 1 ? ' e poi rimuovo la precedente' : ''}`
83
+ );
84
+ return {
85
+ AccessKeyId: `DRYRUNACCESSKEY-${username}`,
86
+ SecretAccessKey: 'DRYRUN-SECRET',
87
+ };
88
+ }
89
+
90
+ const createResponse = await client.send(new CreateAccessKeyCommand({ UserName: username }));
91
+ const newKey = createResponse.AccessKey;
92
+ if (!newKey) {
93
+ throw new Error(`Creazione access key fallita per ${username}.`);
94
+ }
95
+
96
+ if (keys.length === 1) {
97
+ await client.send(
98
+ new DeleteAccessKeyCommand({
99
+ UserName: username,
100
+ AccessKeyId: keys[0].AccessKeyId,
101
+ })
102
+ );
103
+ }
104
+
105
+ return newKey;
106
+ };
107
+
108
+ const buildMediaPolicy = ({ bucketName, cloudfrontId }) => {
109
+ const statements = [
110
+ {
111
+ Sid: 'BucketListAndLocation',
112
+ Effect: 'Allow',
113
+ Action: ['s3:ListBucket', 's3:GetBucketLocation'],
114
+ Resource: `arn:aws:s3:::${bucketName}`,
115
+ },
116
+ {
117
+ Sid: 'BucketObjectsReadWrite',
118
+ Effect: 'Allow',
119
+ Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'],
120
+ Resource: `arn:aws:s3:::${bucketName}/*`,
121
+ },
122
+ ];
123
+
124
+ if (cloudfrontId) {
125
+ statements.push({
126
+ Sid: 'CloudFrontInvalidation',
127
+ Effect: 'Allow',
128
+ Action: ['cloudfront:CreateInvalidation', 'cloudfront:GetInvalidation'],
129
+ Resource: '*',
130
+ });
131
+ }
132
+
133
+ return {
134
+ Version: '2012-10-17',
135
+ Statement: statements,
136
+ };
137
+ };
138
+
139
+ export const createIAMUser = async (projectName, cloudfrontId = null, options = {}) => {
140
+ const client = createIamClient();
141
+ const resolvedProjectName = projectName || getAppName();
142
+ const dryRun = Boolean(options.dryRun);
143
+ const userInfo = await ensureUser(client, resolvedProjectName, { dryRun });
144
+ const username = userInfo.username;
145
+ const bucketName = `${resolvedProjectName}-media`;
146
+
147
+ const policy = buildMediaPolicy({ bucketName, cloudfrontId });
148
+ if (dryRun) {
149
+ console.log(`🧪 [dry-run] Applico/aggiorno inline policy ${MEDIA_POLICY_NAME} su ${username}`);
150
+ } else {
151
+ await client.send(
152
+ new PutUserPolicyCommand({
153
+ UserName: username,
154
+ PolicyName: MEDIA_POLICY_NAME,
155
+ PolicyDocument: JSON.stringify(policy),
156
+ })
157
+ );
158
+ }
159
+
160
+ if (dryRun) {
161
+ console.log(`🧪 [dry-run] Aggiungo/aggiorno tag site=${resolvedProjectName} su ${username}`);
162
+ } else {
163
+ await client.send(
164
+ new TagUserCommand({
165
+ UserName: username,
166
+ Tags: [{ Key: 'site', Value: resolvedProjectName }],
167
+ })
168
+ );
169
+ }
170
+
171
+ const accessKey = await rotateAccessKeysSafely(client, username, {
172
+ dryRun,
173
+ userExists: userInfo.exists,
174
+ });
175
+
176
+ const credentials = {
177
+ username,
178
+ AWS_ACCESS_KEY_ID: accessKey.AccessKeyId,
179
+ AWS_SECRET_ACCESS_KEY: accessKey.SecretAccessKey,
180
+ AWS_BUCKET: bucketName,
181
+ ...(cloudfrontId ? { CLOUDFRONT_DISTRIBUTION_ID: cloudfrontId } : {}),
182
+ };
183
+
184
+ console.log(`āœ… IAM media user pronto: ${username}`);
185
+ return credentials;
186
+ };
187
+
188
+ // Backward-compatible alias (old laravel-tools naming).
189
+ export const createMediaIAMUser = createIAMUser;
package/src/cache.js ADDED
@@ -0,0 +1,49 @@
1
+ import { checkIfEnvFilesExist, getEnvironment } from './utilities/utilities.js';
2
+ import { executeCommand, getIp, getSshUser, getAppName, getServerPath } from './utilities/command.js';
3
+
4
+ // The four standard Laravel cache layers to clear.
5
+ // Equivalent of `wp cache flush` in trellis-tools, but Laravel has separate
6
+ // caches for application data, config, routes, and compiled views.
7
+ export const ARTISAN_CLEAR_COMMANDS = ['cache:clear', 'config:clear', 'route:clear', 'view:clear'];
8
+
9
+ // Clears all Laravel caches inside the local Docker app container.
10
+ // Equivalent of trellis-tools `cache flush-local` (which flushed Memcached).
11
+ export const flushLocal = async () => {
12
+ checkIfEnvFilesExist();
13
+
14
+ const appName = getAppName();
15
+
16
+ console.log(`šŸ”§ Clearing Laravel caches in ${appName}-app...`);
17
+ for (const cmd of ARTISAN_CLEAR_COMMANDS) {
18
+ console.log(` php artisan ${cmd}`);
19
+ await executeCommand('docker', ['exec', `${appName}-api`, 'php', 'artisan', cmd]);
20
+ }
21
+
22
+ console.log('āœ… Cache locale svuotata!');
23
+ };
24
+
25
+ // Clears all Laravel caches on a remote server via SSH.
26
+ // Equivalent of trellis-tools `cache flush-remote`.
27
+ export const flushRemote = async (options = {}) => {
28
+ checkIfEnvFilesExist();
29
+
30
+ const { environment } = options;
31
+ const env = environment || (await getEnvironment());
32
+ const ip = getIp(env);
33
+ const sshUser = getSshUser(env);
34
+ const appName = getAppName();
35
+ const serverPath = getServerPath(env);
36
+
37
+ console.log(`šŸ”§ Clearing Laravel caches on ${env} (${sshUser}@${ip})...`);
38
+ const clearCmd = ARTISAN_CLEAR_COMMANDS.map((cmd) => `php artisan ${cmd}`).join(' && ');
39
+ await executeCommand('ssh', [`${sshUser}@${ip}`, `cd ${serverPath} && ${clearCmd}`]);
40
+
41
+ console.log('āœ… Cache remota svuotata!');
42
+ };
43
+
44
+ // Clears caches both locally and on the remote server.
45
+ // Equivalent of trellis-tools `cache flush-all`.
46
+ export const flushAll = async (options = {}) => {
47
+ await flushLocal();
48
+ await flushRemote(options);
49
+ };
@@ -0,0 +1,315 @@
1
+ import { readFileSync } from 'fs';
2
+ import { basename } from 'path';
3
+ import { parse } from 'dotenv';
4
+ import select from '@inquirer/select';
5
+
6
+ import { checkIfEnvFilesExist, getEnvironment, getUserName } from './utilities/utilities.js';
7
+ import { executeCommand, getIp, getSshUser, getAppName, getServerPath } from './utilities/command.js';
8
+ import { getCurrentDateString } from './utilities/dateUtils.js';
9
+ import { find10LatestFiles } from './utilities/fileUtils.js';
10
+ import { resolveDatabasePath, ensureDirectoryExists } from './utilities/pathUtils.js';
11
+ import { askYesNo, generateDatabaseFilename } from './utilities/userInput.js';
12
+ import { ARTISAN_CLEAR_COMMANDS } from './cache.js';
13
+
14
+ // Reads DB credentials from the local .env file.
15
+ // Prefers Laravel's DB_DATABASE but still accepts legacy DB_NAME.
16
+ const readLocalDbCredentials = () => {
17
+ try {
18
+ const env = parse(readFileSync('.env'));
19
+ const DB_DATABASE = env.DB_DATABASE || env.DB_NAME;
20
+ const { DB_USERNAME, DB_PASSWORD } = env;
21
+ if (!DB_DATABASE || !DB_USERNAME || !DB_PASSWORD) {
22
+ throw new Error('DB_DATABASE/DB_NAME, DB_USERNAME, or DB_PASSWORD missing in .env');
23
+ }
24
+ return { DB_DATABASE, DB_USERNAME, DB_PASSWORD };
25
+ } catch {
26
+ throw new Error('Could not read DB credentials from .env — run local setup first.');
27
+ }
28
+ };
29
+
30
+ const getCurrentTimestamp = () => {
31
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '_');
32
+ };
33
+
34
+ const renderCommand = (command, args = []) => [command, ...args].join(' ');
35
+
36
+ const executeOrDryRun = async (command, args = [], options = {}) => {
37
+ const { dryRun = false, summary = null } = options;
38
+ if (dryRun) {
39
+ if (summary) {
40
+ console.log(`🧪 [dry-run] ${summary}`);
41
+ }
42
+ console.log(` ${renderCommand(command, args)}`);
43
+ return;
44
+ }
45
+ await executeCommand(command, args);
46
+ };
47
+
48
+ // Equivalent of trellis-tools `database remote-export`.
49
+ // Connects via SSH, runs mysqldump on the server (using the server's own .env),
50
+ // downloads the file via scp, then removes the remote copy.
51
+ export const remoteDownload = async (options = {}) => {
52
+ const { amsMode = false, name: customName, dryRun = false } = options;
53
+
54
+ checkIfEnvFilesExist();
55
+
56
+ const env = options.environment || (await getEnvironment());
57
+ const ip = getIp(env);
58
+ const sshUser = getSshUser(env);
59
+ const appName = getAppName();
60
+ const serverPath = getServerPath(env);
61
+ const date = getCurrentDateString();
62
+ const username = dryRun ? null : await getUserName();
63
+ const filename = generateDatabaseFilename(appName, env, date, customName, amsMode, username);
64
+
65
+ const localPath = await resolveDatabasePath(appName);
66
+ ensureDirectoryExists(localPath);
67
+
68
+ console.log(`šŸš€ Esportando il database da ${env} (${sshUser}@${ip})...`);
69
+ await executeOrDryRun('ssh', [
70
+ `${sshUser}@${ip}`,
71
+ `cd ${serverPath} && source .env && mysqldump -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" > ${filename}`,
72
+ ], { dryRun, summary: 'Eseguo dump DB remoto sul server' });
73
+
74
+ console.log(`šŸ“„ Scaricando ${filename}...`);
75
+ await executeOrDryRun('scp', [
76
+ `${sshUser}@${ip}:${serverPath}/${filename}`,
77
+ `${localPath}/${filename}`,
78
+ ], { dryRun, summary: 'Scarico dump remoto in locale' });
79
+
80
+ console.log('šŸ—‘ļø Eliminando il file temporaneo dal server...');
81
+ await executeOrDryRun('ssh', [`${sshUser}@${ip}`, `rm ${serverPath}/${filename}`], {
82
+ dryRun,
83
+ summary: 'Pulisco dump temporaneo dal server',
84
+ });
85
+
86
+ if (dryRun) {
87
+ console.log(`āœ… [dry-run] Preview completata. File target: ${localPath}/${filename}`);
88
+ return;
89
+ }
90
+ console.log(`āœ… Database salvato in: ${localPath}/${filename}`);
91
+ };
92
+
93
+ // Equivalent of trellis-tools `database local-import`.
94
+ // Lists available dumps from Google Drive, lets the user pick one,
95
+ // imports it into the local Docker MySQL container, then clears Laravel caches.
96
+ export const dbLocalImport = async (options = {}) => {
97
+ const { dryRun = false } = options;
98
+
99
+ checkIfEnvFilesExist();
100
+
101
+ const env = options.environment || (await getEnvironment());
102
+ const appName = getAppName();
103
+ const localPath = await resolveDatabasePath(appName);
104
+ const files = find10LatestFiles(localPath, env);
105
+
106
+ if (files.length === 0) {
107
+ console.log(`āŒ Nessun dump trovato per '${env}' in:\n ${localPath}`);
108
+ return;
109
+ }
110
+
111
+ const selected = await select({
112
+ message: 'Seleziona il database da importare:',
113
+ choices: files.map((f) => ({ name: f.display, value: f.path })),
114
+ });
115
+
116
+ if (dryRun) {
117
+ await executeOrDryRun('sh', [
118
+ '-c',
119
+ `docker exec -i ${appName}-mysql mysql -u"<DB_USERNAME>" -p"<DB_PASSWORD>" "<DB_DATABASE>" < "${selected}"`,
120
+ ], { dryRun, summary: `Import locale simulato di ${basename(selected)}` });
121
+ for (const cmd of ARTISAN_CLEAR_COMMANDS) {
122
+ await executeOrDryRun('docker', ['exec', `${appName}-api`, 'php', 'artisan', cmd], {
123
+ dryRun,
124
+ summary: `Clear cache locale (${cmd})`,
125
+ });
126
+ }
127
+ console.log('āœ… [dry-run] Import locale simulato con successo.');
128
+ return;
129
+ }
130
+
131
+ const { DB_DATABASE, DB_USERNAME, DB_PASSWORD } = readLocalDbCredentials();
132
+
133
+ console.log(`šŸš€ Importando ${basename(selected)}...`);
134
+ await executeCommand('sh', [
135
+ '-c',
136
+ `docker exec -i ${appName}-mysql mysql -u"${DB_USERNAME}" -p"${DB_PASSWORD}" "${DB_DATABASE}" < "${selected}"`,
137
+ ]);
138
+
139
+ console.log('šŸ”§ Clearing Laravel caches...');
140
+ for (const cmd of ARTISAN_CLEAR_COMMANDS) {
141
+ await executeCommand('docker', ['exec', `${appName}-api`, 'php', 'artisan', cmd]);
142
+ }
143
+ console.log('āœ… Database importato con successo!');
144
+ };
145
+
146
+ // Equivalent of trellis-tools `database local-export`.
147
+ // Dumps the local Docker MySQL database and saves it to Google Drive.
148
+ export const dbLocalExport = async (options = {}) => {
149
+ const { amsMode = false, name: customName, dryRun = false } = options;
150
+
151
+ checkIfEnvFilesExist();
152
+
153
+ const appName = getAppName();
154
+ const date = getCurrentDateString();
155
+ const username = dryRun ? null : await getUserName();
156
+ const filename = generateDatabaseFilename(appName, 'local', date, customName, amsMode, username);
157
+
158
+ const localPath = await resolveDatabasePath(appName);
159
+ ensureDirectoryExists(localPath);
160
+
161
+ if (dryRun) {
162
+ await executeOrDryRun('sh', [
163
+ '-c',
164
+ `docker exec ${appName}-mysql mysqldump -u"<DB_USERNAME>" -p"<DB_PASSWORD>" "<DB_DATABASE>" > "${localPath}/${filename}"`,
165
+ ], { dryRun, summary: 'Esportazione DB locale simulata' });
166
+ console.log(`āœ… [dry-run] Preview completata. File target: ${localPath}/${filename}`);
167
+ return;
168
+ }
169
+
170
+ const { DB_DATABASE, DB_USERNAME, DB_PASSWORD } = readLocalDbCredentials();
171
+
172
+ console.log('šŸš€ Esportando il database locale...');
173
+ await executeCommand('sh', [
174
+ '-c',
175
+ `docker exec ${appName}-mysql mysqldump -u"${DB_USERNAME}" -p"${DB_PASSWORD}" "${DB_DATABASE}" > "${localPath}/${filename}"`,
176
+ ]);
177
+
178
+ console.log(`āœ… Database salvato in: ${localPath}/${filename}`);
179
+ };
180
+
181
+ // Equivalent of trellis-tools `database remote-import`.
182
+ // Uploads a local dump to the server via scp and imports it, then clears remote caches.
183
+ export const remoteImport = async (options = {}) => {
184
+ const {
185
+ dryRun = false,
186
+ } = options;
187
+
188
+ checkIfEnvFilesExist();
189
+
190
+ const env = options.environment || (await getEnvironment());
191
+ const ip = getIp(env);
192
+ const sshUser = getSshUser(env);
193
+ const appName = getAppName();
194
+ const serverPath = getServerPath(env);
195
+
196
+ const localPath = await resolveDatabasePath(appName);
197
+ const files = find10LatestFiles(localPath, env);
198
+
199
+ if (files.length === 0) {
200
+ console.log(`āŒ Nessun dump trovato per '${env}' in:\n ${localPath}`);
201
+ return;
202
+ }
203
+
204
+ const selected = await select({
205
+ message: 'Seleziona il database da importare sul server:',
206
+ choices: files.map((f) => ({ name: f.display, value: f.path })),
207
+ });
208
+ const filename = basename(selected);
209
+
210
+ if (!dryRun) {
211
+ const confirmed = await askYesNo(
212
+ `Confermi import REMOTO su '${env}' (${sshUser}@${ip}) usando '${filename}'?`
213
+ );
214
+ if (!confirmed) {
215
+ console.log('ā¹ļø Operazione annullata.');
216
+ return;
217
+ }
218
+ }
219
+
220
+ let createBackupBeforeImport = true;
221
+ if (!dryRun) {
222
+ createBackupBeforeImport = await askYesNo(
223
+ "Vuoi creare un backup del DB remoto prima dell'import?"
224
+ );
225
+ }
226
+
227
+ const backupFilename = `${appName}_${env}_preimport_${getCurrentTimestamp()}.sql`;
228
+
229
+ if (createBackupBeforeImport) {
230
+ console.log(`šŸ›Ÿ Creazione backup pre-import su ${env}: ${backupFilename}`);
231
+ await executeOrDryRun('ssh', [
232
+ `${sshUser}@${ip}`,
233
+ `cd ${serverPath} && source .env && mysqldump -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" > ${backupFilename}`,
234
+ ], { dryRun, summary: 'Creo backup pre-import remoto' });
235
+ } else {
236
+ console.log('āš ļø Backup pre-import disabilitato (--no-create-backup-before-import).');
237
+ }
238
+
239
+ console.log(`šŸ“¤ Caricando ${filename} su ${env} (${sshUser}@${ip})...`);
240
+ await executeOrDryRun('scp', [selected, `${sshUser}@${ip}:${serverPath}/${filename}`], {
241
+ dryRun,
242
+ summary: 'Carico dump locale sul server remoto',
243
+ });
244
+
245
+ let imported = false;
246
+ try {
247
+ console.log(`šŸš€ Importando il database su ${env}...`);
248
+ await executeOrDryRun('ssh', [
249
+ `${sshUser}@${ip}`,
250
+ `cd ${serverPath} && source .env && mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" < ${filename}`,
251
+ ], { dryRun, summary: 'Import DB remoto da dump caricato' });
252
+
253
+ await executeOrDryRun('ssh', [
254
+ `${sshUser}@${ip}`,
255
+ `cd ${serverPath} && source .env && mysql -u"$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" -e "SELECT 1;"`,
256
+ ], { dryRun, summary: 'Health check DB remoto (SELECT 1)' });
257
+
258
+ console.log('šŸ”§ Clearing Laravel caches sul server...');
259
+ const clearCmd = ARTISAN_CLEAR_COMMANDS.map((cmd) => `php artisan ${cmd}`).join(' && ');
260
+ await executeOrDryRun('ssh', [`${sshUser}@${ip}`, `cd ${serverPath} && ${clearCmd}`], {
261
+ dryRun,
262
+ summary: 'Clear cache Laravel remoto post-import',
263
+ });
264
+
265
+ imported = true;
266
+ } catch (error) {
267
+ if (createBackupBeforeImport) {
268
+ console.error('🚨 Import remoto fallito. Backup pre-import disponibile per rollback.');
269
+ console.error(
270
+ `Rollback command: ssh ${sshUser}@${ip} "cd ${serverPath} && source .env && mysql -u\\"$DB_USERNAME\\" -p\\"$DB_PASSWORD\\" \\"$DB_DATABASE\\" < ${backupFilename}"`
271
+ );
272
+ }
273
+ throw error;
274
+ } finally {
275
+ if (imported) {
276
+ console.log('šŸ—‘ļø Eliminando il file temporaneo dal server...');
277
+ await executeOrDryRun('ssh', [`${sshUser}@${ip}`, `rm ${serverPath}/${filename}`], {
278
+ dryRun,
279
+ summary: 'Pulizia dump temporaneo remoto post-import',
280
+ });
281
+ } else {
282
+ console.log('ā„¹ļø File dump remoto non rimosso automaticamente (import non completato).');
283
+ }
284
+ }
285
+
286
+ if (dryRun) {
287
+ console.log('āœ… [dry-run] Remote import simulato con successo.');
288
+ return;
289
+ }
290
+ console.log('āœ… Database importato con successo!');
291
+ };
292
+
293
+ // Equivalent of trellis-tools `database list`.
294
+ // Lists the 10 most recent dumps in Google Drive for this project.
295
+ export const listDatabases = async (options = {}) => {
296
+ const { environment } = options;
297
+
298
+ checkIfEnvFilesExist();
299
+
300
+ const appName = getAppName();
301
+ const localPath = await resolveDatabasePath(appName);
302
+ const files = find10LatestFiles(localPath, environment || null);
303
+
304
+ if (files.length === 0) {
305
+ const envLabel = environment ? `per '${environment}' ` : '';
306
+ console.log(`šŸ“‹ Nessun dump trovato ${envLabel}in:\n ${localPath}`);
307
+ return;
308
+ }
309
+
310
+ console.log(`\nšŸ“‹ Database dumps — ${appName}:`);
311
+ files.forEach((f, i) => {
312
+ console.log(` ${i + 1}. ${f.display}`);
313
+ });
314
+ console.log('');
315
+ };
@@ -0,0 +1,43 @@
1
+ import { getForgeToken } from './config.js';
2
+
3
+ const BASE_URL = 'https://forge.laravel.com/api/v1';
4
+
5
+ const request = async (method, path, body = null) => {
6
+ const token = getForgeToken();
7
+
8
+ const options = {
9
+ method,
10
+ headers: {
11
+ Authorization: `Bearer ${token}`,
12
+ Accept: 'application/json',
13
+ 'Content-Type': 'application/json',
14
+ },
15
+ };
16
+
17
+ if (body) {
18
+ options.body = JSON.stringify(body);
19
+ }
20
+
21
+ const response = await fetch(`${BASE_URL}${path}`, options);
22
+
23
+ if (response.status === 204) return null;
24
+
25
+ const data = await response.json().catch(() => null);
26
+
27
+ if (!response.ok) {
28
+ const message = data?.message || data?.error || `HTTP ${response.status}`;
29
+ if (response.status === 401) throw new Error(`Token Forge non valido o scaduto. Esegui: laravel-tools forge token set`);
30
+ if (response.status === 403) throw new Error(`Accesso negato: ${message}`);
31
+ if (response.status === 404) throw new Error(`Risorsa non trovata: ${message}`);
32
+ if (response.status === 422) throw new Error(`Dati non validi:\n${JSON.stringify(data, null, 2)}`);
33
+ if (response.status === 429) throw new Error(`Rate limit Forge raggiunto. Riprova tra qualche secondo.`);
34
+ throw new Error(`Errore Forge API (${response.status}): ${message}`);
35
+ }
36
+
37
+ return data;
38
+ };
39
+
40
+ export const get = (path) => request('GET', path);
41
+ export const post = (path, body) => request('POST', path, body);
42
+ export const put = (path, body) => request('PUT', path, body);
43
+ export const del = (path) => request('DELETE', path);
@@ -0,0 +1,33 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ const CONFIG_PATH = join(homedir(), '.laravel-tools');
6
+
7
+ const readConfig = () => {
8
+ if (!existsSync(CONFIG_PATH)) return {};
9
+ try {
10
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
11
+ } catch {
12
+ return {};
13
+ }
14
+ };
15
+
16
+ const writeConfig = (data) => {
17
+ writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8');
18
+ };
19
+
20
+ export const getForgeToken = () => {
21
+ const token = readConfig().forgeToken;
22
+ if (!token) {
23
+ throw new Error(
24
+ 'Forge API token non trovato. Esegui prima: laravel-tools forge token set'
25
+ );
26
+ }
27
+ return token;
28
+ };
29
+
30
+ export const setForgeToken = (token) => {
31
+ const config = readConfig();
32
+ writeConfig({ ...config, forgeToken: token });
33
+ };