@gannochenko/staticstripes 0.0.10 → 0.0.12

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 (93) hide show
  1. package/Makefile +23 -10
  2. package/dist/cli/commands/add-assets.d.ts +3 -0
  3. package/dist/cli/commands/add-assets.d.ts.map +1 -0
  4. package/dist/cli/commands/add-assets.js +113 -0
  5. package/dist/cli/commands/add-assets.js.map +1 -0
  6. package/dist/cli/commands/bootstrap.d.ts +3 -0
  7. package/dist/cli/commands/bootstrap.d.ts.map +1 -0
  8. package/dist/cli/commands/bootstrap.js +49 -0
  9. package/dist/cli/commands/bootstrap.js.map +1 -0
  10. package/dist/cli/commands/generate.d.ts +3 -0
  11. package/dist/cli/commands/generate.d.ts.map +1 -0
  12. package/dist/cli/commands/generate.js +132 -0
  13. package/dist/cli/commands/generate.js.map +1 -0
  14. package/dist/cli/commands/upload.d.ts +6 -0
  15. package/dist/cli/commands/upload.d.ts.map +1 -0
  16. package/dist/cli/commands/upload.js +67 -0
  17. package/dist/cli/commands/upload.js.map +1 -0
  18. package/dist/cli/s3/s3-upload-strategy.d.ts +18 -0
  19. package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -0
  20. package/dist/cli/s3/s3-upload-strategy.js +149 -0
  21. package/dist/cli/s3/s3-upload-strategy.js.map +1 -0
  22. package/dist/cli/upload-strategy-factory.d.ts +23 -0
  23. package/dist/cli/upload-strategy-factory.d.ts.map +1 -0
  24. package/dist/cli/upload-strategy-factory.js +49 -0
  25. package/dist/cli/upload-strategy-factory.js.map +1 -0
  26. package/dist/cli/upload-strategy.d.ts +25 -0
  27. package/dist/cli/upload-strategy.d.ts.map +1 -0
  28. package/dist/cli/upload-strategy.js +3 -0
  29. package/dist/cli/upload-strategy.js.map +1 -0
  30. package/dist/cli/youtube/auth-commands.d.ts +3 -0
  31. package/dist/cli/youtube/auth-commands.d.ts.map +1 -0
  32. package/dist/cli/youtube/auth-commands.js +273 -0
  33. package/dist/cli/youtube/auth-commands.js.map +1 -0
  34. package/dist/cli/youtube/cli.d.ts +7 -0
  35. package/dist/cli/youtube/cli.d.ts.map +1 -0
  36. package/dist/cli/youtube/cli.js +13 -0
  37. package/dist/cli/youtube/cli.js.map +1 -0
  38. package/dist/cli/youtube/upload-handler.d.ts +12 -0
  39. package/dist/cli/youtube/upload-handler.d.ts.map +1 -0
  40. package/dist/cli/youtube/upload-handler.js +66 -0
  41. package/dist/cli/youtube/upload-handler.js.map +1 -0
  42. package/dist/cli/youtube/youtube-upload-strategy.d.ts +15 -0
  43. package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -0
  44. package/dist/cli/youtube/youtube-upload-strategy.js +37 -0
  45. package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -0
  46. package/dist/cli.js +12 -251
  47. package/dist/cli.js.map +1 -1
  48. package/dist/container-renderer.d.ts +5 -1
  49. package/dist/container-renderer.d.ts.map +1 -1
  50. package/dist/container-renderer.js +8 -7
  51. package/dist/container-renderer.js.map +1 -1
  52. package/dist/ffmpeg.d.ts +1 -1
  53. package/dist/ffmpeg.d.ts.map +1 -1
  54. package/dist/ffmpeg.js +5 -10
  55. package/dist/ffmpeg.js.map +1 -1
  56. package/dist/html-parser.d.ts +3 -4
  57. package/dist/html-parser.d.ts.map +1 -1
  58. package/dist/html-parser.js +20 -17
  59. package/dist/html-parser.js.map +1 -1
  60. package/dist/html-project-parser.d.ts +32 -0
  61. package/dist/html-project-parser.d.ts.map +1 -1
  62. package/dist/html-project-parser.js +413 -44
  63. package/dist/html-project-parser.js.map +1 -1
  64. package/dist/project.d.ts +19 -3
  65. package/dist/project.d.ts.map +1 -1
  66. package/dist/project.js +67 -3
  67. package/dist/project.js.map +1 -1
  68. package/dist/type.d.ts +30 -4
  69. package/dist/type.d.ts.map +1 -1
  70. package/dist/youtube-uploader.d.ts +40 -0
  71. package/dist/youtube-uploader.d.ts.map +1 -0
  72. package/dist/youtube-uploader.js +227 -0
  73. package/dist/youtube-uploader.js.map +1 -0
  74. package/package.json +6 -2
  75. package/src/cli/commands/add-assets.ts +159 -0
  76. package/src/cli/commands/bootstrap.ts +57 -0
  77. package/src/cli/commands/generate.ts +189 -0
  78. package/src/cli/commands/upload.ts +83 -0
  79. package/src/cli/s3/s3-upload-strategy.ts +194 -0
  80. package/src/cli/upload-strategy-factory.ts +58 -0
  81. package/src/cli/upload-strategy.ts +31 -0
  82. package/src/cli/youtube/auth-commands.ts +312 -0
  83. package/src/cli/youtube/cli.ts +11 -0
  84. package/src/cli/youtube/upload-handler.ts +101 -0
  85. package/src/cli/youtube/youtube-upload-strategy.ts +43 -0
  86. package/src/cli.ts +14 -350
  87. package/src/container-renderer.ts +10 -8
  88. package/src/ffmpeg.ts +5 -12
  89. package/src/html-parser.ts +23 -21
  90. package/src/html-project-parser.ts +474 -48
  91. package/src/project.ts +85 -2
  92. package/src/type.ts +35 -4
  93. package/src/youtube-uploader.ts +288 -0
@@ -0,0 +1,83 @@
1
+ import { Command } from 'commander';
2
+ import { resolve } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import { HTMLParser } from '../../html-parser.js';
5
+ import { HTMLProjectParser } from '../../html-project-parser.js';
6
+ import { UploadStrategyFactory } from '../upload-strategy-factory.js';
7
+
8
+ /**
9
+ * Registers the generic upload command that works with any upload provider
10
+ */
11
+ export function registerUploadCommand(
12
+ program: Command,
13
+ handleError: (error: any, operation: string) => void,
14
+ ): void {
15
+ program
16
+ .command('upload')
17
+ .description('Upload video to configured platform (YouTube, S3, etc.)')
18
+ .option('-p, --project <path>', 'Path to project directory', '.')
19
+ .requiredOption('--upload-name <name>', 'Name of the upload configuration')
20
+ .action(async (options) => {
21
+ try {
22
+ // Resolve project path
23
+ const projectPath = resolve(process.cwd(), options.project);
24
+ const projectFilePath = resolve(projectPath, 'project.html');
25
+
26
+ // Validate project.html exists
27
+ if (!existsSync(projectFilePath)) {
28
+ console.error(`āŒ Error: project.html not found in ${projectPath}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ console.log(`šŸ“ Project: ${projectPath}`);
33
+ console.log(`šŸ“„ Loading: ${projectFilePath}\n`);
34
+
35
+ // Parse the project HTML file
36
+ const parser = new HTMLProjectParser(
37
+ await new HTMLParser().parseFile(projectFilePath),
38
+ projectFilePath,
39
+ );
40
+ const project = await parser.parse();
41
+
42
+ // Get the upload configuration
43
+ const upload = project.getUpload(options.uploadName);
44
+ if (!upload) {
45
+ const availableUploads = Array.from(
46
+ project.getUploads().keys(),
47
+ );
48
+ console.error(
49
+ `āŒ Upload "${options.uploadName}" not found in project.html\n`,
50
+ );
51
+ if (availableUploads.length > 0) {
52
+ console.error(`Available uploads: ${availableUploads.join(', ')}`);
53
+ } else {
54
+ console.error('No uploads defined in project.html');
55
+ }
56
+ process.exit(1);
57
+ }
58
+
59
+ // Validate output file exists
60
+ const output = project.getOutput(upload.outputName);
61
+ if (output && !existsSync(output.path)) {
62
+ console.error(`āŒ Error: Output file not found: ${output.path}`);
63
+ console.error(
64
+ 'šŸ’” Please generate the video first with: staticstripes generate\n',
65
+ );
66
+ process.exit(1);
67
+ }
68
+
69
+ // Get the appropriate strategy for this upload tag
70
+ const factory = UploadStrategyFactory.createDefault();
71
+ const strategy = factory.getStrategy(upload.tag);
72
+
73
+ // Validate strategy requirements
74
+ strategy.validate();
75
+
76
+ // Execute the upload
77
+ await strategy.execute(project, upload, projectPath);
78
+ } catch (error) {
79
+ handleError(error, 'Upload');
80
+ process.exit(1);
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,194 @@
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 at ${credentialsPath}\n\n` +
62
+ `šŸ’” Create a JSON file with the following format:\n` +
63
+ `{\n` +
64
+ ` "accessKeyId": "YOUR_ACCESS_KEY",\n` +
65
+ ` "secretAccessKey": "YOUR_SECRET_KEY"\n` +
66
+ `}\n`,
67
+ );
68
+ }
69
+
70
+ console.log(`šŸ” Loading credentials from: ${credentialsPath}`);
71
+
72
+ let credentials: S3Credentials;
73
+ try {
74
+ const credentialsJson = readFileSync(credentialsPath, 'utf-8');
75
+ credentials = JSON.parse(credentialsJson);
76
+
77
+ if (!credentials.accessKeyId || !credentials.secretAccessKey) {
78
+ throw new Error('Missing accessKeyId or secretAccessKey');
79
+ }
80
+ } catch (error) {
81
+ throw new Error(
82
+ `āŒ Error: Failed to parse S3 credentials from ${credentialsPath}\n` +
83
+ `Ensure the file contains valid JSON with accessKeyId and secretAccessKey.\n` +
84
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
85
+ );
86
+ }
87
+
88
+ // Get the output file
89
+ const output = project.getOutput(upload.outputName);
90
+ if (!output) {
91
+ throw new Error(`āŒ Error: Output "${upload.outputName}" not found`);
92
+ }
93
+
94
+ if (!existsSync(output.path)) {
95
+ throw new Error(
96
+ `āŒ Error: Output file not found: ${output.path}\n` +
97
+ 'šŸ’” Please generate the video first',
98
+ );
99
+ }
100
+
101
+ // Interpolate path variables
102
+ const slug = this.slugify(project.getTitle());
103
+ const outputName = output.name;
104
+ const interpolatedPath = path
105
+ .replace(/\$\{slug\}/g, slug)
106
+ .replace(/\$\{output\}/g, outputName);
107
+
108
+ console.log(`\nšŸ“¦ Preparing S3 upload...`);
109
+ console.log(` Bucket: ${bucket}`);
110
+ console.log(` Region: ${region}`);
111
+ if (endpoint) {
112
+ console.log(` Endpoint: ${endpoint}`);
113
+ }
114
+ console.log(` Path: ${interpolatedPath}`);
115
+ console.log(` File: ${output.path}\n`);
116
+
117
+ // Configure S3 client
118
+ const s3Config: any = {
119
+ region,
120
+ credentials: {
121
+ accessKeyId: credentials.accessKeyId,
122
+ secretAccessKey: credentials.secretAccessKey,
123
+ },
124
+ };
125
+
126
+ // Add custom endpoint for S3-compatible services (DigitalOcean Spaces, etc.)
127
+ if (endpoint) {
128
+ // Construct the endpoint URL with region for S3-compatible services
129
+ // e.g., "ams3.digitaloceanspaces.com" for DigitalOcean Spaces
130
+ s3Config.endpoint = `https://${region}.${endpoint}`;
131
+ // Use virtual-hosted-style addressing (bucket.region.endpoint.com)
132
+ s3Config.forcePathStyle = false;
133
+ }
134
+
135
+ const s3Client = new S3Client(s3Config);
136
+
137
+ // Read file
138
+ console.log(`šŸ“¤ Uploading to S3...`);
139
+ const fileBuffer = readFileSync(output.path);
140
+
141
+ // Upload file
142
+ const uploadParams: any = {
143
+ Bucket: bucket,
144
+ Key: interpolatedPath,
145
+ Body: fileBuffer,
146
+ ContentType: 'video/mp4',
147
+ };
148
+
149
+ // Add ACL if specified in configuration
150
+ if (acl) {
151
+ uploadParams.ACL = acl;
152
+ }
153
+
154
+ const command = new PutObjectCommand(uploadParams);
155
+
156
+ try {
157
+ await s3Client.send(command);
158
+
159
+ // Construct public URL
160
+ let publicUrl: string;
161
+ if (endpoint) {
162
+ // For DigitalOcean Spaces and similar services
163
+ // Format: https://{bucket}.{region}.{endpoint}/{path}
164
+ publicUrl = `https://${bucket}.${region}.${endpoint}/${interpolatedPath}`;
165
+ } else {
166
+ // For AWS S3
167
+ publicUrl = `https://${bucket}.s3.${region}.amazonaws.com/${interpolatedPath}`;
168
+ }
169
+
170
+ console.log(`\nāœ… Upload successful!`);
171
+ console.log(`šŸ”— Public URL: ${publicUrl}\n`);
172
+ } catch (error) {
173
+ throw new Error(
174
+ `āŒ Error: Failed to upload to S3\n` +
175
+ `${error instanceof Error ? error.message : String(error)}`,
176
+ );
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Converts a string to a URL-friendly slug
182
+ */
183
+ private slugify(text: string): string {
184
+ return text
185
+ .toString()
186
+ .toLowerCase()
187
+ .trim()
188
+ .replace(/\s+/g, '-') // Replace spaces with -
189
+ .replace(/[^\w\-]+/g, '') // Remove non-word chars
190
+ .replace(/\-\-+/g, '-') // Replace multiple - with single -
191
+ .replace(/^-+/, '') // Trim - from start
192
+ .replace(/-+$/, ''); // Trim - from end
193
+ }
194
+ }
@@ -0,0 +1,58 @@
1
+ import { UploadStrategy } from './upload-strategy';
2
+ import { YouTubeUploadStrategy } from './youtube/youtube-upload-strategy';
3
+ import { S3UploadStrategy } from './s3/s3-upload-strategy';
4
+
5
+ /**
6
+ * Factory for creating upload strategies based on upload tag
7
+ */
8
+ export class UploadStrategyFactory {
9
+ private strategies: Map<string, UploadStrategy> = new Map();
10
+
11
+ /**
12
+ * Registers an upload strategy
13
+ */
14
+ register(strategy: UploadStrategy): void {
15
+ this.strategies.set(strategy.getTag(), strategy);
16
+ }
17
+
18
+ /**
19
+ * Gets a strategy for the given tag
20
+ * @param tag The upload provider tag (e.g., "youtube", "s3")
21
+ * @returns The strategy for this tag
22
+ * @throws Error if no strategy is registered for the tag
23
+ */
24
+ getStrategy(tag: string): UploadStrategy {
25
+ const strategy = this.strategies.get(tag);
26
+ if (!strategy) {
27
+ const availableTags = Array.from(this.strategies.keys());
28
+ throw new Error(
29
+ `No upload strategy registered for tag "${tag}".\n` +
30
+ (availableTags.length > 0
31
+ ? `Available: ${availableTags.join(', ')}`
32
+ : 'No upload strategies registered.'),
33
+ );
34
+ }
35
+ return strategy;
36
+ }
37
+
38
+ /**
39
+ * Creates a factory with all available strategies registered
40
+ */
41
+ static createDefault(): UploadStrategyFactory {
42
+ const factory = new UploadStrategyFactory();
43
+
44
+ // Register YouTube strategy (validation happens during execute)
45
+ const youtubeClientId = process.env.STATICSTRIPES_GOOGLE_CLIENT_ID || '';
46
+ const youtubeClientSecret =
47
+ process.env.STATICSTRIPES_GOOGLE_CLIENT_SECRET || '';
48
+
49
+ factory.register(
50
+ new YouTubeUploadStrategy(youtubeClientId, youtubeClientSecret),
51
+ );
52
+
53
+ // Register S3 strategy
54
+ factory.register(new S3UploadStrategy());
55
+
56
+ return factory;
57
+ }
58
+ }
@@ -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
+ }
@@ -0,0 +1,312 @@
1
+ import { Command } from 'commander';
2
+ import { resolve } from 'path';
3
+ import { existsSync } from 'fs';
4
+ import { YouTubeUploader } from '../../youtube-uploader.js';
5
+ import open from 'open';
6
+ import http from 'http';
7
+ import { parse as parseUrl } from 'url';
8
+
9
+ function getOAuthInstructions(): string {
10
+ let instructions = '';
11
+ instructions +=
12
+ 'āŒ Error: STATICSTRIPES_GOOGLE_CLIENT_ID and STATICSTRIPES_GOOGLE_CLIENT_SECRET environment variables are not set\n\n';
13
+ instructions += 'šŸ“‹ Getting Google OAuth Credentials:\n\n';
14
+ instructions += '1. Go to Google Cloud Console:\n';
15
+ instructions += ' https://console.cloud.google.com/\n\n';
16
+ instructions += '2. Create or select a project\n\n';
17
+ instructions += '3. Enable YouTube Data API v3:\n';
18
+ instructions += ' - Go to "APIs & Services" > "Library"\n';
19
+ instructions += ' - Search for "YouTube Data API v3"\n';
20
+ instructions += ' - Click "Enable"\n\n';
21
+ instructions += '4. Configure OAuth Consent Screen:\n';
22
+ instructions += ' - Go to "APIs & Services" > "OAuth consent screen"\n';
23
+ instructions += ' - Choose "External" user type\n';
24
+ instructions += ' - Fill in app name and contact emails\n';
25
+ instructions +=
26
+ ' - Add scope: https://www.googleapis.com/auth/youtube.upload\n';
27
+ instructions += ' - Add your email as a test user\n\n';
28
+ instructions += '5. Create OAuth 2.0 Credentials:\n';
29
+ instructions += ' - Go to "APIs & Services" > "Credentials"\n';
30
+ instructions += ' - Click "Create Credentials" > "OAuth client ID"\n';
31
+ instructions += ' - Choose "Web application"\n';
32
+ instructions +=
33
+ ' - Add redirect URI: http://localhost:3000/oauth2callback\n';
34
+ instructions += ' - Click "Create"\n\n';
35
+ instructions += '6. Copy your Client ID and Client Secret\n\n';
36
+ instructions += '7. Publish your OAuth app (IMPORTANT):\n';
37
+ instructions +=
38
+ ' - Go to "APIs & Services" > "OAuth consent screen"\n';
39
+ instructions += ' - Click "PUBLISH APP" button\n';
40
+ instructions +=
41
+ ' - This makes refresh tokens permanent (otherwise they expire in 7 days)\n';
42
+ instructions +=
43
+ ' - Note: For personal use, you don\'t need Google verification\n\n';
44
+ instructions += '8. Set environment variables:\n\n';
45
+
46
+ // Platform-specific instructions
47
+ const platform = process.platform;
48
+ if (platform === 'win32') {
49
+ instructions += ' PowerShell (Recommended) - Run as Administrator:\n';
50
+ instructions +=
51
+ ' [System.Environment]::SetEnvironmentVariable("STATICSTRIPES_GOOGLE_CLIENT_ID", "your-client-id.apps.googleusercontent.com", "User")\n';
52
+ instructions +=
53
+ ' [System.Environment]::SetEnvironmentVariable("STATICSTRIPES_GOOGLE_CLIENT_SECRET", "your-client-secret", "User")\n';
54
+ instructions += ' Then restart your terminal\n\n';
55
+ instructions += ' Or Command Prompt - Run as Administrator:\n';
56
+ instructions +=
57
+ ' setx STATICSTRIPES_GOOGLE_CLIENT_ID "your-client-id.apps.googleusercontent.com"\n';
58
+ instructions +=
59
+ ' setx STATICSTRIPES_GOOGLE_CLIENT_SECRET "your-client-secret"\n';
60
+ instructions += ' Then restart your terminal\n\n';
61
+ } else if (platform === 'darwin') {
62
+ instructions += ' Add to ~/.zshrc (or ~/.bash_profile for bash):\n';
63
+ instructions +=
64
+ ' export STATICSTRIPES_GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"\n';
65
+ instructions +=
66
+ ' export STATICSTRIPES_GOOGLE_CLIENT_SECRET="your-client-secret"\n\n';
67
+ instructions += ' Then reload your shell:\n';
68
+ instructions += ' source ~/.zshrc\n\n';
69
+ } else {
70
+ // Linux and others
71
+ instructions += ' Add to ~/.bashrc (or ~/.zshrc for zsh):\n';
72
+ instructions +=
73
+ ' export STATICSTRIPES_GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"\n';
74
+ instructions +=
75
+ ' export STATICSTRIPES_GOOGLE_CLIENT_SECRET="your-client-secret"\n\n';
76
+ instructions += ' Then reload your shell:\n';
77
+ instructions += ' source ~/.bashrc # or source ~/.zshrc\n\n';
78
+ }
79
+
80
+ return instructions;
81
+ }
82
+
83
+ export function registerYouTubeAuthCommands(program: Command): void {
84
+ program
85
+ .command('auth')
86
+ .description('Authenticate with YouTube for uploading')
87
+ .option('-p, --project <path>', 'Path to project directory', '.')
88
+ .requiredOption('--upload-name <name>', 'Name of the upload configuration')
89
+ .action(async (options) => {
90
+ try {
91
+ // Get OAuth credentials from environment variables
92
+ const clientId = process.env.STATICSTRIPES_GOOGLE_CLIENT_ID;
93
+ const clientSecret = process.env.STATICSTRIPES_GOOGLE_CLIENT_SECRET;
94
+
95
+ if (!clientId || !clientSecret) {
96
+ console.error(getOAuthInstructions());
97
+ process.exit(1);
98
+ }
99
+
100
+ // Resolve project path
101
+ const projectPath = resolve(process.cwd(), options.project);
102
+ const projectFilePath = resolve(projectPath, 'project.html');
103
+
104
+ // Validate project.html exists
105
+ if (!existsSync(projectFilePath)) {
106
+ console.error(`Error: project.html not found in ${projectPath}`);
107
+ process.exit(1);
108
+ }
109
+
110
+ console.log(`šŸ“ Project: ${projectPath}`);
111
+ console.log(`šŸ” Authenticating: ${options.uploadName}\n`);
112
+
113
+ // Create uploader instance
114
+ const uploader = new YouTubeUploader(clientId, clientSecret);
115
+
116
+ // Get authorization URL
117
+ const authUrl = uploader.getAuthUrl();
118
+
119
+ console.log('🌐 Starting local server on http://localhost:3000...\n');
120
+
121
+ // Create a promise that resolves when we get the OAuth callback
122
+ const authPromise = new Promise<string>((resolve, reject) => {
123
+ // Track all connections to force-close them
124
+ const connections = new Set<any>();
125
+
126
+ const server = http.createServer((req, res) => {
127
+ const url = parseUrl(req.url || '', true);
128
+
129
+ if (url.pathname === '/oauth2callback') {
130
+ const code = url.query.code as string;
131
+ const error = url.query.error as string;
132
+
133
+ const closeServer = () => {
134
+ // Destroy all connections
135
+ connections.forEach((socket) => {
136
+ socket.destroy();
137
+ });
138
+ connections.clear();
139
+ server.close();
140
+ };
141
+
142
+ if (error) {
143
+ res.writeHead(200, { 'Content-Type': 'text/html' });
144
+ res.end(`
145
+ <html>
146
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
147
+ <h1>āŒ Authorization Failed</h1>
148
+ <p>Error: ${error}</p>
149
+ <p>You can close this window.</p>
150
+ </body>
151
+ </html>
152
+ `);
153
+ res.on('finish', closeServer);
154
+ reject(new Error(`Authorization failed: ${error}`));
155
+ return;
156
+ }
157
+
158
+ if (code) {
159
+ res.writeHead(200, { 'Content-Type': 'text/html' });
160
+ res.end(`
161
+ <html>
162
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
163
+ <h1>āœ… Authorization Successful!</h1>
164
+ <p>You can close this window and return to the terminal.</p>
165
+ </body>
166
+ </html>
167
+ `);
168
+ res.on('finish', closeServer);
169
+ resolve(code);
170
+ } else {
171
+ res.writeHead(400, { 'Content-Type': 'text/html' });
172
+ res.end(`
173
+ <html>
174
+ <body style="font-family: system-ui; padding: 40px; text-align: center;">
175
+ <h1>āŒ No Authorization Code</h1>
176
+ <p>No code was received from Google.</p>
177
+ <p>You can close this window.</p>
178
+ </body>
179
+ </html>
180
+ `);
181
+ res.on('finish', closeServer);
182
+ reject(new Error('No authorization code received'));
183
+ }
184
+ } else {
185
+ res.writeHead(404);
186
+ res.end('Not found');
187
+ }
188
+ });
189
+
190
+ // Track connections
191
+ server.on('connection', (socket) => {
192
+ connections.add(socket);
193
+ socket.on('close', () => {
194
+ connections.delete(socket);
195
+ });
196
+ });
197
+
198
+ server.listen(3000, () => {
199
+ console.log('āœ… Server started successfully\n');
200
+ });
201
+
202
+ // Set timeout to avoid hanging forever
203
+ setTimeout(
204
+ () => {
205
+ connections.forEach((socket) => {
206
+ socket.destroy();
207
+ });
208
+ connections.clear();
209
+ server.close();
210
+ reject(new Error('Authentication timeout (5 minutes)'));
211
+ },
212
+ 5 * 60 * 1000,
213
+ );
214
+ });
215
+
216
+ console.log('🌐 Opening browser for authorization...\n');
217
+
218
+ // Open browser automatically
219
+ try {
220
+ await open(authUrl);
221
+ console.log('āœ… Browser opened successfully\n');
222
+ } catch (err) {
223
+ console.log('āš ļø Could not open browser automatically');
224
+ console.log('🌐 Please visit this URL to authorize:\n');
225
+ console.log(authUrl);
226
+ console.log();
227
+ }
228
+
229
+ console.log('ā³ Waiting for authorization...\n');
230
+
231
+ // Wait for the OAuth callback
232
+ const code = await authPromise;
233
+
234
+ console.log('šŸ”‘ Authorization code received\n');
235
+ console.log('šŸ’¾ Saving authentication tokens...\n');
236
+
237
+ // Complete authentication
238
+ await uploader.authenticate(code, options.uploadName, projectPath);
239
+
240
+ console.log(`āœ… Authentication complete for ${options.uploadName}!\n`);
241
+ } catch (error: any) {
242
+ console.error(`\nāŒ Authentication failed\n`);
243
+ if (error.message) {
244
+ console.error(`Error: ${error.message}\n`);
245
+ }
246
+ process.exit(1);
247
+ }
248
+ });
249
+
250
+ program
251
+ .command('auth-complete')
252
+ .description(
253
+ '(Fallback) Complete authentication with authorization code manually',
254
+ )
255
+ .option('-p, --project <path>', 'Path to project directory', '.')
256
+ .requiredOption('--upload-name <name>', 'Name of the upload configuration')
257
+ .requiredOption('--code <code>', 'Authorization code from OAuth flow')
258
+ .action(async (options) => {
259
+ try {
260
+ const clientId = process.env.STATICSTRIPES_GOOGLE_CLIENT_ID;
261
+ const clientSecret = process.env.STATICSTRIPES_GOOGLE_CLIENT_SECRET;
262
+
263
+ if (!clientId || !clientSecret) {
264
+ console.error(
265
+ 'āŒ Error: STATICSTRIPES_GOOGLE_CLIENT_ID and STATICSTRIPES_GOOGLE_CLIENT_SECRET environment variables are not set',
266
+ );
267
+ console.error('\nšŸ’” Run: staticstripes auth --help');
268
+ console.error(' for complete setup instructions\n');
269
+ process.exit(1);
270
+ }
271
+
272
+ const projectPath = resolve(process.cwd(), options.project);
273
+
274
+ // Create uploader and complete authentication
275
+ const uploader = new YouTubeUploader(clientId, clientSecret);
276
+ await uploader.authenticate(
277
+ options.code,
278
+ options.uploadName,
279
+ projectPath,
280
+ );
281
+
282
+ console.log(`āœ… Authentication complete for ${options.uploadName}`);
283
+ } catch (error: any) {
284
+ console.error(`\nāŒ Authentication completion failed\n`);
285
+ if (error.message) {
286
+ console.error(`Error: ${error.message}\n`);
287
+ }
288
+
289
+ // Provide helpful guidance for common OAuth errors
290
+ if (error.message === 'invalid_grant' || error.code === 400) {
291
+ console.error('šŸ’” Common causes:\n');
292
+ console.error(
293
+ ' • Authorization code expired (codes expire in ~60 seconds)',
294
+ );
295
+ console.error(' • Code was already used');
296
+ console.error(' • Code was copied incorrectly\n');
297
+ console.error(
298
+ 'šŸ”„ Solution: Run the auth command again and complete it quickly:\n',
299
+ );
300
+ console.error(
301
+ ` 1. staticstripes auth --upload-name ${options.uploadName}`,
302
+ );
303
+ console.error(' 2. Authorize in browser immediately');
304
+ console.error(' 3. Copy the code from the URL');
305
+ console.error(
306
+ ' 4. Run auth-complete right away with the fresh code\n',
307
+ );
308
+ }
309
+ process.exit(1);
310
+ }
311
+ });
312
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from 'commander';
2
+ import { registerYouTubeAuthCommands } from './auth-commands.js';
3
+
4
+ /**
5
+ * Registers YouTube-specific commands (authentication only)
6
+ * Upload functionality is handled by the generic upload command with strategy pattern
7
+ */
8
+ export function registerYouTubeCommands(program: Command): void {
9
+ // Register YouTube auth commands
10
+ registerYouTubeAuthCommands(program);
11
+ }