@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,287 @@
1
+ import {
2
+ S3Client,
3
+ CreateBucketCommand,
4
+ HeadBucketCommand,
5
+ PutPublicAccessBlockCommand,
6
+ PutObjectCommand,
7
+ PutBucketTaggingCommand,
8
+ ListBucketsCommand,
9
+ GetBucketTaggingCommand,
10
+ PutBucketPolicyCommand,
11
+ PutBucketOwnershipControlsCommand,
12
+ GetBucketPolicyCommand,
13
+ } from '@aws-sdk/client-s3';
14
+ import select from '@inquirer/select';
15
+
16
+ import { getAwsCredentials, getAwsRegion } from './config.js';
17
+ import { getAppName } from '../utilities/command.js';
18
+
19
+ const createS3Client = () =>
20
+ new S3Client({
21
+ region: getAwsRegion(),
22
+ credentials: getAwsCredentials(),
23
+ });
24
+
25
+ const getBucketName = (projectName) => `${projectName}-media`;
26
+
27
+ const bucketExists = async (s3Client, bucketName) => {
28
+ try {
29
+ await s3Client.send(new HeadBucketCommand({ Bucket: bucketName }));
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ };
35
+
36
+ export const doesBucketExist = async (bucketName) => {
37
+ const s3Client = createS3Client();
38
+ return await bucketExists(s3Client, bucketName);
39
+ };
40
+
41
+ export const createBucket = async (projectName, options = {}) => {
42
+ const s3Client = createS3Client();
43
+ const region = getAwsRegion();
44
+ const bucketName = getBucketName(projectName);
45
+ const dryRun = Boolean(options.dryRun);
46
+
47
+ const runMutation = async (message, fn) => {
48
+ if (dryRun) {
49
+ console.log(`🧪 [dry-run] ${message}`);
50
+ return;
51
+ }
52
+ await fn();
53
+ };
54
+
55
+ const ensurePrivateBucketConfig = async () => {
56
+ await runMutation(`Imposto PublicAccessBlock privato su ${bucketName}`, async () => {
57
+ await s3Client.send(
58
+ new PutPublicAccessBlockCommand({
59
+ Bucket: bucketName,
60
+ PublicAccessBlockConfiguration: {
61
+ BlockPublicAcls: true,
62
+ IgnorePublicAcls: true,
63
+ BlockPublicPolicy: true,
64
+ RestrictPublicBuckets: true,
65
+ },
66
+ })
67
+ );
68
+ });
69
+
70
+ await runMutation(`Imposto OwnershipControls BucketOwnerEnforced su ${bucketName}`, async () => {
71
+ await s3Client.send(
72
+ new PutBucketOwnershipControlsCommand({
73
+ Bucket: bucketName,
74
+ OwnershipControls: {
75
+ Rules: [{ ObjectOwnership: 'BucketOwnerEnforced' }],
76
+ },
77
+ })
78
+ );
79
+ });
80
+ };
81
+
82
+ const ensureSiteTag = async () => {
83
+ let existingTags = [];
84
+ try {
85
+ const current = await s3Client.send(new GetBucketTaggingCommand({ Bucket: bucketName }));
86
+ existingTags = current.TagSet || [];
87
+ } catch {
88
+ existingTags = [];
89
+ }
90
+
91
+ const merged = existingTags.filter((tag) => tag.Key !== 'site');
92
+ merged.push({ Key: 'site', Value: projectName });
93
+
94
+ await runMutation(`Aggiorno tag bucket site=${projectName} su ${bucketName}`, async () => {
95
+ await s3Client.send(
96
+ new PutBucketTaggingCommand({
97
+ Bucket: bucketName,
98
+ Tagging: { TagSet: merged },
99
+ })
100
+ );
101
+ });
102
+ };
103
+
104
+ if (await bucketExists(s3Client, bucketName)) {
105
+ await ensurePrivateBucketConfig();
106
+ await ensureSiteTag();
107
+ console.log(`ℹ️ Bucket già esistente: ${bucketName}`);
108
+ return { name: bucketName, created: false };
109
+ }
110
+
111
+ const createParams = {
112
+ Bucket: bucketName,
113
+ ObjectOwnership: 'BucketOwnerEnforced',
114
+ ...(region !== 'us-east-1'
115
+ ? { CreateBucketConfiguration: { LocationConstraint: region } }
116
+ : {}),
117
+ };
118
+
119
+ await runMutation(`Creo bucket ${bucketName}`, async () => {
120
+ await s3Client.send(new CreateBucketCommand(createParams));
121
+ });
122
+ await ensurePrivateBucketConfig();
123
+
124
+ await runMutation(`Creo prefisso iniziale ${projectName}/ su ${bucketName}`, async () => {
125
+ await s3Client.send(
126
+ new PutObjectCommand({
127
+ Bucket: bucketName,
128
+ Key: `${projectName}/`,
129
+ Body: Buffer.alloc(0),
130
+ })
131
+ );
132
+ });
133
+ await ensureSiteTag();
134
+
135
+ if (dryRun) {
136
+ console.log(`🧪 [dry-run] Bucket da creare/configurare: ${bucketName}`);
137
+ return { name: bucketName, created: true, dryRun: true };
138
+ }
139
+
140
+ console.log(`✅ Bucket creato: ${bucketName}`);
141
+ return { name: bucketName, created: true };
142
+ };
143
+
144
+ export const applyCloudFrontReadPolicy = async ({ bucketName, distributionId, accountId, dryRun = false }) => {
145
+ if (!bucketName || !distributionId || !accountId) {
146
+ throw new Error('bucketName, distributionId e accountId sono obbligatori per la bucket policy.');
147
+ }
148
+
149
+ if (dryRun) {
150
+ console.log(
151
+ `🧪 [dry-run] Upsert bucket policy CloudFront-read su ${bucketName} per distribution ${distributionId}`
152
+ );
153
+ return;
154
+ }
155
+
156
+ const s3Client = createS3Client();
157
+ const distributionArn = `arn:aws:cloudfront::${accountId}:distribution/${distributionId}`;
158
+ const sid = `AllowCloudFrontRead_${distributionId}`;
159
+ const desiredStatement = {
160
+ Sid: sid,
161
+ Effect: 'Allow',
162
+ Principal: { Service: 'cloudfront.amazonaws.com' },
163
+ Action: 's3:GetObject',
164
+ Resource: `arn:aws:s3:::${bucketName}/*`,
165
+ Condition: {
166
+ StringEquals: {
167
+ 'AWS:SourceArn': distributionArn,
168
+ },
169
+ },
170
+ };
171
+
172
+ let existingPolicy = {
173
+ Version: '2012-10-17',
174
+ Statement: [],
175
+ };
176
+
177
+ try {
178
+ const current = await s3Client.send(new GetBucketPolicyCommand({ Bucket: bucketName }));
179
+ const parsed = JSON.parse(current.Policy);
180
+ existingPolicy = {
181
+ Version: parsed.Version || '2012-10-17',
182
+ Statement: Array.isArray(parsed.Statement) ? parsed.Statement : [],
183
+ };
184
+ } catch (error) {
185
+ if (error.name !== 'NoSuchBucketPolicy') {
186
+ throw error;
187
+ }
188
+ }
189
+
190
+ // Remove only statements that refer to this exact distribution ARN
191
+ // or have the same deterministic Sid, preserving all unrelated statements.
192
+ const sourceArnKey = 'AWS:SourceArn';
193
+ const statements = existingPolicy.Statement.filter((statement) => {
194
+ if (!statement || typeof statement !== 'object') {
195
+ return true;
196
+ }
197
+
198
+ if (statement.Sid === sid) {
199
+ return false;
200
+ }
201
+
202
+ const sourceArn = statement.Condition?.StringEquals?.[sourceArnKey];
203
+ if (sourceArn === distributionArn) {
204
+ return false;
205
+ }
206
+
207
+ return true;
208
+ });
209
+
210
+ statements.push(desiredStatement);
211
+
212
+ const policy = {
213
+ Version: existingPolicy.Version || '2012-10-17',
214
+ Statement: statements,
215
+ };
216
+
217
+ await s3Client.send(
218
+ new PutBucketPolicyCommand({
219
+ Bucket: bucketName,
220
+ Policy: JSON.stringify(policy),
221
+ })
222
+ );
223
+ };
224
+
225
+ export const getAllBuckets = async (show = true) => {
226
+ const s3Client = createS3Client();
227
+ const data = await s3Client.send(new ListBucketsCommand({}));
228
+
229
+ const bucketDetails = await Promise.all(
230
+ (data.Buckets || []).map(async (bucket) => {
231
+ const bucketName = bucket.Name;
232
+ let tags = [];
233
+
234
+ try {
235
+ const tagData = await s3Client.send(
236
+ new GetBucketTaggingCommand({ Bucket: bucketName })
237
+ );
238
+ tags = tagData.TagSet || [];
239
+ } catch {
240
+ tags = [];
241
+ }
242
+
243
+ return { Name: bucketName, Tags: tags };
244
+ })
245
+ );
246
+
247
+ if (show) {
248
+ if (bucketDetails.length === 0) {
249
+ console.log('Nessun bucket trovato.');
250
+ } else {
251
+ console.table(
252
+ bucketDetails.map((bucket) => ({
253
+ name: bucket.Name,
254
+ siteTag: bucket.Tags.find((tag) => tag.Key === 'site')?.Value || '-',
255
+ }))
256
+ );
257
+ }
258
+ }
259
+
260
+ return bucketDetails;
261
+ };
262
+
263
+ export const getBucketFromTag = async (tag) => {
264
+ const projectTag = tag || getAppName();
265
+ const buckets = await getAllBuckets(false);
266
+ const taggedBuckets = buckets.filter((bucket) =>
267
+ (bucket.Tags || []).some((tagItem) => tagItem.Key === 'site' && tagItem.Value === projectTag)
268
+ );
269
+
270
+ if (taggedBuckets.length === 0) {
271
+ console.log(`ℹ️ Nessun bucket con tag site=${projectTag}`);
272
+ return null;
273
+ }
274
+
275
+ if (taggedBuckets.length === 1) {
276
+ console.log(`✅ Bucket trovato: ${taggedBuckets[0].Name}`);
277
+ return taggedBuckets[0];
278
+ }
279
+
280
+ const selectedBucket = await select({
281
+ message: 'Seleziona il bucket:',
282
+ choices: taggedBuckets.map((bucket) => ({ name: bucket.Name, value: bucket })),
283
+ });
284
+
285
+ console.log(`✅ Bucket selezionato: ${selectedBucket.Name}`);
286
+ return selectedBucket;
287
+ };
@@ -0,0 +1,433 @@
1
+ import {
2
+ CloudFrontClient,
3
+ CreateDistributionWithTagsCommand,
4
+ CreateOriginAccessControlCommand,
5
+ GetDistributionConfigCommand,
6
+ ListDistributionsCommand,
7
+ ListOriginAccessControlsCommand,
8
+ ListTagsForResourceCommand,
9
+ UpdateDistributionCommand,
10
+ } from '@aws-sdk/client-cloudfront';
11
+
12
+ import { getAwsAccountId, getAwsCredentials, getAwsRegion, getCloudFrontRegion } from './config.js';
13
+ import { getAppName } from '../utilities/command.js';
14
+ import { applyCloudFrontReadPolicy, doesBucketExist } from './bucket.js';
15
+
16
+ const createCloudFrontClient = () =>
17
+ new CloudFrontClient({
18
+ region: getCloudFrontRegion(),
19
+ credentials: getAwsCredentials(),
20
+ });
21
+
22
+ const CACHING_OPTIMIZED_POLICY_ID = '658327ea-f89d-4fab-a63d-7e88639e58f6';
23
+
24
+ const getBucketOriginDomainName = (bucketName) => {
25
+ const region = getAwsRegion();
26
+ return region === 'us-east-1'
27
+ ? `${bucketName}.s3.amazonaws.com`
28
+ : `${bucketName}.s3.${region}.amazonaws.com`;
29
+ };
30
+
31
+ const getBucketOriginDomainCandidates = (bucketName) => {
32
+ const regionSpecific = getBucketOriginDomainName(bucketName);
33
+ const global = `${bucketName}.s3.amazonaws.com`;
34
+ return new Set([regionSpecific, global]);
35
+ };
36
+
37
+ const distributionToRow = async (client, distribution) => {
38
+ let siteTag = '-';
39
+ let tagLookupFailed = false;
40
+ try {
41
+ const tagsResponse = await client.send(
42
+ new ListTagsForResourceCommand({ Resource: distribution.ARN })
43
+ );
44
+ const tags = tagsResponse.Tags?.Items || [];
45
+ siteTag = tags.find((tag) => tag.Key === 'site')?.Value || '-';
46
+ } catch {
47
+ siteTag = '-';
48
+ tagLookupFailed = true;
49
+ }
50
+
51
+ return {
52
+ id: distribution.Id,
53
+ arn: distribution.ARN,
54
+ domainName: distribution.DomainName,
55
+ status: distribution.Status,
56
+ comment: distribution.Comment || '',
57
+ siteTag,
58
+ tagLookupFailed,
59
+ originDomainNames: (distribution.Origins?.Items || []).map((origin) => origin.DomainName).filter(Boolean),
60
+ };
61
+ };
62
+
63
+ export const getAllDistributions = async (show = true) => {
64
+ const client = createCloudFrontClient();
65
+
66
+ let marker;
67
+ let all = [];
68
+ do {
69
+ const data = await client.send(
70
+ new ListDistributionsCommand({
71
+ Marker: marker,
72
+ MaxItems: '100',
73
+ })
74
+ );
75
+
76
+ const items = data.DistributionList?.Items || [];
77
+ all = all.concat(items);
78
+ marker = data.DistributionList?.NextMarker;
79
+ } while (marker);
80
+
81
+ const rows = await Promise.all(all.map((distribution) => distributionToRow(client, distribution)));
82
+
83
+ if (show) {
84
+ if (rows.length === 0) {
85
+ console.log('Nessuna distribuzione CloudFront trovata.');
86
+ } else {
87
+ console.table(rows);
88
+ }
89
+ }
90
+
91
+ return rows;
92
+ };
93
+
94
+ export const getDistributionsByProjectTag = async (projectTag) => {
95
+ const tag = projectTag || getAppName();
96
+ const distributions = await getAllDistributions(false);
97
+ const exactTagMatches = distributions.filter((distribution) => distribution.siteTag === tag);
98
+ if (exactTagMatches.length > 0) {
99
+ return exactTagMatches;
100
+ }
101
+
102
+ // Fallback for accounts where tag listing may be temporarily unavailable:
103
+ // use deterministic comment pattern set by this tool.
104
+ const commentMatches = distributions.filter(
105
+ (distribution) => distribution.comment === `CDN for ${tag}`
106
+ );
107
+ if (commentMatches.length > 0) {
108
+ console.log(
109
+ `⚠️ Tag lookup unavailable/incomplete for some distributions. Using comment fallback for site=${tag}.`
110
+ );
111
+ return commentMatches;
112
+ }
113
+
114
+ const hasTagLookupFailures = distributions.some((distribution) => distribution.tagLookupFailed);
115
+ if (hasTagLookupFailures) {
116
+ throw new Error(
117
+ `Impossibile verificare in modo affidabile le distribuzioni CloudFront per site=${tag} (tag lookup fallito). ` +
118
+ "Per sicurezza blocco la creazione automatica per evitare duplicati. " +
119
+ "Verifica i permessi cloudfront:ListTagsForResource e rilancia."
120
+ );
121
+ }
122
+
123
+ return [];
124
+ };
125
+
126
+ export const getDistributionByProjectTagStrict = async (projectTag) => {
127
+ const tag = projectTag || getAppName();
128
+ const matches = await getDistributionsByProjectTag(tag);
129
+
130
+ if (matches.length === 0) {
131
+ return null;
132
+ }
133
+
134
+ if (matches.length > 1) {
135
+ console.table(matches);
136
+ throw new Error(
137
+ `Trovate ${matches.length} distribuzioni CloudFront con tag site=${tag}. Risolvi il conflitto manualmente.`
138
+ );
139
+ }
140
+
141
+ return matches[0];
142
+ };
143
+
144
+ const getDistributionForProjectStrict = async ({ projectName, bucketName }) => {
145
+ const expectedComment = `CDN for ${projectName}`;
146
+ const expectedOrigins = getBucketOriginDomainCandidates(bucketName);
147
+ const distributions = await getAllDistributions(false);
148
+
149
+ const matches = distributions.filter((distribution) => {
150
+ const commentMatch = distribution.comment === expectedComment;
151
+ const tagMatch = distribution.siteTag === projectName;
152
+ const originMatch = (distribution.originDomainNames || []).some((domain) =>
153
+ expectedOrigins.has(domain)
154
+ );
155
+
156
+ // Prefer deterministic linkage to the project bucket and metadata.
157
+ return originMatch && (commentMatch || tagMatch);
158
+ });
159
+
160
+ if (matches.length === 0) {
161
+ return null;
162
+ }
163
+
164
+ if (matches.length > 1) {
165
+ console.table(matches);
166
+ throw new Error(
167
+ `Trovate ${matches.length} distribuzioni CloudFront candidate per ${projectName}. ` +
168
+ 'Risolvi il conflitto manualmente prima di rilanciare.'
169
+ );
170
+ }
171
+
172
+ return matches[0];
173
+ };
174
+
175
+ const getOriginAccessControlByName = async (client, name) => {
176
+ let marker;
177
+ const allItems = [];
178
+
179
+ do {
180
+ const data = await client.send(
181
+ new ListOriginAccessControlsCommand({
182
+ Marker: marker,
183
+ MaxItems: '100',
184
+ })
185
+ );
186
+ const items = data.OriginAccessControlList?.Items || [];
187
+ allItems.push(...items);
188
+ marker = data.OriginAccessControlList?.NextMarker;
189
+ } while (marker);
190
+
191
+ const summary = allItems.find((item) => item?.Name === name);
192
+ if (!summary) {
193
+ return null;
194
+ }
195
+
196
+ // Normalize list summary shape to the object shape used by create response.
197
+ return {
198
+ Id: summary.Id,
199
+ Name: summary.Name,
200
+ Description: summary.Description,
201
+ SigningProtocol: summary.SigningProtocol,
202
+ SigningBehavior: summary.SigningBehavior,
203
+ OriginAccessControlOriginType: summary.OriginAccessControlOriginType,
204
+ };
205
+ };
206
+
207
+ const ensureOriginAccessControl = async (client, projectName, options = {}) => {
208
+ const dryRun = Boolean(options.dryRun);
209
+ const name = `${projectName}-media-oac`;
210
+ const existing = await getOriginAccessControlByName(client, name);
211
+ if (existing) {
212
+ return existing;
213
+ }
214
+
215
+ if (dryRun) {
216
+ console.log(`🧪 [dry-run] Creo Origin Access Control ${name}`);
217
+ return { Id: `dryrun-oac-${projectName}`, Name: name };
218
+ }
219
+
220
+ const created = await client.send(
221
+ new CreateOriginAccessControlCommand({
222
+ OriginAccessControlConfig: {
223
+ Name: name,
224
+ Description: `OAC for ${projectName} media`,
225
+ OriginAccessControlOriginType: 's3',
226
+ SigningBehavior: 'always',
227
+ SigningProtocol: 'sigv4',
228
+ },
229
+ })
230
+ );
231
+
232
+ return created.OriginAccessControl;
233
+ };
234
+
235
+ export const ensureDistributionUsesOAC = async ({ distributionId, projectName, bucketName, dryRun = false }) => {
236
+ const client = createCloudFrontClient();
237
+ const bucketOriginDomainName = getBucketOriginDomainName(bucketName);
238
+ const oac = await ensureOriginAccessControl(client, projectName, { dryRun });
239
+
240
+ const configResponse = await client.send(
241
+ new GetDistributionConfigCommand({
242
+ Id: distributionId,
243
+ })
244
+ );
245
+
246
+ const config = configResponse.DistributionConfig;
247
+ const etag = configResponse.ETag;
248
+ const origins = config.Origins?.Items || [];
249
+ const targetOrigin = origins.find((origin) => origin.DomainName === bucketOriginDomainName);
250
+
251
+ if (!targetOrigin) {
252
+ throw new Error(
253
+ `La distribuzione ${distributionId} non punta al bucket previsto (${bucketOriginDomainName}).`
254
+ );
255
+ }
256
+
257
+ const alreadyConfigured =
258
+ targetOrigin.OriginAccessControlId === oac.Id &&
259
+ (targetOrigin.S3OriginConfig?.OriginAccessIdentity || '') === '';
260
+
261
+ if (alreadyConfigured) {
262
+ return;
263
+ }
264
+
265
+ targetOrigin.OriginAccessControlId = oac.Id;
266
+ targetOrigin.S3OriginConfig = { OriginAccessIdentity: '' };
267
+
268
+ if (dryRun) {
269
+ console.log(`🧪 [dry-run] Aggiorno distribuzione ${distributionId} per usare OAC ${oac.Id}`);
270
+ return;
271
+ }
272
+
273
+ await client.send(
274
+ new UpdateDistributionCommand({
275
+ Id: distributionId,
276
+ IfMatch: etag,
277
+ DistributionConfig: config,
278
+ })
279
+ );
280
+ };
281
+
282
+ const createDistributionInternal = async ({ projectName, bucketName, dryRun = false }) => {
283
+ const client = createCloudFrontClient();
284
+ const originDomainName = getBucketOriginDomainName(bucketName);
285
+ const oac = await ensureOriginAccessControl(client, projectName, { dryRun });
286
+
287
+ const input = {
288
+ DistributionConfigWithTags: {
289
+ DistributionConfig: {
290
+ CallerReference: `${Date.now()}`,
291
+ Comment: `CDN for ${projectName}`,
292
+ Enabled: true,
293
+ HttpVersion: 'http2and3',
294
+ IsIPV6Enabled: true,
295
+ PriceClass: 'PriceClass_100',
296
+ Origins: {
297
+ Quantity: 1,
298
+ Items: [
299
+ {
300
+ Id: originDomainName,
301
+ DomainName: originDomainName,
302
+ OriginPath: '',
303
+ OriginAccessControlId: oac.Id,
304
+ S3OriginConfig: { OriginAccessIdentity: '' },
305
+ },
306
+ ],
307
+ },
308
+ DefaultCacheBehavior: {
309
+ TargetOriginId: originDomainName,
310
+ ViewerProtocolPolicy: 'redirect-to-https',
311
+ AllowedMethods: {
312
+ Quantity: 2,
313
+ Items: ['GET', 'HEAD'],
314
+ CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] },
315
+ },
316
+ CachePolicyId: CACHING_OPTIMIZED_POLICY_ID,
317
+ Compress: true,
318
+ TrustedSigners: { Enabled: false, Quantity: 0 },
319
+ TrustedKeyGroups: { Enabled: false, Quantity: 0 },
320
+ },
321
+ Restrictions: {
322
+ GeoRestriction: {
323
+ RestrictionType: 'none',
324
+ Quantity: 0,
325
+ },
326
+ },
327
+ ViewerCertificate: {
328
+ CloudFrontDefaultCertificate: true,
329
+ MinimumProtocolVersion: 'TLSv1.2_2021',
330
+ },
331
+ },
332
+ Tags: {
333
+ Items: [{ Key: 'site', Value: projectName }],
334
+ },
335
+ },
336
+ };
337
+
338
+ if (dryRun) {
339
+ console.log(`🧪 [dry-run] Creo distribuzione CloudFront tagged site=${projectName}`);
340
+ return {
341
+ id: `DRYRUN-${projectName}`,
342
+ arn: `arn:aws:cloudfront::dryrun:distribution/DRYRUN-${projectName}`,
343
+ domainName: `${projectName}.dry-run.cloudfront.net`,
344
+ status: 'DRY_RUN',
345
+ comment: `CDN for ${projectName}`,
346
+ siteTag: projectName,
347
+ };
348
+ }
349
+
350
+ const response = await client.send(new CreateDistributionWithTagsCommand(input));
351
+ const distribution = response.Distribution;
352
+ return {
353
+ id: distribution.Id,
354
+ arn: distribution.ARN,
355
+ domainName: distribution.DomainName,
356
+ status: distribution.Status,
357
+ comment: distribution.Comment || '',
358
+ siteTag: projectName,
359
+ };
360
+ };
361
+
362
+ export const resolveOrCreateDistributionForProject = async ({ projectName, bucketName, dryRun = false }) => {
363
+ if (dryRun) {
364
+ return await createDistributionInternal({ projectName, bucketName, dryRun: true });
365
+ }
366
+
367
+ const existing = await getDistributionForProjectStrict({ projectName, bucketName });
368
+ if (existing) {
369
+ await ensureDistributionUsesOAC({
370
+ distributionId: existing.id,
371
+ projectName,
372
+ bucketName,
373
+ dryRun,
374
+ });
375
+ return existing;
376
+ }
377
+
378
+ return await createDistributionInternal({ projectName, bucketName, dryRun });
379
+ };
380
+
381
+ export const createDistribution = async (projectName, bucketName, options = {}) => {
382
+ const resolvedProjectName = projectName || getAppName();
383
+ const resolvedBucketName = bucketName || `${resolvedProjectName}-media`;
384
+ const dryRun = Boolean(options.dryRun);
385
+ const bucketExists = await doesBucketExist(resolvedBucketName);
386
+ if (!bucketExists) {
387
+ if (dryRun) {
388
+ console.log(
389
+ `🧪 [dry-run] Bucket ${resolvedBucketName} non trovato: in run reale \`media cloudfront setup\` fallirebbe.`
390
+ );
391
+ return {
392
+ id: `DRYRUN-${resolvedProjectName}`,
393
+ arn: `arn:aws:cloudfront::dryrun:distribution/DRYRUN-${resolvedProjectName}`,
394
+ domainName: `${resolvedProjectName}.dry-run.cloudfront.net`,
395
+ status: 'DRY_RUN_DEPENDENCY_MISSING',
396
+ comment: `CDN for ${resolvedProjectName}`,
397
+ siteTag: resolvedProjectName,
398
+ };
399
+ }
400
+ throw new Error(
401
+ `Bucket ${resolvedBucketName} non trovato. Crea prima il bucket con \`media setup-general\` o \`media s3\`.`
402
+ );
403
+ }
404
+
405
+ const result = await resolveOrCreateDistributionForProject({
406
+ projectName: resolvedProjectName,
407
+ bucketName: resolvedBucketName,
408
+ dryRun,
409
+ });
410
+
411
+ const accountId = await getAwsAccountId();
412
+ await applyCloudFrontReadPolicy({
413
+ bucketName: resolvedBucketName,
414
+ distributionId: result.id,
415
+ accountId,
416
+ dryRun,
417
+ });
418
+
419
+ console.table([result]);
420
+ return result;
421
+ };
422
+
423
+ // Compat layer for existing CLI entrypoint.
424
+ export const getDistributionByTagOrName = async (tagOrName) => {
425
+ const tag = tagOrName || getAppName();
426
+ const result = await getDistributionByProjectTagStrict(tag);
427
+ if (!result) {
428
+ console.log(`ℹ️ Nessuna distribuzione trovata con tag site=${tag}`);
429
+ return null;
430
+ }
431
+ console.table([result]);
432
+ return result;
433
+ };