From 8a12e83b13ce42fa190f2a3ecd5b22d285bee4bf Mon Sep 17 00:00:00 2001 From: Cory Dransfeldt Date: Sun, 8 Jun 2025 15:47:09 -0700 Subject: [PATCH] feat(cli): add support for running import and API jobs --- cli/bin/index.js | 4 +- cli/lib/config.js | 96 +++++++++++++++++++------ cli/lib/jobs.js | 159 ++++++++++++++++++++++++++++++++++++++++++ cli/package-lock.json | 4 +- cli/package.json | 2 +- package-lock.json | 67 +++++++----------- package.json | 2 +- scripts/setup.sh | 3 + 8 files changed, 272 insertions(+), 65 deletions(-) create mode 100644 cli/lib/jobs.js diff --git a/cli/bin/index.js b/cli/bin/index.js index b2ab859..2f6565d 100755 --- a/cli/bin/index.js +++ b/cli/bin/index.js @@ -7,13 +7,15 @@ import { initConfig, loadConfig } from '../lib/config.js'; import { downloadAsset } from '../lib/download.js'; import { runRootScript } from '../lib/runScript.js'; import { handleExitError } from '../lib/handlers.js'; +import { runJobsMenu } from '../lib/jobs.js'; process.on('unhandledRejection', (err) => handleExitError(err, 'Unhandled rejection')); process.on('uncaughtException', (err) => handleExitError(err, 'Uncaught exception')); -program.name('coryd').description('🪄 Run commands, download things and have fun.').version('1.1.0'); +program.name('coryd').description('🪄 Run commands, jobs, download things and have fun.').version('2.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('jobs').description('Trigger jobs and tasks.').action(runJobsMenu); program.command('download').description('Download, name and store image assets.').action(downloadAsset); if (process.argv.length <= 2) { diff --git a/cli/lib/config.js b/cli/lib/config.js index 8931b73..85a9bf8 100644 --- a/cli/lib/config.js +++ b/cli/lib/config.js @@ -2,6 +2,7 @@ import fs from 'fs-extra'; import path from 'path'; import inquirer from 'inquirer'; import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CACHE_DIR = path.resolve(__dirname, '..', '.cache'); @@ -9,15 +10,39 @@ 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 - }]); +dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') }); - config.storageDir = storageDir; +export const initConfig = async () => { + const existingConfig = await fs.pathExists(CONFIG_PATH) + ? await fs.readJson(CONFIG_PATH) + : {}; + + const config = { ...existingConfig }; + + if (config.storageDir) { + const { updateStorage } = await inquirer.prompt([{ + type: 'confirm', + name: 'updateStorage', + message: `Storage directory is already set to "${config.storageDir}". Do you want to update it?`, + default: false + }]); + + if (updateStorage) { + const { storageDir } = await inquirer.prompt([{ + name: 'storageDir', + message: 'Where is your storage root directory?', + validate: fs.pathExists + }]); + config.storageDir = storageDir; + } + } else { + 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', @@ -43,7 +68,6 @@ export const initConfig = async () => { message: `Subpath for ${mediaType}/${assetType} (relative to storage root):`, default: defaultPath }]); - subpath = response.subpath; } @@ -62,6 +86,7 @@ export const initConfig = async () => { ]) ).artistPath : 'Media assets/artists'; + config.albumPath = customize ? ( await inquirer.prompt([ @@ -73,23 +98,26 @@ export const initConfig = async () => { ]) ).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 inquirer.prompt([ + { + name: 'bookPath', + message: 'Subpath for book images (relative to storage root):', + default: 'Media assets/books' + } + ]) + ).bookPath + : 'Media assets/books'; + + config.globals = await fetchGlobals(); 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)) { @@ -99,3 +127,31 @@ export const loadConfig = async () => { return await fs.readJson(CONFIG_PATH); } + +const fetchGlobals = async () => { + const POSTGREST_URL = process.env.POSTGREST_URL; + const POSTGREST_API_KEY = process.env.POSTGREST_API_KEY; + + if (!POSTGREST_URL || !POSTGREST_API_KEY) { + console.warn('⚠️ Missing POSTGREST_URL or POSTGREST_API_KEY in env, skipping globals fetch.'); + return {}; + } + + try { + const res = await fetch(`${POSTGREST_URL}/optimized_globals?select=*`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${POSTGREST_API_KEY}` + } + }); + + if (!res.ok) throw new Error(await res.text()); + + const data = await res.json(); + return data[0] || {}; + } catch (err) { + console.error('❌ Error fetching globals:', err.message); + return {}; + } +}; diff --git a/cli/lib/jobs.js b/cli/lib/jobs.js new file mode 100644 index 0000000..a126941 --- /dev/null +++ b/cli/lib/jobs.js @@ -0,0 +1,159 @@ +import inquirer from 'inquirer'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import { loadConfig } from './config.js'; + +const config = await loadConfig(); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const JOBS = [{ + name: '🎧 Update total plays', + type: 'curl', + urlEnvVar: 'TOTAL_PLAYS_WEBHOOK', + tokenEnvVar: 'DIRECTUS_API_TOKEN', + method: 'GET' + }, + { + name: '🛠 Rebuild site', + type: 'curl', + urlEnvVar: 'SITE_REBUILD_WEBHOOK', + tokenEnvVar: 'DIRECTUS_API_TOKEN', + method: 'GET' + }, + { + name: '💿 Scrobble listens from Navidrome', + type: 'curl', + apiUrl: `${config.globals.url}/api/scrobble.php`, + tokenEnvVar: 'NAVIDROME_SCROBBLE_TOKEN', + method: 'POST' + }, + { + name: '🐘 Send posts to Mastodon', + type: 'curl', + apiUrl: `${config.globals.url}/api/mastodon.php`, + tokenEnvVar: 'MASTODON_SYNDICATION_TOKEN', + method: 'POST' + }, + { + name: '🎤 Import artist from Navidrome', + type: 'curl', + apiUrl: `${config.globals.url}/api/artist-import.php`, + tokenEnvVar: 'ARTIST_IMPORT_TOKEN', + method: 'POST', + paramsPrompt: [{ + type: 'input', + name: 'artistId', + message: 'Enter the Navidrome artist ID:', + validate: input => input ? true : 'Artist ID is required' + }] + }, + { + name: '📖 Import book', + type: 'curl', + apiUrl: `${config.globals.url}/api/book-import.php`, + tokenEnvVar: 'BOOK_IMPORT_TOKEN', + method: 'POST', + paramsPrompt: [{ + type: 'input', + name: 'isbn', + message: 'Enter the book\'s ISBN:', + validate: input => input ? true : 'ISBN is required' + }] + }, + { + name: '📽 Import movie or show', + type: 'curl', + apiUrl: `${config.globals.url}/api/watching-import.php`, + tokenEnvVar: 'WATCHING_IMPORT_TOKEN', + method: 'POST', + tokenIncludeInParams: true, + paramsPrompt: [{ + type: 'input', + name: 'tmdb_id', + message: 'Enter the TMDB ID:', + validate: (input) => + /^\d+$/.test(input) ? true : 'Please enter a valid TMDB ID' + }, + { + type: 'list', + name: 'media_type', + message: 'Is this a movie or a show?', + choices: ['movie', 'show'] + }] + }, + { + name: '📺 Import upcoming TV seasons', + type: 'curl', + apiUrl: `${config.globals.url}/api/seasons-import.php`, + tokenEnvVar: 'SEASONS_IMPORT_TOKEN', + method: 'POST' + }]; + +export const runJobsMenu = async () => { + const { selectedJob } = await inquirer.prompt([{ + type: 'list', + name: 'selectedJob', + message: 'Select a job to run:', + choices: JOBS.map((job, index) => ({ + name: job.name, + value: index + })) + }]); + + const job = JOBS[selectedJob]; + + if (job.type === 'curl') { + let params = null; + + if (job.paramsPrompt) { + const answers = await inquirer.prompt(job.paramsPrompt); + const token = process.env[job.tokenEnvVar]; + + params = { ...answers, ...(token ? { token } : {}) }; + } + + await runCurl({ ...job, params }); + } else { + console.warn(`⚠️ Unsupported job type: ${job.type}`); + } +}; + +const runCurl = async ({ + urlEnvVar, + apiUrl = "", + tokenEnvVar, + method = 'POST', + name, + params = null +}) => { + const url = process.env[urlEnvVar] || apiUrl; + const token = tokenEnvVar ? process.env[tokenEnvVar] : null; + + if (!url) { + console.error(`❌ Missing URL for job. Check ${urlEnvVar} in your .env`); + return; + } + + try { + const res = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }) + }, + ...(params && { body: JSON.stringify(params) }) + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(errText); + } + + console.log(`✅ ${name} ran successfully.`); + } catch (err) { + console.error(`❌ Request failed: ${err.message}`); + } +}; diff --git a/cli/package-lock.json b/cli/package-lock.json index 688103d..d448c78 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "coryd", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coryd", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "chalk": "^5.4.1", "commander": "^14.0.0", diff --git a/cli/package.json b/cli/package.json index dd8b5d4..7e82c59 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "coryd", - "version": "1.1.0", + "version": "2.0.0", "description": "The CLI for my site to run scripts, manage and download assets.", "type": "module", "bin": { diff --git a/package-lock.json b/package-lock.json index 4fcd477..0f1d64f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@11ty/eleventy": "3.1.1", "@11ty/eleventy-fetch": "5.1.0", "@cdransf/eleventy-plugin-tabler-icons": "^2.13.0", - "cheerio": "1.0.0", + "cheerio": "1.1.0", "concurrently": "9.1.2", "cssnano": "^7.0.7", "dotenv": "16.5.0", @@ -460,9 +460,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -807,22 +807,22 @@ } }, "node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz", + "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==", "dev": true, "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", + "domutils": "^3.2.2", "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", + "undici": "^7.10.0", "whatwg-mimetype": "^4.0.0" }, "engines": { @@ -1409,9 +1409,9 @@ } }, "node_modules/encoding-sniffer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", - "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "dev": true, "license": "MIT", "dependencies": { @@ -1423,9 +1423,9 @@ } }, "node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1881,9 +1881,9 @@ } }, "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -1896,21 +1896,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "domutils": "^3.2.1", + "entities": "^6.0.0" } }, "node_modules/http-equiv-refresh": { @@ -4286,13 +4273,13 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", + "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "node_modules/unpipe": { diff --git a/package.json b/package.json index 844a6ee..71605de 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@11ty/eleventy": "3.1.1", "@11ty/eleventy-fetch": "5.1.0", "@cdransf/eleventy-plugin-tabler-icons": "^2.13.0", - "cheerio": "1.0.0", + "cheerio": "1.1.0", "concurrently": "9.1.2", "cssnano": "^7.0.7", "dotenv": "16.5.0", diff --git a/scripts/setup.sh b/scripts/setup.sh index 98032c4..69d9ef5 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -48,6 +48,9 @@ SECRETS_JSON='{ "NAVIDROME_API_TOKEN": "{{ op://Private/coryd.dev secrets/NAVIDROME_API_TOKEN }}", "COOLIFY_REBUILD_TOKEN": "{{ op://Private/coryd.dev secrets/COOLIFY_REBUILD_TOKEN }}", "COOLIFY_REBUILD_URL": "{{ op://Private/coryd.dev secrets/COOLIFY_REBUILD_URL }}", + "TOTAL_PLAYS_WEBHOOK": "{{ op://Private/coryd.dev secrets/TOTAL_PLAYS_WEBHOOK }}", + "SITE_REBUILD_WEBHOOK": "{{ op://Private/coryd.dev secrets/SITE_REBUILD_WEBHOOK }}", + "DIRECTUS_API_TOKEN": "{{ op://Private/coryd.dev secrets/DIRECTUS_API_TOKEN }}", "GIT_REPO": "{{ op://Private/coryd.dev secrets/GIT_REPO }}", "SERVER_IP": "{{ op://Private/coryd.dev secrets/SERVER_IP }}" }'