Kent C. Dodds

Kent C. Dodds

// Menu: Gather Guest List
// Description: Handle the Guest List for Gather
// Author: Kent C. Dodds
// Twitter: @kentcdodds
import '@johnlindquist/kit'
import {z} from 'zod'
const GuestObjectSchema = z.object({
name: z.string().optional(),
affiliation: z.string().optional(),
role: z.string().optional(),
})
const GuestsSchema = z.record(z.string().email(), GuestObjectSchema)
const GATHER_API_KEY = await env('GATHER_API_KEY', async () => {
return await arg(
{
placeholder: 'GATHER_API_KEY',
ignoreBlur: true,
},
() =>
md(`
# Get a Gather API Key
[app.gather.town/apikeys](https://app.gather.town/apikeys)
`),
)
})
const GATHER_SPACE_ID = await env('GATHER_SPACE_ID', async () => {
return await arg(
{
placeholder: 'GATHER_SPACE_ID',
ignoreBlur: true,
},
() =>
md(`
# Specify the Gather Space ID
It's everything after "app/" in this URL with "/" replaced by "\\":
https://app.gather.town/app/BL0B93FK23T/example
`),
)
})
async function go() {
const params = new URLSearchParams({
apiKey: GATHER_API_KEY,
spaceId: GATHER_SPACE_ID,
})
const rawGuests = await fetch(
`https://gather.town/api/getEmailGuestlist?${params}`,
).then(r => r.json())
const guests = GuestsSchema.parse(rawGuests)
const choices = [
{name: '➕ Add a guest', value: {type: 'add-guest'}},
...Object.entries(guests).map(([email, {name, affiliation, role}]) => ({
name: `${email} (${name?.trim() || 'Unnamed'}, ${
affiliation?.trim() || 'Unaffiliated'
}, ${role?.trim() || 'No role'})`,
value: {type: 'modify-guest', email},
})),
]
const rawSelection = await arg(
{placeholder: 'Which guest would you like to modify?'},
choices,
)
const SelectionSchema = z.union([
z.object({
type: z.literal('add-guest'),
}),
z.object({
type: z.literal('modify-guest'),
email: z.string(),
}),
])
const selection = SelectionSchema.parse(rawSelection)
switch (selection.type) {
case 'add-guest': {
await addGuest()
return go()
}
case 'modify-guest': {
await modifyGuest(selection.email, guests)
return go()
}
}
}
async function addGuest() {
const email = z
.string()
.email()
.parse(await arg({placeholder: `What's the guests' email?`}))
const body = {
apiKey: GATHER_API_KEY,
spaceId: GATHER_SPACE_ID,
guestlist: {[email]: {}},
}
const updateResponse = await fetch(
'https://api.gather.town/api/setEmailGuestlist',
{
method: 'POST',
body: JSON.stringify(body),
headers: {
'content-type': 'application/json',
},
},
)
const update = await updateResponse.json()
console.log('Guest Update: ', update[email])
}
async function modifyGuest(
email: string,
guests: z.infer<typeof GuestsSchema>,
) {
const guest = guests[email]
const action = await arg({placeholder: `What would you like to do?`}, [
{name: 'Remove Guest', value: 'remove'},
{name: `Change Guest Email (${email})`, value: 'change-email'},
{
name: `Change Guest Name (${guest.name?.trim() || 'Unnamed'})`,
value: 'change-name',
},
{
name: `Change Guest Affiliation (${
guest.affiliation?.trim() || 'Unaffiliated'
})`,
value: 'change-affiliation',
},
{
name: `Change Guest Role (${guest.role?.trim() || 'No role'})`,
value: 'change-role',
},
{
name: `Cancel`,
value: 'cancel',
},
])
switch (action) {
case 'remove': {
delete guests[email]
break
}
case 'change-email': {
const newEmail = z
.string()
.email()
.parse(await arg({placeholder: 'New Email'}))
guests[newEmail] = guests[email]
delete guests[email]
email = newEmail
break
}
case 'change-name': {
const newName = await arg({placeholder: 'New Name'})
if (newName) {
guests[email].name = newName
} else {
delete guests[email].name
}
break
}
case 'change-affiliation': {
const newAffiliation = await arg({
placeholder: 'New Affiliation',
})
if (newAffiliation) {
guests[email].affiliation = newAffiliation
} else {
delete guests[email].affiliation
}
break
}
case 'change-role': {
const newRole = await arg({placeholder: 'New Role'})
if (newRole) {
guests[email].role = newRole
} else {
delete guests[email].role
}
break
}
case 'cancel': {
return go()
}
}
const body = {
apiKey: GATHER_API_KEY,
spaceId: GATHER_SPACE_ID,
guestlist: guests,
overwrite: true,
}
const updateResponse = await fetch(
'https://api.gather.town/api/setEmailGuestlist',
{
method: 'POST',
body: JSON.stringify(body),
headers: {
'content-type': 'application/json',
},
},
)
const update = await updateResponse.json()
console.log('Guest Update: ', update[email])
}
go()
// Menu: Cloudinary upload
// Description: Upload an image to cloudinary
// Shortcut: command option control c
// Author: Kent C. Dodds
// Twitter: @kentcdodds
import path from 'path'
const cloudinaryCloudName = await env('CLOUDINARY_CLOUD_NAME')
const cloudinaryKey = await env('CLOUDINARY_API_KEY')
const cloudinarySecret = await env('CLOUDINARY_API_SECRET')
const cloudiaryConsoleId = await env('CLOUDINARY_CONSOLE_ID')
await npm('cloudinary')
import cloudinary from 'cloudinary'
const cacheDb = await db('cloudinary-cache', {lastChoice: '', folders: {}})
await cacheDb.read()
cloudinary.config({
cloud_name: cloudinaryCloudName,
api_key: cloudinaryKey,
api_secret: cloudinarySecret,
secure: true,
})
const actions = {
CREATE_NEW: 'creating new folder',
REFRESH_CACHE: 'refreshing cache',
OPEN_DIR: 'opening directory',
}
let chosenDirectory = await cacheDb.data.lastChoice
let lastSelection
while (true) {
// if the last action was to create a new directory then we know the chosen
// directory is new and has no folders otherwise we have to wait a few seconds
// for the API to be prepared for us to make a request for the contents.
const directories =
lastSelection === actions.CREATE_NEW
? []
: await getFolders(chosenDirectory)
lastSelection = await arg(
`Select directory in ${chosenDirectory || '/'}`,
[
{name: '.', value: '.', description: '✅ Choose this directory'},
!chosenDirectory
? null
: {name: '..', value: '..', description: '⤴️ Go up a directory'},
...directories.map(folder => ({
name: folder.name,
value: folder.path,
description: '⤵️ Select directory',
})),
{
name: 'Open directory',
value: actions.OPEN_DIR,
description: '🌐 Open this directory in the browser',
},
{
name: 'Refresh cache',
value: actions.REFRESH_CACHE,
description: '🔄 Refresh the cache for this directory',
},
{
name: 'Create new directory',
value: actions.CREATE_NEW,
description: '➕ Create a new directory here',
},
].filter(Boolean),
)
if (lastSelection === '..') {
chosenDirectory = chosenDirectory.split('/').slice(0, -1).join('/')
} else if (lastSelection === '.') {
break
} else if (lastSelection === actions.CREATE_NEW) {
const newFolderName = await arg(`What's the new folder name?`)
const newDirectory = `${chosenDirectory}/${newFolderName}`
await cloudinary.v2.api.create_folder(newDirectory)
delete cacheDb.data.folders[chosenDirectory]
chosenDirectory = newDirectory
} else if (lastSelection === actions.REFRESH_CACHE) {
delete cacheDb.data.folders[chosenDirectory]
} else if (lastSelection === actions.OPEN_DIR) {
await openFolder(chosenDirectory)
} else {
chosenDirectory = lastSelection
}
}
cacheDb.data.lastChoice = chosenDirectory
await cacheDb.write()
const images = await drop('Drop the image(s) you want to upload')
let renameSome = true
if (images.length > 1) {
const renameChoice = await arg('Do you want to rename any of these?', [
'yes',
'no',
])
renameSome = renameChoice === 'yes'
}
for (const image of images) {
const defaultName = path.parse(image.path).name
const name = renameSome
? (await arg({
placeholder: `Name of this image?`,
hint: `Default is: "${defaultName}"`,
})) || defaultName
: defaultName
setPlaceholder(`Uploading ${name}`)
const uploadedImage = await cloudinary.v2.uploader.upload(image.path, {
public_id: name,
overwrite: false,
folder: chosenDirectory,
})
// If you have multiple files then this isn't really useful unless you have
// clipbloard history (which I recommend you get!)
await copy(uploadedImage.secure_url)
}
await openFolder(chosenDirectory)
function openFolder(folder) {
const encodedFolder = encodeURIComponent(folder)
console.log('opening')
return exec(
`open "https://cloudinary.com/console/${cloudiaryConsoleId}/media_library/folders/${encodedFolder}"`,
)
}
async function getFolders(directory) {
const cachedDirectories = cacheDb.data.folders[directory]
if (cachedDirectories) {
return cachedDirectories
}
try {
const {folders: directories} = !directory
? await cloudinary.v2.api.root_folders()
: await cloudinary.v2.api.sub_folders(directory)
cacheDb.data.folders[directory] = directories
await cacheDb.write()
return directories
} catch (error) {
console.error('error with the directory')
return []
}
}
// Menu: ConvertKit > Lookup
// Description: Query convertkit
// Author: Kent C. Dodds
// Twitter: @kentcdodds
const CONVERT_KIT_API_SECRET = await env('CONVERT_KIT_API_SECRET')
const CONVERT_KIT_API_KEY = await env('CONVERT_KIT_API_KEY')
const query = await arg('query')
let url
if (query.includes('@')) {
const sub = await getConvertKitSubscriber(query)
if (sub?.id) {
url = `https://app.convertkit.com/subscribers/${sub.id}`
}
}
if (!url) {
url = `https://app.convertkit.com/subscribers?utf8=%E2%9C%93&q=${query}&status=all`
}
exec(`open "${url}"`)
async function getConvertKitSubscriber(email) {
const url = new URL('https://api.convertkit.com/v3/subscribers')
url.searchParams.set('api_secret', CONVERT_KIT_API_SECRET)
url.searchParams.set('email_address', email)
const resp = await fetch(url.toString())
const json = await resp.json()
const {subscribers: [subscriber] = []} = json
return subscriber
}
// Menu: Daily Story
// Description: Write a quick story
// Author: Kent C. Dodds
// Shortcut: command option control o
// Twitter: @kentcdodds
const dateFns = await npm('date-fns')
const filenamify = await npm('filenamify')
const prettier = await npm('prettier')
const storyDir = await env(
'DAILY_STORY_DIRECTORY',
`Where do you want daily stories to be saved?`,
)
const story = await textarea({placeholder: 'Write your story here'})
const today = dateFns.format(new Date(), 'yyyy-MM-dd')
const date = await arg({
input: today,
hint: 'When did this happen?',
})
const title = await arg({
placeholder: 'What do you want to call this story?',
hint: 'Title',
})
const md = `---
title: ${title}
date: ${date}
written: ${today}
---
${story}
`
// prettify the markdown
const prettyMd = await prettier.format(md, {
parser: 'markdown',
arrowParens: 'avoid',
bracketSpacing: false,
embeddedLanguageFormatting: 'auto',
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxBracketSameLine: false,
jsxSingleQuote: false,
printWidth: 80,
proseWrap: 'always',
quoteProps: 'as-needed',
requirePragma: false,
semi: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
vueIndentScriptAndStyle: false,
})
const filename = filenamify(
`${date}-${title.toLowerCase().replace(/ /g, '-')}.md`,
{replacement: '-'},
)
await writeFile(path.join(storyDir, filename), prettyMd)
// Menu: New Post
// Description: Create a new blog post
// Author: Kent C. Dodds
// Shortcut: command option control p
// Twitter: @kentcdodds
const dateFns = await npm('date-fns')
const prettier = await npm('prettier')
const YAML = await npm('yaml')
const slugify = await npm('@sindresorhus/slugify')
const {format: formatDate} = await npm('date-fns')
const makeMetascraper = await npm('metascraper')
const {$filter, toRule} = await npm('@metascraper/helpers')
const unsplashTitleToAlt = toRule(str => str.replace(/ photo – .*$/, ''))
const unsplashOGTitleToAuthor = toRule(str =>
str.replace(/Photo by (.*?) on Unsplash/, '$1'),
)
const unsplashImageToPhotoId = toRule(str =>
new URL(str).pathname.replace('/', ''),
)
const metascraper = makeMetascraper([
{
unsplashPhotoId: [
unsplashImageToPhotoId($ =>
$('meta[property="og:image"]').attr('content'),
),
],
},
{
author: [
unsplashOGTitleToAuthor($ =>
$('meta[property="og:title"]').attr('content'),
),
],
},
{alt: [unsplashTitleToAlt($ => $('title').text())]},
])
async function getMetadata(url) {
const html = await fetch(url).then(res => res.text())
return metascraper({html, url})
}
const blogDir = await env(
'KCD_BLOG_CONTENT_DIR',
`What's the path to the blog content directory on this machine?`,
)
const title = await arg({
placeholder: `What's the title of this post?`,
hint: 'Title',
ignoreBlur: true,
})
const description = await arg({
placeholder: `What's the description of this post?`,
hint: 'Description',
input: 'TODO: add a description',
ignoreBlur: true,
})
const categories = (
await arg({
placeholder: `What are the categories of this post?`,
hint: 'Categories (comma separated)',
ignoreBlur: true,
})
)
.split(',')
.map(c => c.trim())
.filter(Boolean)
const keywords = (
await arg({
placeholder: `What are the keywords of this post?`,
hint: 'Keywords (comma separated)',
ignoreBlur: true,
})
)
.split(',')
.map(c => c.trim())
.filter(Boolean)
const filename = slugify(title, {decamelize: false})
await exec(`open https://unsplash.com/s/photos/${filename}`)
const unsplashPhotoInput = await arg({
placeholder: `What's the unsplash photo?`,
hint: 'Unsplash Photo',
ignoreBlur: true,
})
const unsplashPhotoUrl = unsplashPhotoInput.startsWith('http')
? unsplashPhotoInput
: `https://unsplash.com/photos/${unsplashPhotoInput}`
const metadata = await getMetadata(unsplashPhotoUrl)
const frontmatter = YAML.stringify({
title,
date: dateFns.format(new Date(), 'yyyy-MM-dd'),
description,
categories,
meta: {keywords},
bannerCloudinaryId: `unsplash/${metadata.unsplashPhotoId}`,
bannerAlt: metadata.alt,
bannerCredit: `Photo by [${metadata.author}](${unsplashPhotoUrl})`,
})
const md = `---
${frontmatter}
---
Be excellent to each other.
`
// prettify the markdown
const prettyMd = await prettier.format(md, {
parser: 'markdown',
arrowParens: 'avoid',
bracketSpacing: false,
embeddedLanguageFormatting: 'auto',
htmlWhitespaceSensitivity: 'css',
insertPragma: false,
jsxBracketSameLine: false,
jsxSingleQuote: false,
printWidth: 80,
proseWrap: 'always',
quoteProps: 'as-needed',
requirePragma: false,
semi: false,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: false,
vueIndentScriptAndStyle: false,
})
const newFile = path.join(blogDir, `${filename}.mdx`)
await writeFile(newFile, prettyMd)
await edit(newFile)
// Menu: Open Project
// Description: Opens a project in code
// Shortcut: cmd shift .
import path from 'path'
import fs from 'fs'
import os from 'os'
const isDirectory = async filePath => {
try {
const stat = await fs.promises.stat(filePath)
return stat.isDirectory()
} catch (e) {
return false
}
}
const isFile = async filePath => {
try {
const stat = await fs.promises.stat(filePath)
return stat.isFile()
} catch (e) {
return false
}
}
async function getProjects(parentDir) {
const codeDir = (await ls(parentDir)).stdout.split('\n').filter(Boolean)
const choices = []
for (const dir of codeDir) {
let fullPath = dir
if (!path.isAbsolute(dir)) {
fullPath = path.join(parentDir, dir)
}
if (fullPath.includes('/node_modules/')) continue
if (fullPath.includes('/build/')) continue
if (fullPath.includes('/dist/')) continue
if (fullPath.includes('/coverage/')) continue
const pkgjson = path.join(fullPath, 'package.json')
if (await isFile(pkgjson)) {
choices.push({
name: dir,
value: fullPath,
description: fullPath,
})
} else if (await isDirectory(fullPath)) {
choices.push(...(await getProjects(fullPath)))
}
}
return choices
}
const choice = await arg('Which project?', async () => {
const choices = [
...(await getProjects(path.join(os.homedir(), 'code'))),
...(await getProjects(path.join(os.homedir(), 'Desktop'))),
]
return choices
})
await edit(choice)
// Menu: Shorten
// Description: Shorten a given URL with a given short name via netlify-shortener
// Shortcut: command option control s
// Author: Kent C. Dodds
// Twitter: @kentcdodds
const dir = await env(
'SHORTEN_REPO_DIRECTORY',
'Where is your netlify-shortener repo directory?',
)
const longURL = await arg(`What's the full URL?`)
// TODO: figure out how to make this optional
const shortName = await arg(`What's the short name?`)
const netlifyShortenerPath = path.join(
dir,
'node_modules/netlify-shortener/dist/index.js',
)
const {baseUrl} = JSON.parse(await readFile(path.join(dir, 'package.json')))
setPlaceholder(`Creating redirect: ${baseUrl}/${shortName} -> ${longURL}`)
const result = exec(
`node "${netlifyShortenerPath}" "${longURL}" "${shortName}"`,
)
const {stderr, stdout} = result
if (result.code === 0) {
const lastLine = stdout.split('\n').filter(Boolean).slice(-1)[0]
notify({
title: '✅ Short URL created',
message: lastLine,
})
} else {
const getErr = str => str.match(/Error: (.+)\n/)?.[1]
const error = getErr(stderr) ?? getErr(stdout) ?? 'Unknown error'
console.error({stderr, stdout})
notify({
title: '❌ Short URL not created',
message: error,
})
}
// Menu: Twimage Download
// Description: Download twitter images and set their exif info based on the tweet metadata
// Shortcut: command option control t
// Author: Kent C. Dodds
// Twitter: @kentcdodds
import fs from 'fs'
import {fileURLToPath, URL} from 'url'
const exiftool = await npm('node-exiftool')
const exiftoolBin = await npm('dist-exiftool')
const fsExtra = await npm('fs-extra')
const baseOut = home('Pictures/twimages')
const token = await env('TWITTER_BEARER_TOKEN')
const twitterUrl = await arg('Twitter URL')
console.log(`Starting with ${twitterUrl}`)
const tweetId = new URL(twitterUrl).pathname.split('/').slice(-1)[0]
const response = await get(
`https://api.twitter.com/1.1/statuses/show/${tweetId}.json?include_entities=true`,
{
headers: {
authorization: `Bearer ${token}`,
},
},
)
const tweet = response.data
console.log({tweet})
const {
geo,
id,
text,
created_at,
extended_entities: {media: medias} = {
media: [
{
type: 'photo',
media_url_https: await arg({
ignoreBlur: true,
input: `Can't find media. What's the URL for the media?`,
hint: `Media URL`,
}),
},
],
},
} = tweet
const [latitude, longitude] = geo?.coordinates || []
const ep = new exiftool.ExiftoolProcess(exiftoolBin)
await ep.open()
for (const media of medias) {
let url
if (media.type === 'photo') {
url = media.media_url_https
} else if (media.type === 'video') {
let best = {bitrate: 0}
for (const variant of media.video_info.variants) {
if (variant.bitrate > best.bitrate) best = variant
}
url = best.url
} else {
throw new Error(`Unknown media type for ${twitterUrl}: ${media.type}`)
}
if (!url) throw new Error(`Huh... no media url found for ${twitterUrl}`)
const formattedDate = formatDate(created_at)
const colonDate = formattedDate.replace(/-/g, ':')
const formattedTimestamp = formatTimestamp(created_at)
const filename = new URL(url).pathname.split('/').slice(-1)[0]
const filepath = path.join(
baseOut,
formattedDate.split('-').slice(0, 2).join('-'),
/\..+$/.test(filename) ? filename : `${filename}.jpg`,
)
await download(url, filepath)
await ep.writeMetadata(
filepath,
{
ImageDescription: `${text}${twitterUrl}`,
Keywords: 'photos from tweets',
DateTimeOriginal: formattedTimestamp,
FileModifyDate: formattedTimestamp,
ModifyDate: formattedTimestamp,
CreateDate: formattedTimestamp,
...(geo
? {
GPSLatitudeRef: latitude > 0 ? 'North' : 'South',
GPSLongitudeRef: longitude > 0 ? 'East' : 'West',
GPSLatitude: latitude,
GPSLongitude: longitude,
GPSDateStamp: colonDate,
GPSDateTime: formattedTimestamp,
}
: null),
},
['overwrite_original'],
)
}
await ep.close()
notify(`All done with ${twitterUrl}`)
function formatDate(t) {
const d = new Date(t)
return `${d.getFullYear()}-${padZero(d.getMonth() + 1)}-${padZero(
d.getDate(),
)}`
}
function formatTimestamp(t) {
const d = new Date(t)
const formattedDate = formatDate(t)
return `${formatDate(t)} ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`
}
function padZero(n) {
return String(n).padStart(2, '0')
}
async function getGeoCoords(placeId) {
const response = await get(
`https://api.twitter.com/1.1/geo/id/${placeId}.json`,
{
headers: {
authorization: `Bearer ${token}`,
},
},
)
const [longitude, latitude] = response.data.centroid
return {latitude, longitude}
}
async function download(url, out) {
console.log(`downloading ${url} to ${out}`)
await fsExtra.ensureDir(path.dirname(out))
const writer = fs.createWriteStream(out)
const response = await get(url, {responseType: 'stream'})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => resolve(out))
writer.on('error', reject)
})
}
// Menu: Update EpicReact deps
// Description: Update all the dependencies in the epicreact workshop repos
const repos = [
'advanced-react-hooks',
'advanced-react-patterns',
'bookshelf',
'react-fundamentals',
'react-hooks',
'react-performance',
'react-suspense',
'testing-react-apps',
]
const script = `git add -A && git stash && git checkout main && git pull && ./scripts/update-deps && git commit -am "update all deps" --no-verify && git push && git status`
for (const repo of repos) {
const scriptString = JSON.stringify(
`cd ~/code/epic-react/${repo} && ${script}`,
)
exec(
`osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script ${scriptString}'`,
)
}