feat(cli): add site cli to run scripts + handle media

This commit is contained in:
Cory Dransfeldt 2025-06-05 18:48:20 -07:00
parent 5055816f68
commit d08787f5aa
No known key found for this signature in database
12 changed files with 1615 additions and 26 deletions

23
cli/bin/index.js Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env node
import { program } from 'commander';
import chalk from 'chalk';
import figlet from 'figlet';
import { initConfig, loadConfig } from '../lib/config.js';
import { downloadAsset } from '../lib/download.js';
import { runRootScript } from '../lib/runScript.js';
program.name('coryd').description('🪄 Run commands, download things and have fun.').version('1.0.0');
program.command('init').description('Initialize CLI and populate required config.').action(initConfig);
program.command('run [script]').description('Run site scripts and commands.').action(runRootScript);
program.command('download').description('Download, name and store image assets.').action(downloadAsset);
if (process.argv.length <= 2) {
const ascii = figlet.textSync('coryd.dev', { horizontalLayout: 'default' });
console.log(chalk.hex('#3364ff')(ascii));
console.log();
program.outputHelp();
process.exit(0);
}
program.parse(process.argv);

101
cli/lib/config.js Normal file
View file

@ -0,0 +1,101 @@
import fs from 'fs-extra';
import path from 'path';
import inquirer from 'inquirer';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = path.resolve(__dirname, '..', '.cache');
const CONFIG_PATH = path.join(CACHE_DIR, 'config.json');
const MEDIA_TYPES = ['movie', 'show'];
const ASSET_TYPES = ['poster', 'backdrop'];
export const initConfig = async () => {
const config = {};
const { storageDir } = await inquirer.prompt([{
name: 'storageDir',
message: 'Where is your storage root directory?',
validate: fs.pathExists
}]);
config.storageDir = storageDir;
const { customize } = await inquirer.prompt([{
type: 'confirm',
name: 'customize',
message: 'Do you want to customize default media paths?',
default: false
}]);
config.mediaPaths = {};
for (const mediaType of MEDIA_TYPES) {
config.mediaPaths[mediaType] = {};
for (const assetType of ASSET_TYPES) {
const mediaFolder = `${mediaType}s`;
const assetFolder = assetType === 'poster' ? '' : `/${assetType}s`;
const defaultPath = `Media assets/${mediaFolder}${assetFolder}`.replace(/\/$/, '');
let subpath = defaultPath;
if (customize) {
const response = await inquirer.prompt([{
name: 'subpath',
message: `Subpath for ${mediaType}/${assetType} (relative to storage root):`,
default: defaultPath
}]);
subpath = response.subpath;
}
config.mediaPaths[mediaType][assetType] = subpath;
}
}
config.artistPath = customize
? (
await inquirer.prompt([
{
name: 'artistPath',
message: 'Subpath for artist images (relative to storage root):',
default: 'Media assets/artists'
}
])
).artistPath
: 'Media assets/artists';
config.albumPath = customize
? (
await inquirer.prompt([
{
name: 'albumPath',
message: 'Subpath for album images (relative to storage root):',
default: 'Media assets/albums'
}
])
).albumPath
: 'Media assets/albums';
config.bookPath = customize
? (
await inquirer.prompt([
{
name: 'bookPath',
message: 'Subpath for book images (relative to storage root):',
default: 'Media assets/books'
}
])
).bookPath
: 'Media assets/books';
await fs.ensureDir(CACHE_DIR);
await fs.writeJson(CONFIG_PATH, config, { spaces: 2 });
console.log(`✅ Config saved to ${CONFIG_PATH}`);
}
export const loadConfig = async () => {
if (!await fs.pathExists(CONFIG_PATH)) {
console.error('❌ Config not found. Run `coryd init` first.');
process.exit(1);
}
return await fs.readJson(CONFIG_PATH);
}

182
cli/lib/download.js Normal file
View file

