|
| 1 | +# gctools - Ghost Content Tools |
| 2 | + |
| 3 | +CLI utilities for working with Ghost CMS content via the Admin API. |
| 4 | + |
| 5 | +**Use `yarn` (not npm) for running scripts and installing dependencies.** |
| 6 | + |
| 7 | +## Architecture Overview |
| 8 | + |
| 9 | +``` |
| 10 | +gctools/ |
| 11 | +├── bin/cli.js # Entry point - registers all commands with prettyCLI |
| 12 | +├── commands/ # CLI command definitions (flags, options, run function) |
| 13 | +├── tasks/ # Core business logic (API calls, data processing) |
| 14 | +├── prompts/ # Interactive mode definitions (inquirer prompts) |
| 15 | +├── lib/ # Shared utilities |
| 16 | +└── test/ # Jest tests |
| 17 | +``` |
| 18 | + |
| 19 | +## Adding a New Feature |
| 20 | + |
| 21 | +To add a new command, create/modify these files: |
| 22 | + |
| 23 | +### 1. `tasks/<feature>.js` - Business Logic |
| 24 | + |
| 25 | +```javascript |
| 26 | +import GhostAdminAPI from '@tryghost/admin-api'; |
| 27 | +import {makeTaskRunner} from '@tryghost/listr-smart-renderer'; |
| 28 | +import {discover} from '../lib/batch-ghost-discover.js'; |
| 29 | + |
| 30 | +const initialise = (options) => ({ |
| 31 | + title: 'Initialising API connection', |
| 32 | + task: (ctx, task) => { |
| 33 | + ctx.api = new GhostAdminAPI({ |
| 34 | + url: options.apiURL.replace('localhost', '127.0.0.1'), |
| 35 | + key: options.adminAPIKey, |
| 36 | + version: 'v5.0' |
| 37 | + }); |
| 38 | + ctx.updated = []; |
| 39 | + } |
| 40 | +}); |
| 41 | + |
| 42 | +const getFullTaskList = (options) => [ |
| 43 | + initialise(options), |
| 44 | + { |
| 45 | + title: 'Fetch content', |
| 46 | + task: async (ctx, task) => { |
| 47 | + ctx.posts = await discover({api: ctx.api, type: 'posts', ...}); |
| 48 | + } |
| 49 | + }, |
| 50 | + { |
| 51 | + title: 'Process content', |
| 52 | + task: async (ctx) => { |
| 53 | + // Return nested task runner for per-item progress |
| 54 | + return makeTaskRunner(tasks, {concurrent: 1}); |
| 55 | + } |
| 56 | + } |
| 57 | +]; |
| 58 | + |
| 59 | +const getTaskRunner = (options) => { |
| 60 | + return makeTaskRunner(getFullTaskList(options), {topLevel: true, ...options}); |
| 61 | +}; |
| 62 | + |
| 63 | +export default { initialise, getFullTaskList, getTaskRunner }; |
| 64 | +``` |
| 65 | + |
| 66 | +### 2. `commands/<feature>.js` - CLI Definition |
| 67 | + |
| 68 | +```javascript |
| 69 | +import {ui} from '@tryghost/pretty-cli'; |
| 70 | +import featureTask from '../tasks/<feature>.js'; |
| 71 | + |
| 72 | +const id = 'feature-name'; |
| 73 | +const group = 'Content:'; // or 'Members:', 'Tools:', 'Beta:' |
| 74 | +const flags = 'feature-name <apiURL> <adminAPIKey>'; |
| 75 | +const desc = 'Description of what this does'; |
| 76 | +const paramsDesc = ['URL to your Ghost API', 'Admin API key']; |
| 77 | + |
| 78 | +const setup = (sywac) => { |
| 79 | + sywac.boolean('-V --verbose', {defaultValue: false, desc: 'Show verbose output'}); |
| 80 | + sywac.string('--option', {defaultValue: null, desc: 'Option description'}); |
| 81 | + sywac.enumeration('--choice', {defaultValue: 'all', choices: ['all', 'a', 'b']}); |
| 82 | +}; |
| 83 | + |
| 84 | +const run = async (argv) => { |
| 85 | + let context = {errors: []}; |
| 86 | + let runner = featureTask.getTaskRunner(argv); |
| 87 | + await runner.run(context); |
| 88 | + ui.log.ok(`Successfully updated ${context.updated.length} items.`); |
| 89 | +}; |
| 90 | + |
| 91 | +export default { id, group, flags, desc, paramsDesc, setup, run }; |
| 92 | +``` |
| 93 | + |
| 94 | +### 3. `prompts/<feature>.js` - Interactive Mode |
| 95 | + |
| 96 | +```javascript |
| 97 | +import inquirer from 'inquirer'; |
| 98 | +import inquirerSearchCheckbox from 'inquirer-search-checkbox'; |
| 99 | +inquirer.registerPrompt('search-checkbox', inquirerSearchCheckbox); |
| 100 | +import inquirerDatepickerPrompt from 'inquirer-datepicker-prompt'; |
| 101 | +inquirer.registerPrompt('datetime', inquirerDatepickerPrompt); |
| 102 | +import {ui} from '@tryghost/pretty-cli'; |
| 103 | +import featureTask from '../tasks/<feature>.js'; |
| 104 | +import ghostAPICreds from '../lib/ghost-api-creds.js'; |
| 105 | +import {getAPITagsObj, getAPIAuthorsObj} from '../lib/ghost-api-choices.js'; |
| 106 | + |
| 107 | +const choice = { |
| 108 | + name: 'Menu item name', |
| 109 | + value: 'featureName' // Used to identify in interactive.js |
| 110 | +}; |
| 111 | + |
| 112 | +const options = [ |
| 113 | + ...ghostAPICreds, // API URL and key prompts |
| 114 | + // Add feature-specific prompts |
| 115 | +]; |
| 116 | + |
| 117 | +async function run() { |
| 118 | + await inquirer.prompt(options).then(async (answers) => { |
| 119 | + let context = {errors: []}; |
| 120 | + let runner = featureTask.getTaskRunner(answers); |
| 121 | + await runner.run(context); |
| 122 | + ui.log.ok(`Done.`); |
| 123 | + }); |
| 124 | +} |
| 125 | + |
| 126 | +export default { choice, options, run }; |
| 127 | +``` |
| 128 | + |
| 129 | +### 4. Register the Feature |
| 130 | + |
| 131 | +**`prompts/index.js`** - Add import and export: |
| 132 | +```javascript |
| 133 | +import featureName from './feature-name.js'; |
| 134 | +// In export default: featureName, |
| 135 | +``` |
| 136 | + |
| 137 | +**`commands/interactive.js`** - Add to menu (find appropriate section): |
| 138 | +```javascript |
| 139 | +{ |
| 140 | + name: tasks.featureName.choice.name, |
| 141 | + value: tasks.featureName.choice.value |
| 142 | +}, |
| 143 | +``` |
| 144 | + |
| 145 | +**`bin/cli.js`** - Register CLI command: |
| 146 | +```javascript |
| 147 | +import featureName from '../commands/feature-name.js'; |
| 148 | +// ... |
| 149 | +prettyCLI.command(featureName); |
| 150 | +``` |
| 151 | + |
| 152 | +## Key Libraries |
| 153 | + |
| 154 | +- `@tryghost/admin-api` - Ghost Admin API client |
| 155 | +- `@tryghost/listr-smart-renderer` - Task runner with progress display (`makeTaskRunner`) |
| 156 | +- `@tryghost/pretty-cli` - CLI framework (wraps sywac) |
| 157 | +- `inquirer` - Interactive prompts |
| 158 | + |
| 159 | +**Note:** Use native promises and `async/await`, not Bluebird. Use `sleep()` from `lib/utils.js` for delays. |
| 160 | + |
| 161 | +## Common Patterns |
| 162 | + |
| 163 | +### Filtering Posts |
| 164 | +```javascript |
| 165 | +let filter = []; |
| 166 | +if (options.status !== 'all') filter.push(`status:[${options.status}]`); |
| 167 | +if (options.visibility !== 'all') filter.push(`visibility:[${options.visibility}]`); |
| 168 | +if (options.tag) filter.push(`tags:[${transformToCommaString(options.tag, 'slug')}]`); |
| 169 | +if (options.author) filter.push(`author:[${transformToCommaString(options.author, 'slug')}]`); |
| 170 | +// Join with '+' for AND logic |
| 171 | +discover({...options, filter: filter.join('+')}); |
| 172 | +``` |
| 173 | + |
| 174 | +### API Edit Call |
| 175 | +```javascript |
| 176 | +await ctx.api.posts.edit({ |
| 177 | + id: post.id, |
| 178 | + updated_at: post.updated_at, // Required for optimistic locking |
| 179 | + // ... fields to update |
| 180 | +}); |
| 181 | +``` |
| 182 | + |
| 183 | +### Processing Items with Progress |
| 184 | +```javascript |
| 185 | +import {sleep} from '../lib/utils.js'; |
| 186 | + |
| 187 | +for (const post of ctx.posts) { |
| 188 | + tasks.push({ |
| 189 | + title: post.title, |
| 190 | + task: async () => { |
| 191 | + // Process post |
| 192 | + await sleep(options.delayBetweenCalls); |
| 193 | + } |
| 194 | + }); |
| 195 | +} |
| 196 | +return makeTaskRunner(tasks, {concurrent: 1}); |
| 197 | +``` |
| 198 | + |
| 199 | +## Utility Functions (`lib/utils.js`) |
| 200 | + |
| 201 | +- `transformToCommaString(array, key)` - Convert `[{slug: 'a'}, {slug: 'b'}]` to `'a,b'` |
| 202 | +- `maybeStringToArray(input)` - Convert `'a, b, c'` to `['a', 'b', 'c']` |
| 203 | +- `sleep(ms)` - Promise-based delay |
| 204 | + |
| 205 | +## Testing |
| 206 | + |
| 207 | +```bash |
| 208 | +yarn test # Runs Jest + ESLint |
| 209 | +``` |
| 210 | + |
| 211 | +Tests are in `test/` directory. Mock the Ghost API client for unit tests. |
| 212 | + |
| 213 | +## Command Groups |
| 214 | + |
| 215 | +- `Interactive:` - The `i` command for menu-driven mode |
| 216 | +- `Tools:` - File utilities (zip, json splitting) |
| 217 | +- `Content:` - Post/page API operations |
| 218 | +- `Members:` - Member API operations |
| 219 | +- `Beta:` - Experimental features |
0 commit comments