feat(cli): add support for running import and API jobs

This commit is contained in:
Cory Dransfeldt 2025-06-08 15:47:09 -07:00
parent 75df36acc3
commit 8a12e83b13
No known key found for this signature in database
8 changed files with 272 additions and 65 deletions

View file

@ -7,13 +7,15 @@ import { initConfig, loadConfig } from '../lib/config.js';
import { downloadAsset } from '../lib/download.js'; import { downloadAsset } from '../lib/download.js';
import { runRootScript } from '../lib/runScript.js'; import { runRootScript } from '../lib/runScript.js';
import { handleExitError } from '../lib/handlers.js'; import { handleExitError } from '../lib/handlers.js';
import { runJobsMenu } from '../lib/jobs.js';
process.on('unhandledRejection', (err) => handleExitError(err, 'Unhandled rejection')); process.on('unhandledRejection', (err) => handleExitError(err, 'Unhandled rejection'));
process.on('uncaughtException', (err) => handleExitError(err, 'Uncaught exception')); 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('init').description('Initialize CLI and populate required config.').action(initConfig);
program.command('run [script]').description('Run site scripts and commands.').action(runRootScript); 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); program.command('download').description('Download, name and store image assets.').action(downloadAsset);
if (process.argv.length <= 2) { if (process.argv.length <= 2) {

View file

@ -2,6 +2,7 @@ import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = path.resolve(__dirname, '..', '.cache'); 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 MEDIA_TYPES = ['movie', 'show'];
const ASSET_TYPES = ['poster', 'backdrop']; const ASSET_TYPES = ['poster', 'backdrop'];
dotenv.config({ path: path.resolve(__dirname, '..', '..', '.env') });
export const initConfig = async () => { export const initConfig = async () => {
const config = {}; 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([{ const { storageDir } = await inquirer.prompt([{
name: 'storageDir', name: 'storageDir',
message: 'Where is your storage root directory?', message: 'Where is your storage root directory?',
validate: fs.pathExists validate: fs.pathExists
}]); }]);
config.storageDir = storageDir; 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([{ const { customize } = await inquirer.prompt([{
type: 'confirm', type: 'confirm',
@ -43,7 +68,6 @@ export const initConfig = async () => {
message: `Subpath for ${mediaType}/${assetType} (relative to storage root):`, message: `Subpath for ${mediaType}/${assetType} (relative to storage root):`,
default: defaultPath default: defaultPath
}]); }]);
subpath = response.subpath; subpath = response.subpath;
} }
@ -62,6 +86,7 @@ export const initConfig = async () => {
]) ])
).artistPath ).artistPath
: 'Media assets/artists'; : 'Media assets/artists';
config.albumPath = customize config.albumPath = customize
? ( ? (
await inquirer.prompt([ await inquirer.prompt([
@ -73,6 +98,7 @@ export const initConfig = async () => {
]) ])
).albumPath ).albumPath
: 'Media assets/albums'; : 'Media assets/albums';
config.bookPath = customize config.bookPath = customize
? ( ? (
await inquirer.prompt([ await inquirer.prompt([
@ -85,11 +111,13 @@ export const initConfig = async () => {
).bookPath ).bookPath
: 'Media assets/books'; : 'Media assets/books';
config.globals = await fetchGlobals();
await fs.ensureDir(CACHE_DIR); await fs.ensureDir(CACHE_DIR);
await fs.writeJson(CONFIG_PATH, config, { spaces: 2 }); await fs.writeJson(CONFIG_PATH, config, { spaces: 2 });
console.log(`✅ Config saved to ${CONFIG_PATH}`); console.log(`✅ Config saved to ${CONFIG_PATH}`);
} };
export const loadConfig = async () => { export const loadConfig = async () => {
if (!await fs.pathExists(CONFIG_PATH)) { if (!await fs.pathExists(CONFIG_PATH)) {
@ -99,3 +127,31 @@ export const loadConfig = async () => {
return await fs.readJson(CONFIG_PATH); 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 {};
}
};

159
cli/lib/jobs.js Normal file
View file

@ -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}`);
}
};

4
cli/package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "coryd", "name": "coryd",
"version": "1.1.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "coryd", "name": "coryd",
"version": "1.1.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"chalk": "^5.4.1", "chalk": "^5.4.1",
"commander": "^14.0.0", "commander": "^14.0.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "coryd", "name": "coryd",
"version": "1.1.0", "version": "2.0.0",
"description": "The CLI for my site to run scripts, manage and download assets.", "description": "The CLI for my site to run scripts, manage and download assets.",
"type": "module", "type": "module",
"bin": { "bin": {

67
package-lock.json generated
View file

@ -16,7 +16,7 @@
"@11ty/eleventy": "3.1.1", "@11ty/eleventy": "3.1.1",
"@11ty/eleventy-fetch": "5.1.0", "@11ty/eleventy-fetch": "5.1.0",
"@cdransf/eleventy-plugin-tabler-icons": "^2.13.0", "@cdransf/eleventy-plugin-tabler-icons": "^2.13.0",
"cheerio": "1.0.0", "cheerio": "1.1.0",
"concurrently": "9.1.2", "concurrently": "9.1.2",
"cssnano": "^7.0.7", "cssnano": "^7.0.7",
"dotenv": "16.5.0", "dotenv": "16.5.0",
@ -460,9 +460,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.1", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -807,22 +807,22 @@
} }
}, },
"node_modules/cheerio": { "node_modules/cheerio": {
"version": "1.0.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz",
"integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cheerio-select": "^2.1.0", "cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0", "dom-serializer": "^2.0.0",
"domhandler": "^5.0.3", "domhandler": "^5.0.3",
"domutils": "^3.1.0", "domutils": "^3.2.2",
"encoding-sniffer": "^0.2.0", "encoding-sniffer": "^0.2.0",
"htmlparser2": "^9.1.0", "htmlparser2": "^10.0.0",
"parse5": "^7.1.2", "parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2", "parse5-parser-stream": "^7.1.2",
"undici": "^6.19.5", "undici": "^7.10.0",
"whatwg-mimetype": "^4.0.0" "whatwg-mimetype": "^4.0.0"
}, },
"engines": { "engines": {
@ -1409,9 +1409,9 @@
} }
}, },
"node_modules/encoding-sniffer": { "node_modules/encoding-sniffer": {
"version": "0.2.0", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1423,9 +1423,9 @@
} }
}, },
"node_modules/entities": { "node_modules/entities": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
@ -1881,9 +1881,9 @@
} }
}, },
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "9.1.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
"integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
"dev": true, "dev": true,
"funding": [ "funding": [
"https://github.com/fb55/htmlparser2?sponsor=1", "https://github.com/fb55/htmlparser2?sponsor=1",
@ -1896,21 +1896,8 @@
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0", "domelementtype": "^2.3.0",
"domhandler": "^5.0.3", "domhandler": "^5.0.3",
"domutils": "^3.1.0", "domutils": "^3.2.1",
"entities": "^4.5.0" "entities": "^6.0.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"
} }
}, },
"node_modules/http-equiv-refresh": { "node_modules/http-equiv-refresh": {
@ -4286,13 +4273,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "6.21.3", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
"integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.17" "node": ">=20.18.1"
} }
}, },
"node_modules/unpipe": { "node_modules/unpipe": {

View file

@ -36,7 +36,7 @@
"@11ty/eleventy": "3.1.1", "@11ty/eleventy": "3.1.1",
"@11ty/eleventy-fetch": "5.1.0", "@11ty/eleventy-fetch": "5.1.0",
"@cdransf/eleventy-plugin-tabler-icons": "^2.13.0", "@cdransf/eleventy-plugin-tabler-icons": "^2.13.0",
"cheerio": "1.0.0", "cheerio": "1.1.0",
"concurrently": "9.1.2", "concurrently": "9.1.2",
"cssnano": "^7.0.7", "cssnano": "^7.0.7",
"dotenv": "16.5.0", "dotenv": "16.5.0",

View file

@ -48,6 +48,9 @@ SECRETS_JSON='{
"NAVIDROME_API_TOKEN": "{{ op://Private/coryd.dev secrets/NAVIDROME_API_TOKEN }}", "NAVIDROME_API_TOKEN": "{{ op://Private/coryd.dev secrets/NAVIDROME_API_TOKEN }}",
"COOLIFY_REBUILD_TOKEN": "{{ op://Private/coryd.dev secrets/COOLIFY_REBUILD_TOKEN }}", "COOLIFY_REBUILD_TOKEN": "{{ op://Private/coryd.dev secrets/COOLIFY_REBUILD_TOKEN }}",
"COOLIFY_REBUILD_URL": "{{ op://Private/coryd.dev secrets/COOLIFY_REBUILD_URL }}", "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 }}", "GIT_REPO": "{{ op://Private/coryd.dev secrets/GIT_REPO }}",
"SERVER_IP": "{{ op://Private/coryd.dev secrets/SERVER_IP }}" "SERVER_IP": "{{ op://Private/coryd.dev secrets/SERVER_IP }}"
}' }'