@gannochenko/staticstripes 0.0.11 → 0.0.14

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 (128) hide show
  1. package/Makefile +37 -4
  2. package/dist/asset-manager.d.ts +1 -0
  3. package/dist/asset-manager.d.ts.map +1 -1
  4. package/dist/asset-manager.js +3 -0
  5. package/dist/asset-manager.js.map +1 -1
  6. package/dist/cli/ai-generation-strategy-factory.d.ts +23 -0
  7. package/dist/cli/ai-generation-strategy-factory.d.ts.map +1 -0
  8. package/dist/cli/ai-generation-strategy-factory.js +44 -0
  9. package/dist/cli/ai-generation-strategy-factory.js.map +1 -0
  10. package/dist/cli/ai-generation-strategy.d.ts +33 -0
  11. package/dist/cli/ai-generation-strategy.d.ts.map +1 -0
  12. package/dist/cli/ai-generation-strategy.js +3 -0
  13. package/dist/cli/ai-generation-strategy.js.map +1 -0
  14. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.d.ts +38 -0
  15. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.d.ts.map +1 -0
  16. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.js +174 -0
  17. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.js.map +1 -0
  18. package/dist/cli/auth-strategy-factory.d.ts +31 -0
  19. package/dist/cli/auth-strategy-factory.d.ts.map +1 -0
  20. package/dist/cli/auth-strategy-factory.js +61 -0
  21. package/dist/cli/auth-strategy-factory.js.map +1 -0
  22. package/dist/cli/auth-strategy.d.ts +31 -0
  23. package/dist/cli/auth-strategy.d.ts.map +1 -0
  24. package/dist/cli/auth-strategy.js +3 -0
  25. package/dist/cli/auth-strategy.js.map +1 -0
  26. package/dist/cli/commands/add-assets.d.ts +3 -0
  27. package/dist/cli/commands/add-assets.d.ts.map +1 -0
  28. package/dist/cli/commands/add-assets.js +113 -0
  29. package/dist/cli/commands/add-assets.js.map +1 -0
  30. package/dist/cli/commands/auth.d.ts +6 -0
  31. package/dist/cli/commands/auth.d.ts.map +1 -0
  32. package/dist/cli/commands/auth.js +103 -0
  33. package/dist/cli/commands/auth.js.map +1 -0
  34. package/dist/cli/commands/bootstrap.d.ts +3 -0
  35. package/dist/cli/commands/bootstrap.d.ts.map +1 -0
  36. package/dist/cli/commands/bootstrap.js +49 -0
  37. package/dist/cli/commands/bootstrap.js.map +1 -0
  38. package/dist/cli/commands/generate.d.ts +3 -0
  39. package/dist/cli/commands/generate.d.ts.map +1 -0
  40. package/dist/cli/commands/generate.js +199 -0
  41. package/dist/cli/commands/generate.js.map +1 -0
  42. package/dist/cli/commands/upload.d.ts +6 -0
  43. package/dist/cli/commands/upload.d.ts.map +1 -0
  44. package/dist/cli/commands/upload.js +67 -0
  45. package/dist/cli/commands/upload.js.map +1 -0
  46. package/dist/cli/instagram/instagram-auth-strategy.d.ts +31 -0
  47. package/dist/cli/instagram/instagram-auth-strategy.d.ts.map +1 -0
  48. package/dist/cli/instagram/instagram-auth-strategy.js +505 -0
  49. package/dist/cli/instagram/instagram-auth-strategy.js.map +1 -0
  50. package/dist/cli/instagram/instagram-upload-strategy.d.ts +45 -0
  51. package/dist/cli/instagram/instagram-upload-strategy.d.ts.map +1 -0
  52. package/dist/cli/instagram/instagram-upload-strategy.js +303 -0
  53. package/dist/cli/instagram/instagram-upload-strategy.js.map +1 -0
  54. package/dist/cli/s3/s3-upload-strategy.d.ts +18 -0
  55. package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -0
  56. package/dist/cli/s3/s3-upload-strategy.js +153 -0
  57. package/dist/cli/s3/s3-upload-strategy.js.map +1 -0
  58. package/dist/cli/upload-strategy-factory.d.ts +23 -0
  59. package/dist/cli/upload-strategy-factory.d.ts.map +1 -0
  60. package/dist/cli/upload-strategy-factory.js +49 -0
  61. package/dist/cli/upload-strategy-factory.js.map +1 -0
  62. package/dist/cli/upload-strategy.d.ts +25 -0
  63. package/dist/cli/upload-strategy.d.ts.map +1 -0
  64. package/dist/cli/upload-strategy.js +3 -0
  65. package/dist/cli/upload-strategy.js.map +1 -0
  66. package/dist/cli/youtube/youtube-auth-strategy.d.ts +11 -0
  67. package/dist/cli/youtube/youtube-auth-strategy.d.ts.map +1 -0
  68. package/dist/cli/youtube/youtube-auth-strategy.js +320 -0
  69. package/dist/cli/youtube/youtube-auth-strategy.js.map +1 -0
  70. package/dist/cli/youtube/youtube-upload-strategy.d.ts +22 -0
  71. package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -0
  72. package/dist/cli/youtube/youtube-upload-strategy.js +117 -0
  73. package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -0
  74. package/dist/cli.js +11 -281
  75. package/dist/cli.js.map +1 -1
  76. package/dist/html-parser.d.ts +3 -4
  77. package/dist/html-parser.d.ts.map +1 -1
  78. package/dist/html-parser.js +20 -17
  79. package/dist/html-parser.js.map +1 -1
  80. package/dist/html-project-parser.d.ts +64 -1
  81. package/dist/html-project-parser.d.ts.map +1 -1
  82. package/dist/html-project-parser.js +695 -57
  83. package/dist/html-project-parser.js.map +1 -1
  84. package/dist/lib/file.d.ts +2 -0
  85. package/dist/lib/file.d.ts.map +1 -0
  86. package/dist/lib/file.js +13 -0
  87. package/dist/lib/file.js.map +1 -0
  88. package/dist/lib/net.d.ts +19 -0
  89. package/dist/lib/net.d.ts.map +1 -0
  90. package/dist/lib/net.js +101 -0
  91. package/dist/lib/net.js.map +1 -0
  92. package/dist/project.d.ts +18 -2
  93. package/dist/project.d.ts.map +1 -1
  94. package/dist/project.js +65 -1
  95. package/dist/project.js.map +1 -1
  96. package/dist/type.d.ts +43 -4
  97. package/dist/type.d.ts.map +1 -1
  98. package/dist/youtube-uploader.d.ts +40 -0
  99. package/dist/youtube-uploader.d.ts.map +1 -0
  100. package/dist/youtube-uploader.js +227 -0
  101. package/dist/youtube-uploader.js.map +1 -0
  102. package/package.json +6 -2
  103. package/src/asset-manager.ts +4 -0
  104. package/src/cli/ai-generation-strategy-factory.ts +48 -0
  105. package/src/cli/ai-generation-strategy.ts +35 -0
  106. package/src/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.ts +266 -0
  107. package/src/cli/auth-strategy-factory.ts +67 -0
  108. package/src/cli/auth-strategy.ts +37 -0
  109. package/src/cli/commands/add-assets.ts +159 -0
  110. package/src/cli/commands/auth.ts +120 -0
  111. package/src/cli/commands/bootstrap.ts +57 -0
  112. package/src/cli/commands/generate.ts +242 -0
  113. package/src/cli/commands/upload.ts +83 -0
  114. package/src/cli/instagram/instagram-auth-strategy.ts +569 -0
  115. package/src/cli/instagram/instagram-upload-strategy.ts +398 -0
  116. package/src/cli/s3/s3-upload-strategy.ts +198 -0
  117. package/src/cli/upload-strategy-factory.ts +55 -0
  118. package/src/cli/upload-strategy.ts +31 -0
  119. package/src/cli/youtube/youtube-auth-strategy.ts +323 -0
  120. package/src/cli/youtube/youtube-upload-strategy.ts +174 -0
  121. package/src/cli.ts +13 -391
  122. package/src/html-parser.ts +23 -21
  123. package/src/html-project-parser.ts +821 -62
  124. package/src/lib/file.ts +11 -0
  125. package/src/lib/net.ts +120 -0
  126. package/src/project.ts +81 -1
  127. package/src/type.ts +49 -4
  128. package/src/youtube-uploader.ts +288 -0
