Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save johnlindquist/7222f8967e7b94a4c6a722923f1f4b41 to your computer and use it in GitHub Desktop.
Save johnlindquist/7222f8967e7b94a4c6a722923f1f4b41 to your computer and use it in GitHub Desktop.
// 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