@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.
- package/Makefile +23 -10
- package/dist/cli/commands/add-assets.d.ts +3 -0
- package/dist/cli/commands/add-assets.d.ts.map +1 -0
- package/dist/cli/commands/add-assets.js +113 -0
- package/dist/cli/commands/add-assets.js.map +1 -0
- package/dist/cli/commands/bootstrap.d.ts +3 -0
- package/dist/cli/commands/bootstrap.d.ts.map +1 -0
- package/dist/cli/commands/bootstrap.js +49 -0
- package/dist/cli/commands/bootstrap.js.map +1 -0
- package/dist/cli/commands/generate.d.ts +3 -0
- package/dist/cli/commands/generate.d.ts.map +1 -0
- package/dist/cli/commands/generate.js +132 -0
- package/dist/cli/commands/generate.js.map +1 -0
- package/dist/cli/commands/upload.d.ts +6 -0
- package/dist/cli/commands/upload.d.ts.map +1 -0
- package/dist/cli/commands/upload.js +67 -0
- package/dist/cli/commands/upload.js.map +1 -0
- package/dist/cli/s3/s3-upload-strategy.d.ts +18 -0
- package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -0
- package/dist/cli/s3/s3-upload-strategy.js +149 -0
- package/dist/cli/s3/s3-upload-strategy.js.map +1 -0
- package/dist/cli/upload-strategy-factory.d.ts +23 -0
- package/dist/cli/upload-strategy-factory.d.ts.map +1 -0
- package/dist/cli/upload-strategy-factory.js +49 -0
- package/dist/cli/upload-strategy-factory.js.map +1 -0
- package/dist/cli/upload-strategy.d.ts +25 -0
- package/dist/cli/upload-strategy.d.ts.map +1 -0
- package/dist/cli/upload-strategy.js +3 -0
- package/dist/cli/upload-strategy.js.map +1 -0
- package/dist/cli/youtube/auth-commands.d.ts +3 -0
- package/dist/cli/youtube/auth-commands.d.ts.map +1 -0
- package/dist/cli/youtube/auth-commands.js +273 -0
- package/dist/cli/youtube/auth-commands.js.map +1 -0
- package/dist/cli/youtube/cli.d.ts +7 -0
- package/dist/cli/youtube/cli.d.ts.map +1 -0
- package/dist/cli/youtube/cli.js +13 -0
- package/dist/cli/youtube/cli.js.map +1 -0
- package/dist/cli/youtube/upload-handler.d.ts +12 -0
- package/dist/cli/youtube/upload-handler.d.ts.map +1 -0
- package/dist/cli/youtube/upload-handler.js +66 -0
- package/dist/cli/youtube/upload-handler.js.map +1 -0
- package/dist/cli/youtube/youtube-upload-strategy.d.ts +15 -0
- package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -0
- package/dist/cli/youtube/youtube-upload-strategy.js +37 -0
- package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -0
- package/dist/cli.js +12 -251
- package/dist/cli.js.map +1 -1
- package/dist/container-renderer.d.ts +5 -1
- package/dist/container-renderer.d.ts.map +1 -1
- package/dist/container-renderer.js +8 -7
- package/dist/container-renderer.js.map +1 -1
- package/dist/ffmpeg.d.ts +1 -1
- package/dist/ffmpeg.d.ts.map +1 -1
- package/dist/ffmpeg.js +5 -10
- package/dist/ffmpeg.js.map +1 -1
- package/dist/html-parser.d.ts +3 -4
- package/dist/html-parser.d.ts.map +1 -1
- package/dist/html-parser.js +20 -17
- package/dist/html-parser.js.map +1 -1
- package/dist/html-project-parser.d.ts +32 -0
- package/dist/html-project-parser.d.ts.map +1 -1
- package/dist/html-project-parser.js +413 -44
- package/dist/html-project-parser.js.map +1 -1
- package/dist/project.d.ts +19 -3
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +67 -3
- package/dist/project.js.map +1 -1
- package/dist/type.d.ts +30 -4
- package/dist/type.d.ts.map +1 -1
- package/dist/youtube-uploader.d.ts +40 -0
- package/dist/youtube-uploader.d.ts.map +1 -0
- package/dist/youtube-uploader.js +227 -0
- package/dist/youtube-uploader.js.map +1 -0
- package/package.json +6 -2
- package/src/cli/commands/add-assets.ts +159 -0
- package/src/cli/commands/bootstrap.ts +57 -0
- package/src/cli/commands/generate.ts +189 -0
- package/src/cli/commands/upload.ts +83 -0
- package/src/cli/s3/s3-upload-strategy.ts +194 -0
- package/src/cli/upload-strategy-factory.ts +58 -0
- package/src/cli/upload-strategy.ts +31 -0
- package/src/cli/youtube/auth-commands.ts +312 -0
- package/src/cli/youtube/cli.ts +11 -0
- package/src/cli/youtube/upload-handler.ts +101 -0
- package/src/cli/youtube/youtube-upload-strategy.ts +43 -0
- package/src/cli.ts +14 -350
- package/src/container-renderer.ts +10 -8
- package/src/ffmpeg.ts +5 -12
- package/src/html-parser.ts +23 -21
- package/src/html-project-parser.ts +474 -48
- package/src/project.ts +85 -2
- package/src/type.ts +35 -4
- 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
|
+
}
|