@@ -0,0 +1,398 @@
1
+ import { UploadStrategy } from '../upload-strategy';
2
+ import { Project } from '../../project';
3
+ import { Upload } from '../../type';
4
+ import { readFileSync, existsSync } from 'fs';
5
+ import { resolve } from 'path';
6
+ import ejs from 'ejs';
7
+ import { makeRequest } from '../../lib/net';
8
+
9
+ /**
10
+ * Instagram credentials format stored in .auth/<upload-name>.json
11
+ */
12
+ interface InstagramCredentials {
13
+ accessToken: string; // Long-lived Instagram Graph API access token
14
+ igUserId: string; // Instagram User ID (not the username)
15
+ }
16
+
17
+ /**
18
+ * Instagram upload strategy implementation
19
+ * Uses Instagram Graph API to post Reels/Videos
20
+ */
21
+ export class InstagramUploadStrategy implements UploadStrategy {
22
+ private readonly API_VERSION = 'v21.0';
23
+ private readonly GRAPH_API_BASE = 'https://graph.instagram.com';
24
+
25
+ constructor() {}
26
+
27
+ getTag(): string {
28
+ return 'instagram';
29
+ }
30
+
31
+ validate(): void {
32
+ // Validation happens in execute() since we need upload config
33
+ }
34
+
35
+ async execute(
36
+ project: Project,
37
+ upload: Upload,
38
+ projectPath: string,
39
+ ): Promise<void> {
40
+ // Validate Instagram configuration exists
41
+ if (!upload.instagram) {
42
+ throw new Error(
43
+ `āŒ Error: Instagram configuration missing for upload "${upload.name}"`,
44
+ );
45
+ }
46
+
47
+ const { caption, shareToFeed, thumbOffset, coverUrl, videoUrl } =
48
+ upload.instagram;
49
+
50
+ // Load credentials from .auth/<upload-name>.json
51
+ const authDir = resolve(projectPath, '.auth');
52
+ const credentialsPath = resolve(authDir, `${upload.name}.json`);
53
+
54
+ if (!existsSync(credentialsPath)) {
55
+ throw new Error(
56
+ `āŒ Error: Instagram credentials not found\n\n` +
57
+ `Expected location: ${credentialsPath}\n\n` +
58
+ `šŸ’” Run authentication wizard:\n` +
59
+ ` staticstripes auth --upload-name ${upload.name}\n\n` +
60
+ `šŸ“– Or view detailed setup instructions:\n` +
61
+ ` staticstripes auth-help instagram\n`,
62
+ );
63
+ }
64
+
65
+ console.log(`šŸ” Loading credentials from: ${credentialsPath}`);
66
+
67
+ let credentials: InstagramCredentials;
68
+ try {
69
+ const credentialsJson = readFileSync(credentialsPath, 'utf-8');
70
+ credentials = JSON.parse(credentialsJson);
71
+
72
+ if (!credentials.accessToken || !credentials.igUserId) {
73
+ throw new Error('Missing accessToken or igUserId');
74
+ }
75
+ } catch (error) {
76
+ throw new Error(
77
+ `āŒ Error: Failed to parse Instagram credentials from ${credentialsPath}\n` +
78
+ `Ensure the file contains valid JSON with accessToken and igUserId.\n` +
79
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
80
+ );
81
+ }
82
+
83
+ // Determine video URL
84
+ let publicVideoUrl: string;
85
+
86
+ if (videoUrl) {
87
+ // Use explicitly provided URL
88
+ publicVideoUrl = videoUrl;
89
+ console.log(`\nšŸ“¹ Using provided video URL: ${publicVideoUrl}`);
90
+ } else {
91
+ // Try to infer from S3 upload if available
92
+ const s3Upload = this.findS3Upload(project, upload);
93
+ if (s3Upload && s3Upload.s3) {
94
+ publicVideoUrl = this.constructS3Url(project, s3Upload);
95
+ console.log(
96
+ `\nšŸ“¹ Using S3 URL from upload "${s3Upload.name}": ${publicVideoUrl}`,
97
+ );
98
+ } else {
99
+ throw new Error(
100
+ `āŒ Error: No video URL specified for Instagram upload "${upload.name}"\n\n` +
101
+ `Either:\n` +
102
+ `1. Add <video-url value="https://..." /> to your Instagram config, or\n` +
103
+ `2. Configure an S3 upload with the same output name to auto-generate the URL`,
104
+ );
105
+ }
106
+ }
107
+
108
+ // Determine title (use upload-specific title or fall back to project title)
109
+ const title = upload.title || project.getTitle();
110
+
111
+ // Format tags with # and space-separated (Instagram style)
112
+ const formattedTags = upload.tags.map((tag) => `#${tag}`).join(' ');
113
+
114
+ // Convert ${variable} syntax to <%= variable %> for EJS compatibility
115
+ const ejsCaption = caption.replace(/\$\{(\w+)\}/g, '<%= $1 %>');
116
+
117
+ const processedCaption = ejs.render(ejsCaption, {
118
+ title,
119
+ tags: formattedTags,
120
+ });
121
+
122
+ console.log(`\nšŸ“ø Preparing Instagram Reel upload...`);
123
+ console.log(` Title: ${title}`);
124
+ console.log(` Tags: ${formattedTags}`);
125
+ console.log(` Caption: ${processedCaption.substring(0, 50)}${processedCaption.length > 50 ? '...' : ''}`);
126
+ console.log(` Share to Feed: ${shareToFeed ? 'Yes' : 'No'}`);
127
+ if (thumbOffset) {
128
+ console.log(` Thumbnail offset: ${thumbOffset}ms`);
129
+ }
130
+ console.log('');
131
+
132
+ // Step 1: Create media container
133
+ console.log('šŸ“¦ Step 1: Creating media container...');
134
+ const containerId = await this.createMediaContainer(
135
+ credentials,
136
+ publicVideoUrl,
137
+ processedCaption,
138
+ shareToFeed,
139
+ thumbOffset,
140
+ coverUrl,
141
+ );
142
+
143
+ console.log(`āœ… Container created: ${containerId}`);
144
+
145
+ // Step 2: Wait for container to be ready
146
+ console.log('\nā³ Step 2: Waiting for Instagram to process video...');
147
+ await this.waitForContainerReady(credentials, containerId);
148
+
149
+ // Step 3: Publish the Reel
150
+ console.log('\nšŸ“¤ Step 3: Publishing Reel...');
151
+ const mediaId = await this.publishMedia(credentials, containerId);
152
+
153
+ // Step 4: Get permalink
154
+ console.log('\nšŸ”— Getting permalink...');
155
+ const permalink = await this.getPermalink(credentials, mediaId);
156
+
157
+ console.log(`\nāœ… Reel published successfully!`);
158
+ console.log(`šŸ”— Media ID: ${mediaId}`);
159
+ console.log(`šŸ“ŗ View at: ${permalink}\n`);
160
+ }
161
+
162
+ /**
163
+ * Creates a media container for the Reel
164
+ */
165
+ private async createMediaContainer(
166
+ credentials: InstagramCredentials,
167
+ videoUrl: string,
168
+ caption: string,
169
+ shareToFeed: boolean,
170
+ thumbOffset?: number,
171
+ coverUrl?: string,
172
+ ): Promise<string> {
173
+ const params = new URLSearchParams({
174
+ media_type: 'REELS',
175
+ video_url: videoUrl,
176
+ caption: caption,
177
+ access_token: credentials.accessToken,
178
+ });
179
+
180
+ if (shareToFeed) {
181
+ params.append('share_to_feed', 'true');
182
+ }
183
+
184
+ if (thumbOffset !== undefined) {
185
+ params.append('thumb_offset', thumbOffset.toString());
186
+ }
187
+
188
+ if (coverUrl) {
189
+ params.append('cover_url', coverUrl);
190
+ }
191
+
192
+ const url = `${this.GRAPH_API_BASE}/${this.API_VERSION}/${credentials.igUserId}/media`;
193
+
194
+ try {
195
+ const data = await makeRequest<{ id?: string }>({
196
+ url,
197
+ method: 'POST',
198
+ body: params,
199
+ });
200
+
201
+ if (!data.id) {
202
+ throw new Error('No container ID returned from API');
203
+ }
204
+
205
+ return data.id;
206
+ } catch (error) {
207
+ throw new Error(
208
+ `āŒ Error: Failed to create media container\n` +
209
+ `${error instanceof Error ? error.message : String(error)}\n\n` +
210
+ `Common issues:\n` +
211
+ `- Video URL must be publicly accessible\n` +
212
+ `- Access token may be expired (refresh it)\n` +
213
+ `- Video must be MP4 format and meet Instagram requirements\n` +
214
+ `- Instagram User ID must be correct`,
215
+ );
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Waits for the container to be ready for publishing
221
+ * Polls the status endpoint until status_code is FINISHED
222
+ */
223
+ private async waitForContainerReady(
224
+ credentials: InstagramCredentials,
225
+ containerId: string,
226
+ ): Promise<void> {
227
+ const maxAttempts = 60; // Maximum 60 attempts (5 minutes)
228
+ const delayMs = 5000; // 5 seconds between checks
229
+
230
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
231
+ const params = new URLSearchParams({
232
+ fields: 'status_code',
233
+ access_token: credentials.accessToken,
234
+ });
235
+
236
+ const url = `${this.GRAPH_API_BASE}/${this.API_VERSION}/${containerId}?${params.toString()}`;
237
+
238
+ try {
239
+ const data = await makeRequest<{ status_code?: string }>({
240
+ url,
241
+ method: 'GET',
242
+ });
243
+
244
+ if (data.status_code === 'FINISHED') {
245
+ console.log(`āœ… Video processed and ready!`);
246
+ return;
247
+ } else if (data.status_code === 'ERROR') {
248
+ throw new Error('Instagram failed to process the video');
249
+ } else if (data.status_code === 'IN_PROGRESS') {
250
+ console.log(` Processing... (${attempt}/${maxAttempts})`);
251
+ } else {
252
+ console.log(
253
+ ` Status: ${data.status_code || 'UNKNOWN'} (${attempt}/${maxAttempts})`,
254
+ );
255
+ }
256
+ } catch (error) {
257
+ console.log(` Warning: Status check failed, continuing...`);
258
+ }
259
+
260
+ // Wait before next check
261
+ if (attempt < maxAttempts) {
262
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
263
+ }
264
+ }
265
+
266
+ // If we get here, we timed out, but let's try to publish anyway
267
+ console.log(
268
+ `āš ļø Timeout waiting for status. Attempting to publish anyway...`,
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Publishes the media container
274
+ */
275
+ private async publishMedia(
276
+ credentials: InstagramCredentials,
277
+ containerId: string,
278
+ ): Promise<string> {
279
+ const params = new URLSearchParams({
280
+ creation_id: containerId,
281
+ access_token: credentials.accessToken,
282
+ });
283
+
284
+ const url = `${this.GRAPH_API_BASE}/${this.API_VERSION}/${credentials.igUserId}/media_publish`;
285
+
286
+ try {
287
+ const data = await makeRequest<{ id?: string }>({
288
+ url,
289
+ method: 'POST',
290
+ body: params,
291
+ });
292
+
293
+ if (!data.id) {
294
+ throw new Error('No media ID returned from API');
295
+ }
296
+
297
+ return data.id;
298
+ } catch (error) {
299
+ throw new Error(
300
+ `āŒ Error: Failed to publish media\n` +
301
+ `${error instanceof Error ? error.message : String(error)}\n\n` +
302
+ `The container may still be processing. Wait a few seconds and try again.`,
303
+ );
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Finds a corresponding S3 upload for the same output
309
+ */
310
+ private findS3Upload(project: Project, upload: Upload): Upload | undefined {
311
+ const uploads = project.getUploads();
312
+ for (const [name, u] of uploads.entries()) {
313
+ if (
314
+ u.tag === 's3' &&
315
+ u.outputName === upload.outputName &&
316
+ name !== upload.name
317
+ ) {
318
+ return u;
319
+ }
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ /**
325
+ * Constructs the public S3 URL for a video
326
+ */
327
+ private constructS3Url(project: Project, s3Upload: Upload): string {
328
+ if (!s3Upload.s3) {
329
+ throw new Error('S3 configuration missing');
330
+ }
331
+
332
+ const { endpoint, region, bucket, path } = s3Upload.s3;
333
+ const output = project.getOutput(s3Upload.outputName);
334
+ if (!output) {
335
+ throw new Error(`Output "${s3Upload.outputName}" not found`);
336
+ }
337
+
338
+ // Interpolate path variables
339
+ const slug = this.slugify(project.getTitle());
340
+ const outputName = output.name;
341
+ const interpolatedPath = path
342
+ .replace(/\$\{slug\}/g, slug)
343
+ .replace(/\$\{output\}/g, outputName);
344
+
345
+ // Construct URL
346
+ if (endpoint) {
347
+ return `https://${bucket}.${region}.${endpoint}/${interpolatedPath}`;
348
+ } else {
349
+ return `https://${bucket}.s3.${region}.amazonaws.com/${interpolatedPath}`;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Converts a string to a URL-friendly slug
355
+ */
356
+ private slugify(text: string): string {
357
+ return text
358
+ .toString()
359
+ .toLowerCase()
360
+ .trim()
361
+ .replace(/\s+/g, '-')
362
+ .replace(/[^\w\-]+/g, '')
363
+ .replace(/\-\-+/g, '-')
364
+ .replace(/^-+/, '')
365
+ .replace(/-+$/, '');
366
+ }
367
+
368
+ /**
369
+ * Gets the permalink (URL) for the published media
370
+ */
371
+ private async getPermalink(
372
+ credentials: InstagramCredentials,
373
+ mediaId: string,
374
+ ): Promise<string> {
375
+ const params = new URLSearchParams({
376
+ fields: 'permalink',
377
+ access_token: credentials.accessToken,
378
+ });
379
+
380
+ const url = `${this.GRAPH_API_BASE}/${this.API_VERSION}/${mediaId}?${params.toString()}`;
381
+
382
+ try {
383
+ const data = await makeRequest<{ permalink?: string }>({
384
+ url,
385
+ method: 'GET',
386
+ });
387
+
388
+ if (!data.permalink) {
389
+ return `https://www.instagram.com/ (check your profile)`;
390
+ }
391
+
392
+ return data.permalink;
393
+ } catch (error) {
394
+ console.log(` Warning: Failed to get permalink: ${error}`);
395
+ return `https://www.instagram.com/ (check your profile)`;
396
+ }
397
+ }
398
+ }
@@ -0,0 +1,198 @@
1
+ import { UploadStrategy } from '../upload-strategy';
2
+ import { Project } from '../../project';
3
+ import { Upload } from '../../type';
4
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
5
+ import { readFileSync, existsSync } from 'fs';
6
+ import { resolve } from 'path';
7
+
8
+ /**
9
+ * S3 credentials format stored in .auth/<upload-name>.json
10
+ */
11
+ interface S3Credentials {
12
+ accessKeyId: string;
13
+ secretAccessKey: string;
14
+ }
15
+
16
+ /**
17
+ * S3 upload strategy implementation
18
+ * Supports generic S3-compatible storage (AWS S3, DigitalOcean Spaces, etc.)
19
+ */
20
+ export class S3UploadStrategy implements UploadStrategy {
21
+ constructor() {}
22
+
23
+ getTag(): string {
24
+ return 's3';
25
+ }
26
+
27
+ validate(): void {
28
+ // Validation happens in execute() since we need upload config
29
+ }
30
+
31
+ async execute(
32
+ project: Project,
33
+ upload: Upload,
34
+ projectPath: string,
35
+ ): Promise<void> {
36
+ // Validate S3 configuration exists
37
+ if (!upload.s3) {
38
+ throw new Error(
39
+ `āŒ Error: S3 configuration missing for upload "${upload.name}"`,
40
+ );
41
+ }
42
+
43
+ const { endpoint, region, bucket, path, acl } = upload.s3;
44
+
45
+ // Validate ACL value if specified
46
+ const allowedAcls = ['private', 'public-read', 'authenticated-read'];
47
+ if (acl && !allowedAcls.includes(acl)) {
48
+ throw new Error(
49
+ `āŒ Error: Invalid ACL value "${acl}" for upload "${upload.name}"\n\n` +
50
+ `Allowed values: ${allowedAcls.join(', ')}\n` +
51
+ `Note: "public-read-write" is not supported for security reasons.`,
52
+ );
53
+ }
54
+
55
+ // Load credentials from .auth/<upload-name>.json
56
+ const authDir = resolve(projectPath, '.auth');
57
+ const credentialsPath = resolve(authDir, `${upload.name}.json`);
58
+
59
+ if (!existsSync(credentialsPath)) {
60
+ throw new Error(
61
+ `āŒ Error: S3 credentials not found\n\n` +
62
+ `Expected location: ${credentialsPath}\n\n` +
63
+ `šŸ’” Create a JSON file with your S3 credentials:\n` +
64
+ `{\n` +
65
+ ` "accessKeyId": "YOUR_ACCESS_KEY",\n` +
66
+ ` "secretAccessKey": "YOUR_SECRET_KEY"\n` +
67
+ `}\n\n` +
68
+ `šŸ“– Get credentials from:\n` +
69
+ ` • AWS: IAM → Users → Security Credentials\n` +
70
+ ` • DigitalOcean: API → Spaces Keys\n`,
71
+ );
72
+ }
73
+
74
+ console.log(`šŸ” Loading credentials from: ${credentialsPath}`);
75
+
76
+ let credentials: S3Credentials;
77
+ try {
78
+ const credentialsJson = readFileSync(credentialsPath, 'utf-8');
79
+ credentials = JSON.parse(credentialsJson);
80
+
81
+ if (!credentials.accessKeyId || !credentials.secretAccessKey) {
82
+ throw new Error('Missing accessKeyId or secretAccessKey');
83
+ }
84
+ } catch (error) {
85
+ throw new Error(
86
+ `āŒ Error: Failed to parse S3 credentials from ${credentialsPath}\n` +
87
+ `Ensure the file contains valid JSON with accessKeyId and secretAccessKey.\n` +
88
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
89
+ );
90
+ }
91
+
92
+ // Get the output file
93
+ const output = project.getOutput(upload.outputName);
94
+ if (!output) {
95
+ throw new Error(`āŒ Error: Output "${upload.outputName}" not found`);
96
+ }
97
+
98
+ if (!existsSync(output.path)) {
99
+ throw new Error(
100
+ `āŒ Error: Output file not found: ${output.path}\n` +
101
+ 'šŸ’” Please generate the video first',
102
+ );
103
+ }
104
+
105
+ // Interpolate path variables
106
+ const slug = this.slugify(project.getTitle());
107
+ const outputName = output.name;
108
+ const interpolatedPath = path
109
+ .replace(/\$\{slug\}/g, slug)
110
+ .replace(/\$\{output\}/g, outputName);
111
+
112
+ console.log(`\nšŸ“¦ Preparing S3 upload...`);
113
+ console.log(` Bucket: ${bucket}`);
114
+ console.log(` Region: ${region}`);
115
+ if (endpoint) {
116
+ console.log(` Endpoint: ${endpoint}`);
117
+ }
118
+ console.log(` Path: ${interpolatedPath}`);
119
+ console.log(` File: ${output.path}\n`);
120
+
121
+ // Configure S3 client
122
+ const s3Config: any = {
123
+ region,
124
+ credentials: {
125
+ accessKeyId: credentials.accessKeyId,
126
+ secretAccessKey: credentials.secretAccessKey,
127
+ },
128
+ };
129
+
130
+ // Add custom endpoint for S3-compatible services (DigitalOcean Spaces, etc.)
131
+ if (endpoint) {
132
+ // Construct the endpoint URL with region for S3-compatible services
133
+ // e.g., "ams3.digitaloceanspaces.com" for DigitalOcean Spaces
134
+ s3Config.endpoint = `https://${region}.${endpoint}`;
135
+ // Use virtual-hosted-style addressing (bucket.region.endpoint.com)
136
+ s3Config.forcePathStyle = false;
137
+ }
138
+
139
+ const s3Client = new S3Client(s3Config);
140
+
141
+ // Read file
142
+ console.log(`šŸ“¤ Uploading to S3...`);
143
+ const fileBuffer = readFileSync(output.path);
144
+
145
+ // Upload file
146
+ const uploadParams: any = {
147
+ Bucket: bucket,
148
+ Key: interpolatedPath,
149
+ Body: fileBuffer,
150
+ ContentType: 'video/mp4',
151
+ };
152
+
153
+ // Add ACL if specified in configuration
154
+ if (acl) {
155
+ uploadParams.ACL = acl;
156
+ }
157
+
158
+ const command = new PutObjectCommand(uploadParams);
159
+
160
+ try {
161
+ await s3Client.send(command);
162
+
163
+ // Construct public URL
164
+ let publicUrl: string;
165
+ if (endpoint) {
166
+ // For DigitalOcean Spaces and similar services
167
+ // Format: https://{bucket}.{region}.{endpoint}/{path}
168
+ publicUrl = `https://${bucket}.${region}.${endpoint}/${interpolatedPath}`;
169
+ } else {
170
+ // For AWS S3
171
+ publicUrl = `https://${bucket}.s3.${region}.amazonaws.com/${interpolatedPath}`;
172
+ }
173
+
174
+ console.log(`\nāœ… Upload successful!`);
175
+ console.log(`šŸ”— Public URL: ${publicUrl}\n`);
176
+ } catch (error) {
177
+ throw new Error(
178
+ `āŒ Error: Failed to upload to S3\n` +
179
+ `${error instanceof Error ? error.message : String(error)}`,
180
+ );
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Converts a string to a URL-friendly slug
186
+ */
187
+ private slugify(text: string): string {
188
+ return text
189
+ .toString()
190
+ .toLowerCase()
191
+ .trim()
192
+ .replace(/\s+/g, '-') // Replace spaces with -
193
+ .replace(/[^\w\-]+/g, '') // Remove non-word chars
194
+ .replace(/\-\-+/g, '-') // Replace multiple - with single -
195
+ .replace(/^-+/, '') // Trim - from start
196
+ .replace(/-+$/, ''); // Trim - from end
197
+ }
198
+ }
@@ -0,0 +1,55 @@
1
+ import { UploadStrategy } from './upload-strategy';
2
+ import { YouTubeUploadStrategy } from './youtube/youtube-upload-strategy';
3
+ import { S3UploadStrategy } from './s3/s3-upload-strategy';
4
+ import { InstagramUploadStrategy } from './instagram/instagram-upload-strategy';
5
+
6
+ /**
7
+ * Factory for creating upload strategies based on upload tag
8
+ */
9
+ export class UploadStrategyFactory {
10
+ private strategies: Map<string, UploadStrategy> = new Map();
11
+
12
+ /**
13
+ * Registers an upload strategy
14
+ */
15
+ register(strategy: UploadStrategy): void {
16
+ this.strategies.set(strategy.getTag(), strategy);
17
+ }
18
+
19
+ /**
20
+ * Gets a strategy for the given tag
21
+ * @param tag The upload provider tag (e.g., "youtube", "s3", "instagram")
22
+ * @returns The strategy for this tag
23
+ * @throws Error if no strategy is registered for the tag
24
+ */
25
+ getStrategy(tag: string): UploadStrategy {
26
+ const strategy = this.strategies.get(tag);
27
+ if (!strategy) {
28
+ const availableTags = Array.from(this.strategies.keys());
29
+ throw new Error(
30
+ `No upload strategy registered for tag "${tag}".\n` +
31
+ (availableTags.length > 0
32
+ ? `Available: ${availableTags.join(', ')}`
33
+ : 'No upload strategies registered.'),
34
+ );
35
+ }
36
+ return strategy;
37
+ }
38
+
39
+ /**
40
+ * Creates a factory with all available strategies registered
41
+ */
42
+ static createDefault(): UploadStrategyFactory {
43
+ const factory = new UploadStrategyFactory();
44
+
45
+ factory.register(new YouTubeUploadStrategy());
46
+
47
+ // Register S3 strategy
48
+ factory.register(new S3UploadStrategy());
49
+
50
+ // Register Instagram strategy
51
+ factory.register(new InstagramUploadStrategy());
52
+
53
+ return factory;
54
+ }
55
+ }
@@ -0,0 +1,31 @@
1
+ import { Project } from '../project';
2
+ import { YouTubeUpload } from '../type';
3
+
4
+ /**
5
+ * Interface for upload strategies
6
+ * Each upload provider (YouTube, S3, etc.) implements this interface
7
+ */
8
+ export interface UploadStrategy {
9
+ /**
10
+ * Returns the tag name this strategy handles (e.g., "youtube", "s3")
11
+ */
12
+ getTag(): string;
13
+
14
+ /**
15
+ * Validates that required environment variables and configuration are present
16
+ * @throws Error if validation fails
17
+ */
18
+ validate(): void;
19
+
20
+ /**
21
+ * Executes the upload
22
+ * @param project The parsed project
23
+ * @param upload The upload configuration
24
+ * @param projectPath The absolute path to the project directory
25
+ */
26
+ execute(
27
+ project: Project,
28
+ upload: YouTubeUpload,
29
+ projectPath: string,
30
+ ): Promise<void>;
31
+ }