@ -0,0 +1,182 @@
import fs from 'fs-extra';
import path from 'path';
import https from 'https';
import inquirer from 'inquirer';
import { fileURLToPath } from 'url';
import { loadConfig } from './config.js';
import { sanitizeMediaString } from './sanitize.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const downloadImage = (url, dest) => new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
https.get(url, response => {
if (response.statusCode !== 200) return reject(new Error(`Failed to download. Status: ${response.statusCode}`));
response.pipe(file);
file.on('finish', () => file.close(resolve));
}).on('error', reject);
});
const isValidTMDBUrl = (val) =>
/^https:\/\/image\.tmdb\.org\/t\/p\//.test(val) || '❌ Must be a valid TMDB image url';
export const downloadWatchingImages = async () => {
const config = await loadConfig();
const { mediaType } = await inquirer.prompt([{
type: 'list',
name: 'mediaType',
message: 'Movie or a show?',
choices: Object.keys(config.mediaPaths)
}]);
const { tmdbId } = await inquirer.prompt([{
name: 'tmdbId',
message: 'Enter the TMDB ID:'
}]);
const { posterUrl, backdropUrl } = await inquirer.prompt([{
name: 'posterUrl',
message: 'Enter the poster url:',
validate: isValidTMDBUrl
},
{
name: 'backdropUrl',
message: 'Enter the backdrop url:',
validate: isValidTMDBUrl
}]);
const types = [{ type: 'poster', url: posterUrl },
{ type: 'backdrop', url: backdropUrl }];
for (const { type, url } of types) {
const fileName = `${type}-${tmdbId}.jpg`;
const targetSubPath = config.mediaPaths[mediaType][type];
if (!targetSubPath) {
console.error(`❌ Missing path for ${mediaType}/${type}. Check your config.`);
continue;
}
const targetDir = path.join(config.storageDir, targetSubPath);
const finalPath = path.join(targetDir, fileName);
await fs.ensureDir(targetDir);
await downloadImage(url, finalPath);
console.log(`✅ Saved ${type} to: ${finalPath}`);
}
}
export const downloadArtistImage = async () => {
const config = await loadConfig();
const { artistName, imageUrl } = await inquirer.prompt([{
name: 'artistName',
message: 'Enter the artist name:'
},
{
name: 'imageUrl',
message: 'Enter the artist image url:',
validate: (val) => {
try {
new URL(val);
return true;
} catch {
return '❌ Must be a valid url.';
}
}
}]);
const sanitizedName = sanitizeMediaString(artistName);
const fileName = `${sanitizedName}.jpg`;
const targetDir = path.join(config.storageDir, 'artists');
const finalPath = path.join(targetDir, fileName);
await fs.ensureDir(targetDir);
await downloadImage(imageUrl, finalPath);
console.log(`✅ Saved artist image to: ${finalPath}`);
}
export const downloadAlbumImage = async () => {
const config = await loadConfig();
const { artistName, albumName, imageUrl } = await inquirer.prompt([{
name: 'artistName',
message: 'Enter the artist name:'
},
{
name: 'albumName',
message: 'Enter the album name:'
},
{
name: 'imageUrl',
message: 'Enter the album image url:',
validate: (val) => {
try {
new URL(val);
return true;
} catch {
return '❌ Must be a valid url.';
}
}
}]);
const artistSlug = sanitizeMediaString(artistName);
const albumSlug = sanitizeMediaString(albumName);
const fileName = `${artistSlug}-${albumSlug}.jpg`;
const targetDir = path.join(config.storageDir, config.albumPath);
const finalPath = path.join(targetDir, fileName);
await fs.ensureDir(targetDir);
await downloadImage(imageUrl, finalPath);
console.log(`✅ Saved album image to: ${finalPath}`);
}
export const downloadBookImage = async () => {
const config = await loadConfig();
const { isbn, bookTitle, imageUrl } = await inquirer.prompt([{
name: 'isbn',
message: 'Enter the ISBN (no spaces):',
validate: (val) => /^[a-zA-Z0-9-]+$/.test(val) || 'ISBN must contain only letters, numbers, or hyphens'
},
{
name: 'bookTitle',
message: 'Enter the book title:'
},
{
name: 'imageUrl',
message: 'Enter the book cover image URL:',
validate: (val) => {
try {
new URL(val);
return true;
} catch {
return 'Must be a valid URL';
}
}
}]);
const titleSlug = sanitizeMediaString(bookTitle);
const fileName = `${isbn}-${titleSlug}.jpg`;
const targetDir = path.join(config.storageDir, config.bookPath);
const finalPath = path.join(targetDir, fileName);
await fs.ensureDir(targetDir);
await downloadImage(imageUrl, finalPath);
console.log(`✅ Saved book cover to: ${finalPath}`);
}
export const downloadAsset = async () => {
const { type } = await inquirer.prompt([{
type: 'list',
name: 'type',
message: 'What type of asset are you downloading?',
choices: ['movie/show', 'artist', 'album', 'book']
}]);
if (type === 'artist') {
await downloadArtistImage();
} else if (type === 'album') {
await downloadAlbumImage();
} else if (type === 'book') {
await downloadBookImage();
} else {
await downloadWatchingImages();
}
}

42
cli/lib/runScript.js Normal file
View file

@ -0,0 +1,42 @@
import fs from 'fs-extra';
import path from 'path';
import inquirer from 'inquirer';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..', '..');
const packageJsonPath = path.join(rootDir, 'package.json');
export const runRootScript = async (scriptArg) => {
const pkg = await fs.readJson(packageJsonPath);
const scripts = pkg.scripts || {};
let script = scriptArg;
if (!script) {
const { selected } = await inquirer.prompt([{
type: 'list',
name: 'selected',
message: 'Select a script to run:',
choices: Object.keys(scripts)
}]);
script = selected;
}
if (!scripts[script]) {
console.error(`❌ Script "${script}" not found in package.json`);
process.exit(1);
}
try {
execSync(`npm run ${script}`, {
stdio: 'inherit',
cwd: rootDir
});
} catch (err) {
console.error(`❌ Failed to run script "${script}"`);
process.exit(1);
}
}

9
cli/lib/sanitize.js Normal file
View file

@ -0,0 +1,9 @@
import { transliterate } from 'transliteration';
export const sanitizeMediaString = (input) => {
const ascii = transliterate(input);
const cleaned = ascii.replace(/[^a-zA-Z0-9\s-]/g, '');
const slugified = cleaned.replace(/[\s-]+/g, '-').toLowerCase();
return slugified.replace(/^-+|-+$/g, '');
}

1170
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
cli/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "coryd",
"version": "1.0.0",
"description": "The CLI for my site that helps manage assets and other changes.",
"type": "module",
"bin": {
"coryd": "./bin/index.js"
},
"scripts": {
"start": "node ./bin/index.js"
},
"author": "Cory Dransfeldt",
"dependencies": {
"chalk": "^5.4.1",
"commander": "^11.0.0",
"figlet": "^1.8.1",
"fs-extra": "^11.2.0",
"glob": "^10.3.10",
"inquirer": "^9.2.7",
"transliteration": "^2.3.5"
}
}