feat(cli): add site cli to run scripts + handle media
This commit is contained in:
parent
5055816f68
commit
d08787f5aa
12 changed files with 1615 additions and 26 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -1,3 +1,9 @@
|
|||
# cli cache
|
||||
cli/.cache/
|
||||
|
||||
# local dependencies
|
||||
.env
|
||||
|
||||
# build output
|
||||
.cache
|
||||
node_modules
|
||||
|
@ -5,8 +11,5 @@ vendor
|
|||
generated
|
||||
dist
|
||||
|
||||
# local dependencies
|
||||
.env
|
||||
|
||||
# system files
|
||||
.DS_Store
|
||||
|
|
44
README.md
44
README.md
|
@ -6,29 +6,43 @@ This is the source for my personal site [built using 11ty, PHP and a number of o
|
|||
|
||||
`npm run setup`
|
||||
|
||||
This will generate the required `.env` file, `apache` configs, commands and php extensions to install and enable on the server (if needed).
|
||||
This will generate the required `.env` file, install dependencies and configure the CLI.
|
||||
|
||||
Once the CLI is installed, it is invoked by running `coryd`.
|
||||
|
||||
## Remote dev setup
|
||||
|
||||
`npm run setup:dev`
|
||||
|
||||
This runs `setup` and generates `apache` configs, commands and php extensions to install and enable on the server.
|
||||
|
||||
## Local dev workflow
|
||||
|
||||
1. `npm start`
|
||||
1. `coryd run start`
|
||||
2. Open `http://localhost:8080`
|
||||
|
||||
To debug and develop php components, run `npm run php`. This will start the PHP server on `http://localhost:8000` and inject required environment variables from `.env`. It will also serve the static 11ty files from `dist`, so you can test the full site locally while leaving 11ty running to generate updates to files it watches.
|
||||
This will run `eleventy --watch` and the PHP cli concurrently, allowing for an environment similar to production where both static and dynamic pages are available.
|
||||
|
||||
## CLI
|
||||
|
||||
- `cli init`: begins a series of prompts to populate the config used by `cli download`.
|
||||
- `cli run`: presents a list of commands available to run (described below).
|
||||
- `cli run [command]`: runs the specified command immediately.
|
||||
- `cli download`: presents prompts to download images, name them consistently and place them in the directories specified when running `cli init`.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run start`: primary dev command that runs `watch` and `php` concurrently.
|
||||
- `npm run start:eleventy`: starts 11ty.
|
||||
- `npm run start:eleventy:quick`: starts 11ty a bit quicker (provided it's already been built).
|
||||
- `npm run debug`: runs 11ty with additional debug output.
|
||||
- `npm run watch`: watch and update when files change without running the web server.
|
||||
- `npm run build`: builds static site output.
|
||||
- `npm run php`: starts a PHP server for local development.
|
||||
- `npm run update:deps`: checks for dependency updates and updates 11ty.
|
||||
- `npm run setup`: populates `.env` from 1Password and installs dependencies using `npm` and `composer`.
|
||||
- `npm run clean`: removes the `dist` and `.cache` folders.
|
||||
- `npm run clean:cache`: removes the `.cache` folder.
|
||||
- `npm run clean:dist`: removes the `dist` folder.
|
||||
- `coryd run start`: primary dev command that runs `watch` and `php` concurrently.
|
||||
- `coryd run start:eleventy`: starts 11ty.
|
||||
- `coryd run start:eleventy:quick`: starts 11ty a bit quicker (provided it's already been built).
|
||||
- `coryd run debug`: runs 11ty with additional debug output.
|
||||
- `coryd run watch`: watch and update when files change without running the web server.
|
||||
- `coryd run build`: builds static site output.
|
||||
- `coryd run php`: starts a PHP server for local development.
|
||||
- `coryd run update:deps`: checks for dependency updates and updates 11ty.
|
||||
- `coryd run clean`: removes the `dist` and `.cache` folders.
|
||||
- `coryd run clean:cache`: removes the `.cache` folder.
|
||||
- `coryd run clean:dist`: removes the `dist` folder.
|
||||
|
||||
## Required environment variables
|
||||
|
||||
|
|
23
cli/bin/index.js
Executable file
23
cli/bin/index.js
Executable 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
101
cli/lib/config.js
Normal 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
182
cli/lib/download.js
Normal 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
42
cli/lib/runScript.js
Normal 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
9
cli/lib/sanitize.js
Normal 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
1170
cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
cli/package.json
Normal file
22
cli/package.json
Normal 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"
|
||||
}
|
||||
}
|
12
package-lock.json
generated
12
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "coryd.dev",
|
||||
"version": "8.3.3",
|
||||
"version": "9.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "coryd.dev",
|
||||
"version": "8.3.3",
|
||||
"version": "9.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minisearch": "^7.1.2",
|
||||
|
@ -31,7 +31,7 @@
|
|||
"postcss-import": "^16.1.0",
|
||||
"postcss-import-ext-glob": "^2.1.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"terser": "^5.40.0",
|
||||
"terser": "^5.41.0",
|
||||
"truncate-html": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
|
@ -4104,9 +4104,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.40.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz",
|
||||
"integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==",
|
||||
"version": "5.41.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.41.0.tgz",
|
||||
"integrity": "sha512-H406eLPXpZbAX14+B8psIuvIr8+3c+2hkuYzpMkoE0ij+NdsVATbA78vb8neA/eqrj7rywa2pIkdmWRsXW6wmw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "coryd.dev",
|
||||
"version": "8.3.3",
|
||||
"version": "9.0.0",
|
||||
"description": "The source for my personal site. Built using 11ty (and other tools).",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
@ -55,7 +55,7 @@
|
|||
"postcss-import": "^16.1.0",
|
||||
"postcss-import-ext-glob": "^2.1.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"terser": "^5.40.0",
|
||||
"terser": "^5.41.0",
|
||||
"truncate-html": "^1.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,6 +63,29 @@ echo "${COLOR_BLUE}Writing .env file...${COLOR_RESET}"
|
|||
echo "$SECRETS" | jq -r 'to_entries | .[] | "\(.key)=\(.value)"' > .env
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
echo "${COLOR_BLUE}📦 Installing root project dependencies...${COLOR_RESET}"
|
||||
npm install
|
||||
|
||||
echo "${COLOR_BLUE}📦 Installing PHP dependencies (composer)...${COLOR_RESET}"
|
||||
composer install
|
||||
|
||||
echo "${COLOR_BLUE}📦 Installing CLI dependencies...${COLOR_RESET}"
|
||||
(
|
||||
cd cli
|
||||
npm install
|
||||
)
|
||||
|
||||
if ! command -v cd_cli >/dev/null 2>&1; then
|
||||
echo "${COLOR_BLUE}🔗 Linking CLI globally...${COLOR_RESET}"
|
||||
(
|
||||
cd cli
|
||||
npm link
|
||||
)
|
||||
fi
|
||||
|
||||
echo "${COLOR_BLUE}⚙️ Initializing media storage config...${COLOR_RESET}"
|
||||
cd_cli init
|
||||
|
||||
mkdir -p generated
|
||||
|
||||
# escape sed replacements
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue