Created
August 6, 2025 21:41
-
-
Save johnlindquist/7222f8967e7b94a4c6a722923f1f4b41 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Name: Create egghead lesson from markdown | |
| import "@johnlindquist/kit" | |
| import axios, { AxiosInstance } from 'axios'; | |
| import matter from 'gray-matter'; | |
| import { basename, dirname, resolve } from 'path'; | |
| import { z } from 'zod'; | |
| const DEBUG_ENABLED = true; | |
| // OAuthClient.ts β v6βready, ESM | |
| import * as client from 'openid-client'; | |
| import ora from 'ora'; | |
| import chalk from 'chalk'; | |
| import open from 'open'; | |
| import { homedir } from 'os'; | |
| import { existsSync } from 'fs'; | |
| import { mkdir, readFile, writeFile } from 'fs/promises'; | |
| import { join } from 'path'; | |
| const TOKEN_FILE = home('.course-builder', 'tokens.json'); | |
| interface StoredTokens { | |
| access_token: string; | |
| refresh_token?: string; | |
| expires_at?: number; // β we compute this ourselves | |
| id_token?: string; | |
| token_type: string; | |
| } | |
| export class OAuthClient { | |
| private config: client.Configuration | null = null; | |
| private authenticationInProgress = false; | |
| private lastAuthenticationTime = 0; | |
| /* 1οΈβ£ full login (device flow) */ | |
| async authenticate(): Promise<client.TokenEndpointResponse> { | |
| // Prevent multiple simultaneous authentication attempts | |
| if (this.authenticationInProgress) { | |
| throw new Error('Authentication already in progress'); | |
| } | |
| // Prevent rapid re-authentication (within 30 seconds) | |
| const now = Date.now(); | |
| if (now - this.lastAuthenticationTime < 30000) { | |
| throw new Error('Authentication was attempted too recently. Please wait before trying again.'); | |
| } | |
| this.authenticationInProgress = true; | |
| this.lastAuthenticationTime = now; | |
| const spinner = ora('Registering client & starting device flowβ¦').start(); | |
| try { | |
| /* First, fetch the discovery document directly */ | |
| spinner.text = 'Discovering OAuth configuration...'; | |
| const discoveryResponse = await fetch('https://builder.egghead.io/oauth/.well-known/openid-configuration'); | |
| if (!discoveryResponse.ok) { | |
| throw new Error(`Discovery failed: ${discoveryResponse.status}`); | |
| } | |
| const serverMetadata = await discoveryResponse.json() as client.ServerMetadata; | |
| /* Then perform client registration */ | |
| spinner.text = 'Registering client...'; | |
| const registrationEndpoint = serverMetadata.registration_endpoint; | |
| if (!registrationEndpoint) { | |
| throw new Error('No registration endpoint found in discovery metadata'); | |
| } | |
| const registrationResponse = await fetch(registrationEndpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| grant_types: [ | |
| 'urn:ietf:params:oauth:grant-type:device_code', | |
| 'refresh_token', | |
| ], | |
| token_endpoint_auth_method: 'none', | |
| response_types: [], | |
| }), | |
| }); | |
| if (!registrationResponse.ok) { | |
| throw new Error(`Registration failed: ${registrationResponse.status}`); | |
| } | |
| const clientData = await registrationResponse.json() as client.ClientMetadata; | |
| // Create Configuration with the discovered metadata and client registration data | |
| this.config = new client.Configuration(serverMetadata, clientData.client_id, clientData); | |
| spinner.text = 'Requesting device codeβ¦'; | |
| const device = await client.initiateDeviceAuthorization(this.config, { | |
| scope: 'openid', | |
| }); | |
| spinner.succeed('User action required'); | |
| console.log( | |
| `\n${chalk.yellow('Verify at:')} ${chalk.cyan(device.verification_uri_complete ?? device.verification_uri)}`, | |
| ); | |
| try { await open(device.verification_uri_complete ?? device.verification_uri); } | |
| catch { console.log(chalk.dim('(Could not autoβopen browser)')); } | |
| const poll = ora('Waiting for you to finish loginβ¦').start(); | |
| try { | |
| const tokens = await client.pollDeviceAuthorizationGrant( | |
| this.config, | |
| device, | |
| ); | |
| poll.succeed('Authentication successful β '); | |
| console.log(chalk.dim('Received tokens, storing...')); | |
| await this.storeTokens(tokens); | |
| console.log(chalk.green('β Tokens stored successfully')); | |
| this.authenticationInProgress = false; | |
| return tokens; | |
| } catch (pollError) { | |
| poll.fail('Device authorization polling failed'); | |
| console.error(chalk.red('Polling error:'), pollError); | |
| this.authenticationInProgress = false; | |
| throw pollError; | |
| } | |
| } catch (error) { | |
| spinner.fail('OAuth authentication failed'); | |
| this.authenticationInProgress = false; | |
| throw error; | |
| } | |
| } | |
| /* 2οΈβ£ use (or refresh) an access token */ | |
| async getValidToken(): Promise<string> { | |
| console.log(chalk.dim('Getting valid token...')); | |
| const stored = await this.loadTokens(); | |
| if (!stored) { | |
| console.log(chalk.yellow('No stored tokens found, authenticating...')); | |
| return (await this.authenticate()).access_token; | |
| } | |
| const now = Math.floor(Date.now() / 1000); | |
| // If no expires_at is set, assume token expires after 1 hour (3600 seconds) | |
| const expiresAt = stored.expires_at || 0; | |
| if (expiresAt > now) { | |
| console.log(chalk.dim('Using stored access token')); | |
| return stored.access_token; | |
| } else { | |
| console.log(chalk.yellow('Token expired or no expiration set')); | |
| } | |
| if (stored.refresh_token) { | |
| try { | |
| // Ensure we have config (rediscover if needed) | |
| if (!this.config) { | |
| const discoveryResponse = await fetch('https://builder.egghead.io/oauth/.well-known/openid-configuration'); | |
| if (!discoveryResponse.ok) { | |
| throw new Error(`Discovery failed: ${discoveryResponse.status}`); | |
| } | |
| const serverMetadata = await discoveryResponse.json() as client.ServerMetadata; | |
| const registrationEndpoint = serverMetadata.registration_endpoint; | |
| if (!registrationEndpoint) { | |
| throw new Error('No registration endpoint found in discovery metadata'); | |
| } | |
| const registrationResponse = await fetch(registrationEndpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| grant_types: [ | |
| 'urn:ietf:params:oauth:grant-type:device_code', | |
| 'refresh_token', | |
| ], | |
| token_endpoint_auth_method: 'none', | |
| response_types: [], | |
| }), | |
| }); | |
| if (!registrationResponse.ok) { | |
| throw new Error(`Registration failed: ${registrationResponse.status}`); | |
| } | |
| const clientData = await registrationResponse.json() as client.ClientMetadata; | |
| this.config = new client.Configuration(serverMetadata, clientData.client_id, clientData); | |
| } | |
| const fresh = await client.refreshTokenGrant( | |
| this.config, | |
| stored.refresh_token, | |
| ); | |
| await this.storeTokens(fresh); | |
| return fresh.access_token; | |
| } catch { | |
| console.log(chalk.yellow('Refresh failed β reβauthenticatingβ¦')); | |
| } | |
| } | |
| return (await this.authenticate()).access_token; | |
| } | |
| /* 3οΈβ£ helpers */ | |
| private async storeTokens(t: client.TokenEndpointResponse) { | |
| // Default to 1 hour expiration if not provided | |
| const expiresIn = t.expires_in || 3600; | |
| const payload: StoredTokens = { | |
| access_token: t.access_token, | |
| refresh_token: t.refresh_token, | |
| id_token: t.id_token, | |
| token_type: t.token_type, | |
| expires_at: Math.floor(Date.now() / 1000) + expiresIn, | |
| }; | |
| const dir = join(homedir(), '.course-builder'); | |
| if (!existsSync(dir)) { | |
| console.log(chalk.dim(`Creating directory: ${dir}`)); | |
| await mkdir(dir, { recursive: true }); | |
| } | |
| console.log(chalk.dim(`Writing tokens to: ${TOKEN_FILE}`)); | |
| await writeFile(TOKEN_FILE, JSON.stringify(payload, null, 2), 'utf-8'); | |
| console.log(chalk.dim('Tokens written successfully')); | |
| } | |
| private async loadTokens(): Promise<StoredTokens | null> { | |
| if (!existsSync(TOKEN_FILE)) return null; | |
| try { return JSON.parse(await readFile(TOKEN_FILE, 'utf-8')); } | |
| catch { console.log(chalk.yellow('Corrupt token file')); return null; } | |
| } | |
| async logout() { | |
| if (existsSync(TOKEN_FILE)) { | |
| await writeFile(TOKEN_FILE, '{}', 'utf-8'); | |
| console.log(chalk.green('β Logged out')); | |
| } else { | |
| console.log(chalk.dim('No active session')); | |
| } | |
| } | |
| } | |
| type Post = { id: string; fields?: { title: string } }; | |
| /** | |
| * zod schema for frontβmatter validation | |
| */ | |
| const LessonSchema = z.object({ | |
| title: z.string().min(1, 'Title is required'), | |
| video: z.string().min(1, 'Video path is required'), | |
| tags: z.array(z.string()).optional().default([]), | |
| state: z.enum(['draft', 'published']).optional().default('draft'), | |
| description: z.string().optional(), | |
| }); | |
| type LessonConfig = z.infer<typeof LessonSchema>; | |
| /* βββ Logger helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function logDebug(message: string, data?: unknown): void { | |
| if (!DEBUG_ENABLED) return; | |
| console.log(chalk.gray(`[DEBUG] ${message}`)); | |
| if (data) console.log(chalk.gray(JSON.stringify(data, null, 2))); | |
| } | |
| class CourseBuilderClient { | |
| private readonly http: AxiosInstance; | |
| private readonly oauth = new OAuthClient(); | |
| constructor(baseURL = 'https://builder.egghead.io') { | |
| this.http = axios.create({ | |
| baseURL, | |
| timeout: 30_000, | |
| headers: { 'Content-Type': 'application/json' }, | |
| validateStatus: () => true, // we handle error codes manually | |
| }); | |
| /** Inject OAuth bearer & debug logging on every request. */ | |
| this.http.interceptors.request.use(async (cfg) => { | |
| const token = await this.oauth.getValidToken(); | |
| cfg.headers.Authorization = `Bearer ${token}`; | |
| logDebug(`Request β ${cfg.method?.toUpperCase()} ${cfg.url}`, { | |
| headers: cfg.headers, | |
| params: cfg.params, | |
| data: cfg.data, | |
| }); | |
| return cfg; | |
| }); | |
| /** Responseβside debug logging only. */ | |
| this.http.interceptors.response.use((res) => { | |
| logDebug(`Response β ${res.status} ${res.config.url}`, { | |
| headers: res.headers, | |
| data: res.data, | |
| }); | |
| return res; | |
| }); | |
| } | |
| /* βββ Posts βββ */ | |
| async createPost(title: string): Promise<Post> { | |
| logDebug('Creating post', { title }); | |
| const res = await this.http.post('/api/posts', { title }); | |
| // Success path | |
| if (res.status < 400 && res.data?.id) return res.data; | |
| // API sometimes returns 500β―+β―payload containing the postβcatch that. | |
| const maybePost = this.extractPostFromWeirdResponse(res.data, title); | |
| if (maybePost) { | |
| console.log(chalk.yellow('β οΈ API error code, but post exists. Continuingβ¦')); | |
| return maybePost; | |
| } | |
| // Final hailβmary: list all posts and match by title. | |
| if (res.status === 500) { | |
| console.log(chalk.yellow('500 receivedβdoubleβchecking if post was still createdβ¦')); | |
| const list = await this.http.get('/api/posts'); | |
| const found = (list.data as Post[]).find((p) => p.fields?.title === title); | |
| if (found) { | |
| console.log(chalk.green('β Found the new post despite the 500.')); | |
| return found; | |
| } | |
| } | |
| throw new Error(`Failed to create post (${res.status}Β ${res.statusText})`); | |
| } | |
| async updatePost(id: string, fields: Record<string, unknown>): Promise<void> { | |
| logDebug('Updating post', { id, fields }); | |
| const res = await this.http.put('/api/posts', { id, fields }); | |
| if (res.status >= 400 && res.status !== 500) { | |
| throw new Error(`Failed to update post (${res.status}Β ${res.statusText})`); | |
| } | |
| } | |
| /* βββ Upload handling βββ */ | |
| async getPresignedUrl( | |
| filename: string, | |
| contentType = 'video/mp4' | |
| ): Promise<{ signedUrl: string; publicUrl: string; objectName: string }> { | |
| logDebug('Requesting presigned URL', { filename, contentType }); | |
| const res = await this.http.get('/api/uploads/signed-url', { | |
| params: { objectName: filename, contentType }, | |
| }); | |
| if (res.status >= 400) { | |
| throw new Error(`Failed presign (${res.status}Β ${res.statusText})`); | |
| } | |
| return res.data; | |
| } | |
| async createVideoResource( | |
| publicUrl: string, | |
| objectName: string, | |
| postId: string | |
| ): Promise<{ id: string }> { | |
| logDebug('Creating video resource', { publicUrl, objectName, postId }); | |
| const res = await this.http.post('/api/uploads/new', { | |
| file: { url: publicUrl, name: objectName }, | |
| metadata: { parentResourceId: postId }, | |
| }); | |
| if (res.status >= 400) { | |
| throw new Error(`Failed newβvideo (${res.status}Β ${res.statusText})`); | |
| } | |
| return res.data; | |
| } | |
| async getVideoStatus(videoId: string): Promise<{ state: string; muxPlaybackId?: string }> { | |
| logDebug('Fetching video status', { videoId }); | |
| const res = await this.http.get(`/api/videos/${videoId}`); | |
| if (res.status >= 400) { | |
| throw new Error(`Failed status (${res.status}Β ${res.statusText})`); | |
| } | |
| return res.data; | |
| } | |
| async getTags(): Promise<Array<{ id: string, type: 'topic', fields: { name: string, slug: string } }>> { | |
| logDebug('Fetching tags'); | |
| const res = await this.http.get('/api/tags'); | |
| if (res.status >= 400) { | |
| throw new Error(`Failed to fetch tags (${res.status})`); | |
| } | |
| return res.data; | |
| } | |
| async updatePostTags(postId: string, tags: Array<{ id: string, type: string, fields: any }>): Promise<void> { | |
| logDebug('Updating post tags', { postId, tagCount: tags.length }); | |
| const res = await this.http.post(`/api/tags/${postId}`, tags); | |
| if (res.status >= 400) { | |
| throw new Error(`Failed to update tags (${res.status})`); | |
| } | |
| } | |
| /* βββ Helpers βββ */ | |
| private extractPostFromWeirdResponse(payload: unknown, title: string): Post | null { | |
| const data: any = payload; | |
| if (data?.id && data.fields) return data; | |
| if (data?.data?.id) return data.data; | |
| if (Array.isArray(data?.posts)) return data.posts.find((p: any) => p.fields?.title === title) || null; | |
| if (data?.result?.id) return data.result; | |
| return null; | |
| } | |
| } | |
| class VideoUploader { | |
| constructor(private readonly api: CourseBuilderClient) { } | |
| async upload(videoPath: string, postId: string): Promise<void> { | |
| const { size } = await stat(videoPath); | |
| const filename = this.sanitiseFilename(basename(videoPath)); | |
| console.log(chalk.blue(`\nπΉ Uploading video: ${filename}`)); | |
| console.log(chalk.dim(` Size: ${this.formatBytes(size)}`)); | |
| const spinner = ora('Getting presigned URLβ¦').start(); | |
| const { signedUrl, publicUrl, objectName } = await this.api.getPresignedUrl(filename); | |
| spinner.text = 'Uploading to S3β¦'; | |
| logDebug('S3 PUT β', { signedUrl, bytes: size }); | |
| const buf = await readFile(videoPath); | |
| const res = await fetch(signedUrl, { | |
| method: 'PUT', | |
| body: buf, | |
| headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': size.toString() }, | |
| }); | |
| if (!res.ok) { | |
| throw new Error(`S3 upload failed (${res.status}Β ${res.statusText}): ${await res.text()}`); | |
| } | |
| logDebug('S3 PUT β complete'); | |
| spinner.text = 'Creating video resourceβ¦'; | |
| await this.api.createVideoResource(publicUrl, objectName, postId); | |
| spinner.succeed('Video uploaded π'); | |
| // Video processing happens in the background - no need to poll | |
| } | |
| /* βββ Private helpers βββ */ | |
| private sanitiseFilename(original: string): string { | |
| const timestamp = Date.now(); | |
| const clean = original.toLowerCase().replace(/[^a-z0-9.-]/g, '-'); | |
| const parts = clean.split('.'); | |
| const ext = parts.pop(); | |
| return `${parts.join('.')}-${timestamp}.${ext}`; | |
| } | |
| private formatBytes(bytes: number): string { | |
| return `${(bytes / 1_048_576).toFixed(2)}Β MB`; | |
| } | |
| } | |
| async function parseAndValidateMarkdown(markdownPath: string): Promise<{ | |
| config: LessonConfig; | |
| content: string; | |
| videoAbsolutePath: string; | |
| }> { | |
| console.log(chalk.blue('π Reading lesson markdownβ¦')); | |
| const fileText = await readFile(markdownPath, 'utf8'); | |
| const { data, content } = matter(fileText); | |
| logDebug('Frontβmatter', data); | |
| logDebug('Content chars', content.length); | |
| const config = LessonSchema.parse(data); | |
| const absVideoPath = resolve(dirname(markdownPath), config.video); | |
| if (!existsSync(absVideoPath)) { | |
| throw new Error(`Video file not found β ${absVideoPath}`); | |
| } | |
| return { config, content, videoAbsolutePath: absVideoPath }; | |
| } | |
| /* βββ Main orchestrator ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| async function publishLesson(markdownPath: string): Promise<void> { | |
| const { config, content, videoAbsolutePath } = await parseAndValidateMarkdown(markdownPath); | |
| console.log(chalk.green('\nπ Publishing lesson to CourseΒ Builder')); | |
| console.log(` Title: ${chalk.white(config.title)}`); | |
| console.log(` State: ${chalk.white(config.state)}`); | |
| console.log(` Tags : ${chalk.white(config.tags.join(', ') || 'none')}`); | |
| const api = new CourseBuilderClient(); | |
| const videoUploader = new VideoUploader(api); | |
| /* 1. Create post */ | |
| const postSpinner = ora('Creating postβ¦').start(); | |
| let post: Post; | |
| try { | |
| post = await api.createPost(config.title); | |
| postSpinner.succeed(`Post created (IDΒ ${post.id})`); | |
| } catch (err: any) { | |
| postSpinner.fail('Post creation failed'); | |
| throw err; | |
| } | |
| /* 2. Update post body/meta */ | |
| try { | |
| const updSpin = ora('Updating post bodyβ¦').start(); | |
| await api.updatePost(post.id, { | |
| title: config.title, | |
| body: content.trim(), | |
| state: config.state, | |
| description: config.description, | |
| }); | |
| updSpin.succeed('Post updated βοΈ'); | |
| } catch (err: any) { | |
| console.error(chalk.red('β οΈ Post updated failed, but continuing to video upload.')); | |
| logDebug('Update error', err); | |
| } | |
| /* 2.5 Handle tags */ | |
| if (config.tags.length > 0) { | |
| try { | |
| const tagSpinner = ora('Fetching available tags...').start(); | |
| const availableTags = await api.getTags(); | |
| const matchedTags = []; | |
| for (const tagName of config.tags) { | |
| const tag = availableTags.find(t => | |
| t.fields.name.toLowerCase() === tagName.toLowerCase() || | |
| t.fields.slug.toLowerCase() === tagName.toLowerCase() | |
| ); | |
| if (tag) matchedTags.push(tag); | |
| } | |
| if (matchedTags.length > 0) { | |
| tagSpinner.text = 'Assigning tags...'; | |
| await api.updatePostTags(post.id, matchedTags); | |
| tagSpinner.succeed(`Assigned ${matchedTags.length} tags`); | |
| } else { | |
| tagSpinner.warn('No matching tags found'); | |
| } | |
| } catch (err) { | |
| console.error(chalk.yellow('β οΈ Tag assignment failed')); | |
| } | |
| } | |
| /* 3. Upload video */ | |
| await videoUploader.upload(videoAbsolutePath, post.id); | |
| /* π Done */ | |
| console.log(chalk.green.bold('\nβ Lesson published successfully!')); | |
| console.log(` Post: https://builder.egghead.io/posts/${post.id}\n`); | |
| } | |
| /* βββ CLI Entrypoint βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| const summaryPath = await path({ | |
| startPath: home("Documents", "screenflow", "claude-code", "print"), | |
| }) | |
| await publishLesson(summaryPath) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment