diff --git a/AGENTS.md b/AGENTS.md index c416f1ee..709c2202 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,8 @@ import featureName from '../commands/feature-name.js'; prettyCLI.command(featureName); ``` +**Docs** - Document the command in `docs/.md` (a `## ` section with usage examples and an "Available options" list matching `setup()`), and add a row to the command index table in `README.md` linking to that section. + ## Key Libraries - `@tryghost/admin-api` - Ghost Admin API client @@ -218,4 +220,5 @@ Tests are in `test/` directory. Mock the Ghost API client for unit tests. - `Tools:` - File utilities (zip, json splitting) - `Content:` - Post/page API operations - `Members:` - Member API operations +- `Staff:` - Staff user API operations - `Beta:` - Experimental features diff --git a/README.md b/README.md index 5873cbe3..cd82ee49 100644 --- a/README.md +++ b/README.md @@ -32,1400 +32,86 @@ GCTools has an interactive mode which walks you through each tool, without needi gctools i ``` -Available tools include: - -* [`zip-split`](#zip-split) -* [`zip-create`](#zip-create) -* [`json-split`](#json-split) -* [`json-clean`](#json-clean) -* [`fetch-assets`](#fetch-assets) -* [`dedupe-members-csv`](#dedupe-members-csv) -* [`compare-member-csv`](#compare-member-csv) -* [`random-posts`](#random-posts) -* [`seed-demo`](#seed-demo) -* [`delete-posts`](#delete-posts) -* [`delete-pages`](#delete-pages) -* [`add-tags`](#add-tags) -* [`remove-tags`](#remove-tags) -* [`combine-tags`](#combine-tags) -* [`inline-media`](#inline-media) -* [`add-preview`](#add-preview) -* [`delete-tags`](#delete-tags) -* [`delete-labels`](#delete-labels) -* [`delete-empty-tags`](#delete-empty-tags) -* [`keep-tags`](#keep-tags) -* [`find-replace`](#find-replace) -* [`change-author`](#change-author) -* [`add-author`](#add-author) -* [`change-visibility-posts`](#change-visibility-posts) -* [`change-visibility-pages`](#change-visibility-pages) -* [`change-status`](#change-status) -* [`change-role`](#change-role) -* [`delete-staff-users`](#delete-staff-users) -* [`clean-staff-slugs`](#clean-staff-slugs) -* [`comment-notifications`](#comment-notifications) -* [`add-member-comp-subscription`](#add-member-comp-subscription) -* [`add-member-comp-from-csv`](#add-member-comp-from-csv) -* [`remove-member-comp-subscription`](#remove-member-comp-subscription) -* [`add-member-newsletter-subscription`](#add-member-newsletter-subscription) -* [`member-newsletter-backup`](#member-newsletter-backup) -* [`split-members`](#split-members) -* [`add-label-to-members`](#add-label-to-members) -* [`change-tags`](#change-tags) -* [`post-tiers`](#post-tiers) -* [`set-template`](#set-template) -* [`set-canonical-url`](#set-canonical-url) -* [`page-to-post`](#page-to-post) -* [`content-stats`](#content-stats) -* [`get-posts`](#get-posts) -* [`set-featured-images`](#set-featured-images) -* [`clean-slugs`](#clean-slugs) -* [`set-podcast`](#set-podcast) -* [`update-posts-from-json`](#update-posts-from-json) - -Each of the tools also has a traditional CLI counterpart with more options, detailed below. - - -### zip-split - -Split a zip file into smaller zip files of a defined maximum size, while maintaining the directory structure. - -```sh -# See all available options -gctools zip-split --help - -# Split a zip file into as many files needed for them to all be 50mb or below -gctools zip-split /path/to/big-file.zip -M 50 -``` - - -### zip-create - -Split a large directory into smaller directories of a defined maximum size and zip each, while maintaining the directory structure. - -```sh -# See all available options -gctools zip-create --help - -# Split a large directory into as many files needed for them to all be 50mb or below -gctools zip-create /path/to/big-directory -M 50 -``` - - -### json-split - -Split a large JSON file into smaller JSON files of a defined maximum size, while retaining meta, tag, and author information. - -Output files are saved in a folder named after the source file. For example, splitting `big-file.json` with `--M 50` produces: - -``` -big-file/ - big-file-posts-00.json - big-file-posts-01.json - ... -``` - -```sh -# See all available options -gctools json-split --help - -# Split a JSON file into as many files needed for them to have a maximum of 50 posts per file -gctools json-split /path/to/big-file.json --M 50 -``` - - -### json-clean - -Clean a JSON file so it only contains content. Optionally connect to a target Ghost site to automatically match and update user IDs, slugs, names, and emails by comparing against existing Ghost users (matched by email address). Users not found in Ghost can still be updated manually via interactive prompts. - -```sh -# See all available options -gctools json-clean --help - -# Clean a JSON file to only contain content -gctools json-clean /path/to/file.json - -# Clean a JSON file and auto-update users from a Ghost site -gctools json-clean /path/to/file.json --ghostApiUrl https://example.ghost.io --ghostAdminKey 1234:abcd -``` - -**Available options:** -- `--ghostApiUrl`: Ghost site URL to fetch existing users (e.g. https://example.ghost.io) -- `--ghostAdminKey`: Ghost Admin API key to authenticate with Ghost (format: id:secret) - - -### fetch-assets - -Download all available assets from a valid Ghost JSON file create a JSON file with updated image references - -```sh -# See all available options -gctools fetch-assets --help - -# Fetch assets from a valid Ghost JSON file, using `https://example.com` to resolve relative URLs -gctools fetch-assets /path/to/file.json --url https://example.com -``` - - -### dedupe-members-csv - -Create new CSV files that only contain new or updated members, by comparing the existing members with the output from the output from [`@tryghost/migrate`](https://github.com/TryGhost/migrate/tree/main/packages/mg-substack-members-csv). - -```sh -# See all available options -gctools dedupe-members-csv --help - -gctools dedupe-members-csv [new-free] [new-comp] [new-paid] -``` - - -### compare-member-csv - -Compare two member CSV files to identify new, updated, and unsubscribed members. This tool is useful for tracking membership changes between two exports. - -The tool will generate up to three output files in the same directory as your source files: -- `new.csv` - Members present in the new file but not in the old file (new signups) -- `unsubscribed.csv` - Members present in the old file but not in the new file (cancellations/unsubscribes) -- `updated.csv` - Members present in both files but with changes (e.g., new Stripe customer ID, label changes, subscription status changes) - -```sh -# See all available options -gctools compare-member-csv --help - -# Compare two member CSV files -gctools compare-member-csv - -# Compare with verbose output -gctools compare-member-csv --verbose - -# Example: Compare January and February exports -gctools compare-member-csv /path/to/members-jan-2024.csv /path/to/members-feb-2024.csv -``` - -The comparison uses email addresses as the unique identifier. All columns from the original CSV files are preserved in the output files. The tool handles case-insensitive email matching and ignores entries with missing or empty email fields. - -**What counts as an update:** -- Changes to Stripe customer ID -- Changes to subscription status (subscribed_to_emails) -- Changes to complimentary plan status -- Changes to labels - -**Output:** -- The tool displays statistics showing how many new, updated, and unsubscribed members were found -- Output files are only created when there are differences to report -- Files are saved in the same directory as the old/first CSV file - - -### random-posts - -Insert a number of posts with random content. - -```sh -# See all available options -gctools random-posts --help - -# Create and insert 10 random posts -gctools random-posts - -# Create and insert 3000 random draft posts with 2 tags visible to members only, written by a specific author -gctools random-posts --count 3000 --tag '#random,New World' --status draft --visibility members --author person@dummyemail.com - -# Customize the content length and structure -gctools random-posts --count 50 --contentUnit paragraphs --contentCount 5 --titleMinLength 2 --titleMaxLength 6 -``` - -**Available options:** -- `--count` (default: 10): Number of posts to create -- `--titleMinLength` (default: 3): Minimum words in title -- `--titleMaxLength` (default: 8): Maximum words in title -- `--contentUnit` (default: paragraphs): Content unit type (paragraphs, sentences, words) -- `--contentCount` (default: 10): Number of content units per post -- `--paragraphLowerBound` (default: 3): Min sentences per paragraph -- `--paragraphUpperBound` (default: 7): Max sentences per paragraph -- `--sentenceLowerBound` (default: 3): Min words per sentence -- `--sentenceUpperBound` (default: 15): Max words per sentence -- `--author`: Author email address -- `--tag` (default: #gctools): Comma separated list of tags -- `--status` (default: published): Post status (published, draft, scheduled, sent) -- `--visibility` (default: public): Post visibility (public, members, paid) -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - - -### seed-demo - -Seed a Ghost site with rich demo content in one run: posts with feature images and in-post image cards, a distributed tag set, an `/about` page, a "golden" post at `/style-guide`, a navigation menu (Home, About, Style Guide, Author, Collection), and optionally a dummy author attributed across the content. - -The Style Guide doubles as a front-end theme stress test. It covers every editor card — header (dark/light/accent/image variants), image (regular/wide/full, linked, no-caption, adjacent), multi-row gallery, audio, video, file download, callout (full colour palette + no-emoji), toggle, bookmark (with thumbnail), button (center/left), product, signup, embed, code block, call-to-action, markdown, HTML and divider — plus the email-only `email` and `email-cta` cards, a full inline-formatting sampler (bold, italic, strikethrough, underline, inline code, highlight, super/subscript, links), heading levels h2–h6, nested lists, a table, and edge cases (long headings/unbroken strings). The paywall (public preview) card is included automatically when the site has members enabled. Card shapes mirror `@tryghost/kg-default-nodes`, so everything remains fully editable in Ghost Admin. - -Images are downloaded from [Lorem Picsum](https://picsum.photos/) and re-uploaded to the site so the result is self-contained; the Style Guide's audio and video are sourced from [lorem.media](https://lorem.media/) (all permissively licensed for commercial use). Images, audio and video are chosen at random, so do review these before using the site in public demos. - -```sh -# See all available options -gctools seed-demo --help - -# Seed 10 posts plus an about page, style guide, and navigation -gctools seed-demo - -# Seed 50 posts, feature images on half of them, no in-post image cards -gctools seed-demo --count 50 --featureImages 50% --imageCards false - -# Spread post dates across a range (defaults the end date to now) -gctools seed-demo --count 24 --dateStart 2025-01-01 --dateEnd 2025-12-31 - -# Add a dummy author (requires a staff access token) as primary author on ~40% of posts -gctools seed-demo --addAuthor --authorShare 40 - -# Seed content and also set navigation automatically (pass an owner/admin staff access token as the key) -gctools seed-demo - -# Preview what would be created without writing anything -gctools seed-demo --dryRun -``` - -> **Navigation & staff access tokens:** Updating the navigation menu writes to Ghost's settings, which integration API keys are not permitted to do. Supply an owner/admin [staff access token](https://docs.ghost.org/admin-api#staff-access-token-authentication) (found on a staff user's profile page, in the same `{id}:{secret}` format) as the `` argument and navigation is written automatically. With an integration key everything else still runs; the navigation step is skipped and the menu JSON is printed for you to paste into Ghost Admin → Settings → Navigation. -> -> **Dummy author & staff access tokens:** `--addAuthor` creates a placeholder staff user (with a hosted profile image and bio) and attributes it to the Style Guide (as a second author) and a random share of posts (as primary author). Creating a staff user uses Ghost's content import endpoint, which only an owner/admin [staff access token](https://docs.ghost.org/admin-api#staff-access-token-authentication) can write to — no invite email is sent. Re-runs reuse the existing author rather than duplicating it. With an integration key the step is skipped and an importable JSON file is printed for you to load via Ghost Admin → Settings → Labs → Import content. A Contributor can be a post's primary author; it just can't publish (the API key does the publishing here). - -**Available options:** -- `--count` (default: 10): Number of demo posts to create -- `--featureImages` (default: all): `all`, `none`, or a percentage like `50%` -- `--imageCards` (default: true): Insert 0-3 in-post image cards (weighted low, never before the first paragraph) -- `--maxImageCards` (default: 3): Maximum in-post image cards per post -- `--tags` (default: true): Distribute the five fixed tags (lorem, ipsum, dolor, sit, amet) across posts -- `--extraTags` (default: 0): Additional random tags to generate and distribute (max 30) -- `--collectionTag` (default: lorem): Tag whose archive backs the "Collection" nav item -- `--aboutPage` (default: true): Create or overwrite the `/about` page -- `--styleGuide` (default: true): Create the Style Guide post -- `--addAuthor` (default: false): Create a dummy author and attribute it to the Style Guide (second author) and a share of posts (primary author). Requires an owner/admin staff access token; with an integration key the step is skipped and importable JSON is printed -- `--authorShare` (default: 30): Percentage of posts (0-100) on which the dummy author is the primary author -- `--authorName` (default: Sam Example): Display name for the dummy author -- `--authorEmail`: Email for the dummy author (default: derived from the name, e.g. `sam-example@example.com`) -- `--authorRole` (default: Contributor): Role for the dummy author (`Contributor`, `Author`, `Editor`, `Administrator`) -- `--nav` (default: true): Update the navigation menu (requires an owner/admin staff access token; with an integration key the step is skipped and the menu JSON is printed to paste manually) -- `--authorNav` (default: true): Include the "Author" archive link -- `--collectionNav` (default: true): Include the "Collection" archive link -- `--status` (default: published): Status for created posts (published, draft) -- `--visibility` (default: public): Visibility for created posts (public, members, paid) -- `--dateStart`: Distribute post dates from this date (e.g. `2025-01-01`). Posts are spread evenly across the range with random jitter inside each slot, so they look naturally paced rather than clustered. Without it, every post is dated now. (Interactive mode offers "Past year" / "Past month" / "Custom" presets.) -- `--dateEnd` (default: now): End of the post date range; only used with `--dateStart` -- `--dryRun` (default: false): Preview without writing to the site -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - -> **Asset licensing:** None of the seeded media requires attribution. -> - **Images** come from [Lorem Picsum](https://picsum.photos/), drawn from an Unsplash pool collected under CC0 (public domain), so no credit is required. Note this is "CC0-era" Unsplash content — fine for placeholders and demos, but source properly-licensed originals if you need production brand imagery. -> - **Video & photos** from [lorem.media](https://lorem.media/) use the [Pexels](https://www.pexels.com/license/) and [Pixabay](https://pixabay.com/service/license-summary/) licenses — free for commercial use, attribution not required (only appreciated). -> - **Audio** from lorem.media comes from the Internet Archive 78rpm collection and is public domain (recordings from 1925 or earlier). -> -> lorem.media exposes optional per-asset attribution in its response headers (`X-Attribution`, `X-Source`, `X-Source-Url`) and via its `/v2/info/...` JSON endpoint if you ever want to surface credits, but it is not a licensing requirement. As with any random placeholder media, review the generated assets before using a site for a public demo. - - -### delete-posts - -Delete all content or content with a specific set of filters, which can be combined. - -```sh -# See all available options -gctools delete-posts --help - -# Delete all posts (⛔️ dangerous!) -gctools delete-posts - -# Delete all posts with a specific tag -gctools delete-posts --tag 'hash-testing' - -# Delete all published posts -gctools delete-posts --status 'published' - -# Delete all draft posts -gctools delete-posts --status 'draft' - -# Delete all posts by a specific author -gctools delete-posts --author 'sample-user' - -# Delete all posts by a specific author with a specific tag -gctools delete-posts --author 'sample-user' --tag 'hash-testing' -``` - - -### delete-pages - -Delete all content or content with a specific set of filters, which can be combined. - -```sh -# See all available options -gctools delete-pages --help - -# Delete all pages (⛔️ dangerous!) -gctools delete-pages - -# Delete all pages with a specific tag -gctools delete-pages --tag 'hash-testing' - -# Delete all published pages -gctools delete-pages --status 'published' - -# Delete all draft pages -gctools delete-pages --status 'draft' - -# Delete all pages by a specific author -gctools delete-pages --author 'sample-user' - -# Delete all pages by a specific author with a specific tag -gctools delete-pages --author 'sample-user' --tag 'hash-testing' -``` - - -### add-tags - -Add a tag to specific posts and pages with a specific set of filters - -```sh -# Add a tag of 'Testing' to all posts and pages -gctools add-tags --new_tags 'Testing' - -# Add a tag of 'Testing' to all posts and pages (same result as above) -gctools add-tags --new_tags 'Testing' --type posts pages - -# Add a tag of 'Testing' to all posts only -gctools add-tags --new_tags 'Testing' --type posts - -# Add a tag of 'Testing' to all pages only -gctools add-tags --new_tags 'Testing' --type pages - -# Add a tag of 'Testing' to all public posts and pages -gctools add-tags --visibility public --new_tags 'Testing' - -# Add a tag of 'Testing' to all members-only posts and pages that also have a tag of 'hello' -gctools add-tags --visibility members --tag 'hello' --new_tags 'Testing' - -# Add a tag of 'Testing' to all members-only posts and pages that also have a tag of 'hello', and are by written by 'harry' -gctools add-tags --visibility members --tag 'hello' --author 'harry' --new_tags 'Testing' -``` - - -### remove-tags - -Remove tags from specific posts and pages with a specific set of filters - -```sh -# See all available options -gctools remove-tags --help - -# Remove 'Legacy' tag from all posts and pages -gctools remove-tags --remove_tags 'Legacy' - -# Remove multiple tags from posts only -gctools remove-tags --remove_tags 'Legacy, Old Tag' --type posts - -# Remove tags from posts published before a specific date -gctools remove-tags --remove_tags 'Legacy' --before-date 2023-01-01 - -# Remove tags from posts with specific visibility -gctools remove-tags --remove_tags 'Draft' --visibility members - -# Remove tags from posts by specific author -gctools remove-tags --remove_tags 'Newsletter' --author 'john-doe' - -# Remove tags from posts that have specific existing tags -gctools remove-tags --remove_tags 'Legacy' --tag 'old-content, archived' - -# Remove tags from posts with date range filtering -gctools remove-tags --remove_tags 'Old' --before-date 2023-01-01 --after-date 2022-01-01 -``` - -**Interactive Mode:** -The interactive mode provides a user-friendly interface with searchable tag selection, date pickers, and all filtering options. Use `gctools i` and select "Remove tags from posts and pages". - - -### combine-tags - -Merge one tag into another. All posts with Tag B will have it replaced with Tag A at the same position. If a post already has both tags, Tag B is simply removed. Tag A is preserved in its original position. - -```sh -gctools combine-tags --tagA --tagB -``` - -For example, to merge the `newsletter` tag into `articles`: - -```sh -gctools combine-tags --tagA articles --tagB newsletter -``` - - -### inline-media - -Download external media from posts and pages, re-upload them to Ghost, and update the URLs in the content. Handles image, audio, video, and file cards in both Lexical and Mobiledoc content, gallery cards, and metadata fields (`feature_image`, `og_image`, `twitter_image`). HEIC/HEIF images are automatically converted to JPEG. Media already hosted on the Ghost site is skipped. Processed content is tagged with `#ImagesUploaded` to prevent reprocessing. - -Supported file types are routed to the correct Ghost upload endpoint: -- **Images** (`/images/upload/`): JPEG, PNG, GIF, WebP, SVG, ICO, AVIF, HEIC/HEIF -- **Media** (`/media/upload/`): MP4, WebM, OGG, MP3, WAV, M4A -- **Files** (`/files/upload/`): PDF, JSON, XML, RTF, Office documents, OpenDocument formats - -When `--assetDomains` is specified, a regex scan also catches file URLs in links (e.g. PDFs in `` tags) that are not inside media cards. - -```sh -# Inline all external media across all posts and pages -gctools inline-media - -# Only process a single post by slug or ID -gctools inline-media --slug 'my-post-slug' -gctools inline-media --id '69d78ee0149bd000016f2852' - -# Only process posts (not pages) -gctools inline-media --type posts - -# Only process media from specific domains (also catches files in links) -gctools inline-media --assetDomains 'cdn.example.com, images.example.com' - -# Dry run to see what would be processed -gctools inline-media --dryRun - -# Dry run with verbose output to see which URLs would be inlined -gctools inline-media --dryRun -V - -# Only process published posts by a specific author -gctools inline-media --status published --author 'example-author' - -# Only process posts with a specific tag -gctools inline-media --tag 'imported' -``` - -**Available options:** -- `--slug`: Fetch a single post by its slug -- `--id`: Fetch a single post by its ID -- `--type` (default: all): Content type (all, posts, pages) -- `--status` (default: all): Post status (all, draft, published) -- `--visibility` (default: all): Post visibility (all, public, members, paid) -- `--tag`: Filter by tag slugs (comma separated) -- `--author`: Filter by author slugs (comma separated) -- `--assetDomains`: Comma separated list of domains to process media from -- `--dryRun`: Show what would be processed without downloading or uploading -- `-V --verbose`: Show verbose output (lists URLs found per post) -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - - -### add-preview - -Insert a public preview divider at a specific point, after the `previewPosition` number. - -```sh -# Add a divider to all posts after position 2 -gctools add-preview --previewPosition 2 - -# Add a divider to all posts after position 2, and overwrites if a divider already exists -gctools add-preview --previewPosition 2 --overwrite - -# Add a divider to all posts 50% through the post -gctools add-preview --previewPosition 50% - -# Add a divider to all posts after position 2 for members-only posts -gctools add-preview --visibility members --previewPosition 2 - -# Add a divider to all posts after position 2 for paid posts -gctools add-preview --visibility paid --previewPosition 2 - -# Add a divider to all posts after position 2 for paid posts that also have a tag of `hello` -gctools add-preview --visibility paid --tag hello --previewPosition 2 - -# Add a divider to all posts after position 2 for paid posts that also have a tag of `hello`, and are by written by `harry` -gctools add-preview --visibility paid --tag hello --author harry --previewPosition 2 -``` - - -### delete-tags - -Delete tags, but not the content that uses that tag - -```sh -# See all available options -gctools delete-tags --help - -# Delete a specific tag or multiple tags -gctools delete-tags --tags hash-gctools, test 1 -``` - -### delete-labels - -Delete member labels: the ones you name, the ones no member has, or all of them. With `--all` or `--unused` you can pass a list of labels to keep. Labels are matched by name or slug, ignoring case. - -The tool fetches the labels it needs with their member counts first, and stops if any request fails. With `--labels` it asks Ghost only for the labels you named, unless a name contains a quote or backslash; with `--all` or `--unused` it fetches every label. On SQLite, a name typed in a different case from the label in Ghost may not be found, unless it matches the label's slug. Such a label is reported as not in Ghost, and not deleted. The tool then lists what it would delete and asks you to confirm (default: **No**). There is no flag to skip that prompt. With `--unused`, each label's member count is checked again just before it's deleted, and labels that have gained members are skipped. A report CSV of every deleted, kept, skipped and failed label is written to the folder of `--keepFile`, or to the current folder. - -Deleting a label is expensive for Ghost: it loads and detaches the label's members and purges the site cache. Labels are therefore deleted one at a time with a delay between calls. Raise `--concurrency` with care. - -```sh -# See all available options -gctools delete-labels --help - -# Delete specific labels -gctools delete-labels --labels 'First' 'Second' - -# Preview, then delete, every label no member has -gctools delete-labels --unused --dryRun -gctools delete-labels --unused - -# Delete every label except the ones listed -gctools delete-labels --all --keep 'VIP' 'Paid' -gctools delete-labels --all --keepFile /path/to/labels-to-keep.csv - -# Delete every label -gctools delete-labels --all --force -``` - -Also available in `gctools i` as **Delete labels**. - -**Available options:** -- `--labels`: Names or slugs of the labels to delete -- `--all` (default: false): Delete every label. Requires `--keep`, `--keepFile` or `--force` -- `--unused` (default: false): Delete only labels that no member has -- `--keep`: Names or slugs of labels never to delete. Use with `--all` or `--unused` -- `--keepFile`: Path to a single-column CSV of labels never to delete. The first row is always treated as a header -- `--dryRun` (default: false): Report which labels would be deleted, without prompting or deleting -- `--force` (default: false): Bypass the checks for `--all` without a keep list, and for a keep list that is empty or matches nothing -- `--concurrency` (default: 1): How many labels to delete at once. If a delete fails, deletes already started finish and no new ones start -- `--timeout` (default: 60000): How long to wait for Ghost to answer each request, in ms. Raise it if deleting labels with very many members times out -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms -- `-V --verbose`: List every label to delete, not just the first 20 - - -### delete-empty-tags - -Delete tags that have no or a low number of associated posts - -```sh -# See all available options -gctools delete-empty-tags --help - -# Delete tags with no associated posts -gctools delete-empty-tags - -# Delete tags with 3 or fewer associated posts -gctools delete-empty-tags --maxPostCount 3 - -# Custom delay between API calls -gctools delete-empty-tags --maxPostCount 5 --delayBetweenCalls 100 -``` - -**Available options:** -- `--maxPostCount` (default: 0): Maximum number of associated posts a tag can have to be deleted -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - - -### keep-tags - -Delete every tag that is *not* listed in a CSV of tags to keep. Useful for reconciling a site's tags against a canonical list. - -**CSV format:** only the first column is read, and the first row is always treated as a header — whatever it's called. Values are matched ignoring case and surrounding whitespace, and duplicates are collapsed. - -By default values are matched against the tag **slug**: - -```csv -slug -news -how-to -product-updates -``` - -Use `--matchBy name` when your list is of human-readable tag **names**: - -```csv -name -News -How To -Product Updates -``` - -Use `--matchBy either` for a list that mixes the two — each value is tried as a slug first, then as a name: - -```csv -tag -news -How To -product-updates -``` - -Since the header name doesn't matter and extra columns are ignored, a CSV exported from somewhere else works as-is, as long as the column you want to match on is first: - -```csv -name,slug,post_count -News,news,42 -How To,how-to,17 -Product Updates,product-updates,8 -``` - -That file matches on `name`, so it needs `--matchBy name` (or `either`). Reorder the columns to match on `slug` instead. - -Note that a name and a slug often normalise to the same thing — `News` and `news` both become `news` — so a list of simple one-word names will match on slug without any flag. `--matchBy` only matters for tags whose name differs from its slug, like `How To` vs `how-to`. - -A report CSV is always written next to the input CSV, on both dry and real runs. It lists every tag with an `action` of `kept`, `kept_internal`, `would_delete`, `deleted` or `delete_failed`, plus a `not_in_ghost` row for each CSV value that matched no tag. - -> **Safety:** internal tags (`#internal`) are never deleted, regardless of the CSV or `--force`. The command also aborts without deleting anything if the CSV yields no values, or if not a single CSV value matches a tag — either almost always means the wrong column or the wrong `--matchBy`. - -```sh -# See all available options -gctools keep-tags --help - -# Report what would be deleted, without changing anything -gctools keep-tags /path/to/tags.csv --dryRun - -# Delete every tag whose slug is not in the CSV -gctools keep-tags /path/to/tags.csv - -# Match against tag names instead of slugs -gctools keep-tags /path/to/tags.csv --matchBy name - -# Match against either the slug or the name -gctools keep-tags /path/to/tags.csv --matchBy either -``` - -**Available options:** -- `--dryRun` (default: false): Report which tags would be deleted, without deleting anything -- `--matchBy` (default: `slug`): Match CSV values against the tag `slug`, `name`, or `either`. Note that `either` makes the "nothing matched" safety check much less likely to catch a wrong CSV -- `--force` (default: false): Bypass the safety checks that abort when the CSV is empty or matches no tags. Internal tags are still never deleted -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - - -### find-replace - -Find & replace strings of text within Ghost posts. If `--replace` is omitted, the command runs in dry-run mode and reports the number of matches without making any changes. - -```sh -# See all available options -gctools find-replace --help - -# Dry run: find matches without replacing anything -gctools find-replace --find 'Old text' - -# Dry run with detailed per-post, per-field match report -gctools find-replace --find 'Old text' --where all -V - -# Replace a string but only in the `mobiledoc` and `title` -gctools find-replace --find 'Old text' --replace 'New text' --where mobiledoc,title - -# Replace a string in all available fields -gctools find-replace --find 'Old text' --replace 'New text' --where all - -# Replace a string in all available fields in posts with the `world-news` tag -gctools find-replace --tag world-news --find 'Old text' --replace 'New text' --where all - -# Custom delay between API calls -gctools find-replace --find 'Old text' --replace 'New text' --delayBetweenCalls 100 -``` - -Use `-V` (`--verbose`) for detailed output showing which fields matched or were replaced in each post. - -Available `where` fields are: - -* `all` -* `mobiledoc` (default) -* `html` -* `lexical` -* `title` -* `slug` -* `custom_excerpt` -* `meta_title` -* `meta_description` -* `twitter_title` -* `twitter_description` -* `og_title` -* `og_description` - - -### change-author - -Change the author assigned to a post - -```sh -# See all available options -gctools change-author --help - -# Change the posts written by `richard` and assign to `michael` -gctools change-author --author 'richard' --new_author 'michael' -``` - - -### add-author - -Add an author to a post - -```sh -# See all available options -gctools add-author --help - -# Add author with the slug 'michael' to all posts -gctools add-author --new_author 'michael' - -# Add author with the slug 'michael' to the posts with the tag 'news` -gctools add-author --tag 'news' --new_author 'michael' - -# For posts that have 'richard' as an author and with the tag 'news', add 'michael' as an author -gctools add-author --author 'richard' --tag 'news' --new_author 'michael' -``` - - -### change-visibility-posts - -Change the visibility of posts - -```sh -# See all available options -gctools change-visibility-posts --help - -# Change the posts that are currently public to be members-only -gctools change-visibility-posts --visibility 'public' --new_visibility 'members' - -# Change the posts that are currently members-only to be paid-members only -gctools change-visibility-posts --visibility 'members' --new_visibility 'paid' - -# Change the posts tagged with 'news' to be paid-members only -gctools change-visibility-posts --tag 'news' --new_visibility 'paid' - -# Change the posts tagged with 'news', and written by 'jane' to be paid-members only -gctools change-visibility-posts --tag 'news' --author 'jane' --new_visibility 'paid' -``` - - -### change-visibility-pages - -Change the visibility of pages - -```sh -# See all available options -gctools change-visibility-pages --help - -# Change the pages that are currently public to be members-only -gctools change-visibility-pages --visibility 'public' --new_visibility 'members' - -# Change the pages that are currently members-only to be paid-members only -gctools change-visibility-pages --visibility 'members' --new_visibility 'paid' - -# Change the pages tagged with 'news' to be paid-members only -gctools change-visibility-pages --tag 'news' --new_visibility 'paid' - -# Change the pages tagged with 'news', and written by 'jane' to be paid-members only -gctools change-visibility-pages --tag 'news' --author 'jane' --new_visibility 'paid' -``` - - -### change-status - -Change the status of posts - -```sh -# See all available options -gctools change-status --help - -# Change the posts that are currently drafts to be public -gctools change-status --status 'draft' --new_status 'published' - -# Change the posts that are currently drafts with the tag `news` to be public -gctools change-status --status 'draft' --tag 'news' --new_status 'published' -``` - - -### change-role - -Change the staff user role (requires a staff user token) [Ghost >= 5.2.0] - -```sh -# See all available options -gctools change-role --help - -# Change all staff users (except the site owner) to have the Contributor role -gctools change-role --newRole 'Contributor' - -# Change all staff users who are currently the Editor role to have the Author role -gctools change-role --filterRole 'Editor' --newRole 'Author' -``` - -### delete-staff-users - -Permanently delete staff users in selected roles when they have no posts or pages. You can optionally limit the operation to staff whose email has an exact domain. The site owner and the staff user whose token is running the command are always protected. A [staff access token](https://docs.ghost.org/admin-api#staff-access-token-authentication) with permission to delete the selected users is required; an integration Admin API key will be rejected. - -The command treats Ghost's authenticated `count.posts` value as the combined post/page count. If that count is missing, the staff user is skipped. Every user is fetched and checked again immediately before deletion. - -```sh -# See all available options -gctools delete-staff-users --help - -# Preview content-free Contributors and Authors -gctools delete-staff-users --roles 'Contributor,Author' --dry-run - -# Permanently delete the eligible staff users -gctools delete-staff-users --roles 'Contributor,Author' --yes - -# Only delete eligible staff whose email ends with @example.com -gctools delete-staff-users --roles 'Contributor,Author' --email-domain 'example.com' --yes - -# Show every eligible and skipped staff user -gctools delete-staff-users --roles 'Contributor' --dry-run --verbose -``` - -The interactive mode also supports this tool via `gctools i`. It reuses saved/manual credentials, defaults to preview, and asks for confirmation before deletion. - -**Available options:** - -- `--roles`: Required comma-separated list of roles to delete; each value must be `Contributor`, `Author`, `Editor`, or `Administrator` -- `--email-domain`: Only delete staff whose email ends with this exact domain; accepts `example.com` or `@example.com` -- `--dry-run`: Preview eligible staff users without deleting anything -- `--yes`: Confirm permanent deletion; cannot be combined with `--dry-run` -- `--delayBetweenCalls` (default: 200): Delay between deletion attempts in ms -- `--verbose`: Show every eligible and skipped staff user - -### clean-staff-slugs - -Find staff user slugs that end with a Ghost ID suffix, such as `first-last-6a32d1c818627a48420e689d`, and remove the suffix when the cleaned slug is safe to use. - -The tool fetches all staff users, checks whether the cleaned slug already exists, and only updates users where the cleaned slug is unique. If `first-last` already exists, or multiple ID-suffixed staff users would collapse to `first-last`, those users are skipped. - -```sh -# See all available options -gctools clean-staff-slugs --help - -# Preview what would be changed without making updates -gctools clean-staff-slugs --dry-run - -# Clean safe staff user slugs -gctools clean-staff-slugs - -# Custom delay between API calls -gctools clean-staff-slugs --delayBetweenCalls 100 -``` - -The interactive mode also supports this tool via `gctools i` and uses the same saved/manual Ghost Admin authentication prompts as the other API tools. - -**Available options:** -- `--dry-run`: Show what would be changed without making actual changes -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - -### comment-notifications - -Manage comment and membership notification settings for staff users. Backup current settings to CSV, restore from a backup, or enable/disable comment notifications for all staff. Bulk updates also disable free-member signup, paid-subscription cancellation, and paid-subscription start notifications. - -```sh -# See all available options -gctools comment-notifications --help - -# Backup current settings to CSV (no changes made) -gctools comment-notifications --backup ./backup.csv - -# Backup settings, then disable notifications for all staff -gctools comment-notifications --backup ./backup.csv --value false - -# Disable notifications for all staff (no backup) -gctools comment-notifications --value false - -# Enable comment notifications for all staff (membership notifications remain disabled) -gctools comment-notifications --value true - -# Restore settings from a previous backup -gctools comment-notifications --restore ./backup.csv -``` - -The backup CSV contains columns: `id`, `email`, `name`, `slug`, `comment_notifications`, `free_member_signup_notification`, `paid_subscription_canceled_notification`, `paid_subscription_started_notification` - -**Available options:** -- `--backup`: Path to save backup CSV before making changes -- `--restore`: Path to CSV file to restore settings from -- `--value`: Enable (true) or disable (false) comment notifications; the three membership notification settings are always disabled -- `--delayBetweenCalls` (default: 1000): Delay between API calls in ms - -**Notes:** -- The Owner role is always excluded from changes -- Cannot combine `--restore` with `--value` (restore uses values from the CSV) -- Using `--backup` without `--value` only creates a backup (no updates) -- Backups created before the membership notification columns were added can still be restored; missing settings are left unchanged - - -### add-member-comp-subscription - -Add complimentary subscriptions for members - -```sh -# Add a complimentary plan to tier ID abcdtierid1234 that expired on May 4th 2025, but only for members with the label slug 'my-member-label-slug' -gctools add-member-comp-subscription --tierId abcdtierid1234 --expireAt '2025-05-04T00:00:00.000Z' --onlyForLabelSlugs my-member-label-slug - -# Custom delay between API calls -gctools add-member-comp-subscription --tierId abcdtierid1234 --delayBetweenCalls 200 -``` - -**Available options:** -- `--tierId`: The ID for the tier to add the subscription to -- `--expireAt`: Expiration date in ISO format -- `--onlyForLabelSlugs`: Filter by member label slugs -- `--delayBetweenCalls` (default: 100): Delay between API calls in ms - - -### add-member-comp-from-csv - -Add complimentary subscriptions for members from a CSV file - -```sh -# See all available options -gctools add-member-comp-from-csv --help - -# Add complimentary subscriptions from a CSV file -gctools add-member-comp-from-csv - -# Custom delay between API calls -gctools add-member-comp-from-csv --delayBetweenCalls 200 -``` - -The CSV file must have columns: `email`, `expireAt`, `tierName` - -**Available options:** -- `--delayBetweenCalls` (default: 100): Delay between API calls in ms - - -### remove-member-comp-subscription - -Remove complimentary subscriptions for members - -```sh -# Remove a complimentary plan to tier ID abcdtierid1234, but only for members with the label slug 'my-member-label-slug' -gctools remove-member-comp-subscription --tierId abcdtierid1234 --onlyForLabelSlugs my-member-label-slug - -# Custom delay between API calls -gctools remove-member-comp-subscription --tierId abcdtierid1234 --delayBetweenCalls 200 -``` - -**Available options:** -- `--tierId`: The ID for the tier to remove the subscription from -- `--onlyForLabelSlugs`: Filter by member label slugs -- `--delayBetweenCalls` (default: 100): Delay between API calls in ms - - -### add-member-newsletter-subscription - -Add subscription for a specific newsletter - -```sh -# Add subscription to a specific newsletter to all members -gctools add-member-newsletter-subscription - -# Add subscription to a specific newsletter to all members that have a label slug of 'premium-blog' or 'news' -# Note: Slugs are not the same as names. You can get the label slug by filtering members and checking the URL -gctools add-member-newsletter-subscription --onlyForLabelSlugs 'premium-blog,news' -``` - - -### member-newsletter-backup - -Backup and restore member newsletter preferences. This tool allows you to save a snapshot of which newsletters each member is subscribed to, and restore those preferences later. - -```sh -# See all available options -gctools member-newsletter-backup --help - -# Backup all members' newsletter preferences to CSV -gctools member-newsletter-backup --backup ./backup.csv - -# Backup only members with specific labels -gctools member-newsletter-backup --backup ./premium-backup.csv --label premium --label vip - -# Preview what would be restored (dry run) -gctools member-newsletter-backup --restore ./backup.csv --dry-run - -# Restore newsletter preferences from backup -gctools member-newsletter-backup --restore ./backup.csv -``` - -The backup CSV contains columns: `id`, `email`, `name`, `newsletter_slugs` - -Newsletter slugs are stored as comma-separated values (e.g., `main-newsletter,weekly-digest`), making the file human-readable and portable across Ghost instances. - -**Available options:** -- `--backup`: Path to save backup CSV -- `--restore`: Path to CSV file to restore from -- `--dry-run`: Show what would be changed without making changes (restore only) -- `--label`: Filter members by label slug (backup only, can specify multiple) -- `--delayBetweenCalls` (default: 50): Delay between API calls in ms - -**Notes:** -- Cannot combine `--backup` with `--restore` -- Members are matched by email address during restore (more reliable than ID across instances) -- Use `--dry-run` before restoring to preview changes - - -### split-members - -Split members into two evenly-distributed halves, and optionally bulk-label each group. Members can be **fetched live from the Ghost Admin API** (optionally filtered) or read from a **CSV file**. They are sorted by `created_at`, then split using a zipper pattern (index 0→A, 1→B, 2→A, 3→B…), producing two balanced groups useful for segmented migrations or A/B campaigns. - -```sh -# See all available options -gctools split-members --help - -# Split an existing members CSV file -gctools split-members /path/to/members.csv - -# Fetch all members from Ghost, then split -gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd - -# Fetch only the members matching a filter, then split. --filter accepts an NQL -# filter, or a URL pasted straight from the Ghost members screen -# (Admin → Members → set a filter → copy the browser URL) -gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd --filter "status:free" -gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd \ - --filter "https://example.com/ghost/#/members?filter=created_at%3A%3C%3D%272026-06-02T03%3A59%3A59.999Z%27%2Bstatus%3Afree" - -# Custom output directory and filename prefix -gctools split-members /path/to/members.csv --output ./output --baseName campaign-members - -# Split AND bulk-add a label to each group (labels are created if they don't exist) -gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd --filter "status:free" \ - --addLabels --labelA "Experiment A" --labelB "Experiment B" --chunkSize 100 -``` - -This produces three CSV files: -- `members-all.csv` — all members sorted by `created_at` ascending -- `members-a.csv` — even-index half (0, 2, 4…) -- `members-b.csv` — odd-index half (1, 3, 5…) - -Interleaving A and B reconstructs the original sorted order. When fetching from the API, the files use Ghost's native member-export columns (`id`, `email`, `name`, `note`, `subscribed_to_emails`, `complimentary_plan`, `stripe_customer_id`, `created_at`, `deleted_at`, `labels`, `tiers`) so they can be re-imported. When reading a CSV, the columns match whatever is in the input file. - -With `--addLabels`, after the split the tool resolves `--labelA`/`--labelB` to label IDs (creating any that don't exist), then bulk-adds label A to group A and label B to group B via the Admin API `members/bulk/` endpoint, in chunks of `--chunkSize` members per request and rate-limited to `--requestsPerSecond` requests per second. - -**Available options:** -- `--output` (default: `.`): Output directory for CSV files -- `--baseName` (default: `members`): Filename prefix for output files -- `--apiURL`: URL to your Ghost API (required when no CSV path is given) -- `--adminAPIKey`: Admin API key (required when no CSV path is given) -- `--filter`: Filter members when fetching — an NQL filter (e.g. `status:free`) or a pasted Ghost members URL -- `--addLabels` (default: `false`): After splitting, bulk-add a label to each group -- `--labelA`: Label name (or 24-character hex ID) to add to group A (required with `--addLabels`) -- `--labelB`: Label name (or 24-character hex ID) to add to group B (required with `--addLabels`) -- `--chunkSize` (default: `100`): Members per bulk label request -- `--requestsPerSecond` (default: `5`): Maximum bulk label API requests per second - -**Notes:** -- Provide **either** a CSV path **or** `--apiURL` + `--adminAPIKey`. If both are given, the CSV is used. -- `--addLabels` always needs `--apiURL`, `--adminAPIKey`, `--labelA`, and `--labelB` — even when splitting from a CSV (which must contain a member `id` column). -- To label an existing CSV without splitting, use [`add-label-to-members`](#add-label-to-members) instead. - - -### add-label-to-members - -Add a label to all members listed in a CSV file, using the Ghost Admin API bulk endpoint. This is useful after `split-members` to apply a label to one of the output groups. - -```sh -# See all available options -gctools add-label-to-members --help - -# Add a label by name (will look up or create the label) -gctools add-label-to-members https://example.com 1234:abcd /path/to/members-a.csv "Group A" - -# Add a label by ID (skips the lookup) -gctools add-label-to-members https://example.com 1234:abcd /path/to/members-a.csv 69bacf7ebd018900018563b5 -``` - -The `` tags) that are not inside media cards. + +```sh +# Inline all external media across all posts and pages +gctools inline-media + +# Only process a single post by slug or ID +gctools inline-media --slug 'my-post-slug' +gctools inline-media --id '69d78ee0149bd000016f2852' + +# Only process posts (not pages) +gctools inline-media --type posts + +# Only process media from specific domains (also catches files in links) +gctools inline-media --assetDomains 'cdn.example.com, images.example.com' + +# Dry run to see what would be processed +gctools inline-media --dryRun + +# Dry run with verbose output to see which URLs would be inlined +gctools inline-media --dryRun -V + +# Only process published posts by a specific author +gctools inline-media --status published --author 'example-author' + +# Only process posts with a specific tag +gctools inline-media --tag 'imported' +``` + +**Available options:** +- `--slug`: Fetch a single post by its slug +- `--id`: Fetch a single post by its ID +- `--type` (default: all): Content type (all, posts, pages) +- `--status` (default: all): Post status (all, draft, published) +- `--visibility` (default: all): Post visibility (all, public, members, paid) +- `--tag`: Filter by tag slugs (comma separated) +- `--author`: Filter by author slugs (comma separated) +- `--afterAndOnDate`: Select posts published on or after this date (inclusive). Format: YYYY-MM-DD, i.e. '2024-01-01' +- `--beforeAndOnDate`: Select posts published on or before this date (inclusive). Format: YYYY-MM-DD, i.e. '2024-12-31' +- `--assetDomains`: Comma separated list of domains to process media from +- `--dryRun` (default: false): Show what would be processed without downloading or uploading +- `-V --verbose` (default: false): Show verbose output (lists URLs found per post) +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms + +## update-posts-from-json + +Update live Ghost posts using data from a Ghost export JSON file. Posts are matched by slug, and you choose which fields to update. Only posts with actual changes are modified — unchanged posts are skipped. Changed posts are tagged with an internal `#edited-` tag for easy identification. + +```sh +# See all available options +gctools update-posts-from-json --help + +# Dry run to preview what would change +gctools update-posts-from-json /path/to/export.json --fields 'title,lexical' --dryRun + +# Update title and lexical content from the JSON file +gctools update-posts-from-json /path/to/export.json --fields 'title,lexical' + +# Update only a single post by slug +gctools update-posts-from-json /path/to/export.json --fields 'title,lexical' --slug 'my-post-slug' + +# Update multiple metadata fields +gctools update-posts-from-json /path/to/export.json --fields 'meta_title,meta_description,og_title,og_description' + +# Update feature images +gctools update-posts-from-json /path/to/export.json --fields 'feature_image,feature_image_alt,feature_image_caption' + +# Update post authors (matched to existing Ghost users by email) +gctools update-posts-from-json /path/to/export.json --fields 'authors' + +# Custom delay between API calls +gctools update-posts-from-json /path/to/export.json --fields 'title' --delayBetweenCalls 100 +``` + +The JSON file should be in the standard Ghost export format (`{db: [{data: {posts: [...]}}]}`). + +**Available fields:** `title`, `slug`, `html`, `lexical`, `feature_image`, `feature_image_alt`, `feature_image_caption`, `custom_excerpt`, `meta_title`, `meta_description`, `og_title`, `og_description`, `og_image`, `twitter_title`, `twitter_description`, `twitter_image`, `status`, `visibility`, `canonical_url`, `codeinjection_head`, `codeinjection_foot`, `authors` + +**Updating authors:** Include `authors` to set each post's author(s) from the export. Authors are resolved through the export's `posts_authors` → `users` join and matched to existing Ghost users **by email**, so the matching users must already exist on the destination site (this command does not create them) and the export must include the `users` and `posts_authors` data. Author order from the export is preserved (first author = primary), and posts that have no authors in the export are left unchanged. + +**Available options:** +- `--fields`: Comma-separated list of fields to update (required, unless `--insertMissing` is set) +- `--slug`: Only update the post with this slug (fetches just that one post from Ghost) +- `--insertMissing` (default: false): Create posts from the JSON that don't exist in Ghost (matched by slug). New posts default to draft and are tagged `#added-` +- `--skipExisting` (default: false): Skip updating posts that already exist in Ghost (typically paired with `--insertMissing`) +- `--dryRun` (default: false): Preview what would be changed without making any updates +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output, including a progress bar while posts are fetched from Ghost + +## seed-demo + +Seed a Ghost site with rich demo content in one run: posts with feature images and in-post image cards, a distributed tag set, an `/about` page, a "golden" post at `/style-guide`, a navigation menu (Home, About, Style Guide, Author, Collection), and optionally a dummy author attributed across the content. + +The Style Guide doubles as a front-end theme stress test. It covers every editor card — header (dark/light/accent/image variants), image (regular/wide/full, linked, no-caption, adjacent), multi-row gallery, audio, video, file download, callout (full colour palette + no-emoji), toggle, bookmark (with thumbnail), button (center/left), product, signup, embed, code block, call-to-action, markdown, HTML and divider — plus the email-only `email` and `email-cta` cards, a full inline-formatting sampler (bold, italic, strikethrough, underline, inline code, highlight, super/subscript, links), heading levels h2–h6, nested lists, a table, and edge cases (long headings/unbroken strings). The paywall (public preview) card is included automatically when the site has members enabled. Card shapes mirror `@tryghost/kg-default-nodes`, so everything remains fully editable in Ghost Admin. + +Images are downloaded from [Lorem Picsum](https://picsum.photos/) and re-uploaded to the site so the result is self-contained; the Style Guide's audio and video are sourced from [lorem.media](https://lorem.media/) (all permissively licensed for commercial use). Images, audio and video are chosen at random, so do review these before using the site in public demos. + +```sh +# See all available options +gctools seed-demo --help + +# Seed 10 posts plus an about page, style guide, and navigation +gctools seed-demo + +# Seed 50 posts, feature images on half of them, no in-post image cards +gctools seed-demo --count 50 --featureImages 50% --imageCards false + +# Spread post dates across a range (defaults the end date to now) +gctools seed-demo --count 24 --dateStart 2025-01-01 --dateEnd 2025-12-31 + +# Add a dummy author (requires a staff access token) as primary author on ~40% of posts +gctools seed-demo --addAuthor --authorShare 40 + +# Seed content and also set navigation automatically (pass an owner/admin staff access token as the key) +gctools seed-demo + +# Preview what would be created without writing anything +gctools seed-demo --dryRun +``` + +> **Navigation & staff access tokens:** Updating the navigation menu writes to Ghost's settings, which integration API keys are not permitted to do. Supply an owner/admin [staff access token](https://docs.ghost.org/admin-api#staff-access-token-authentication) (found on a staff user's profile page, in the same `{id}:{secret}` format) as the `` argument and navigation is written automatically. With an integration key everything else still runs; the navigation step is skipped and the menu JSON is printed for you to paste into Ghost Admin → Settings → Navigation. +> +> **Dummy author & staff access tokens:** `--addAuthor` creates a placeholder staff user (with a hosted profile image and bio) and attributes it to the Style Guide (as a second author) and a random share of posts (as primary author). Creating a staff user uses Ghost's content import endpoint, which only an owner/admin [staff access token](https://docs.ghost.org/admin-api#staff-access-token-authentication) can write to — no invite email is sent. Re-runs reuse the existing author rather than duplicating it. With an integration key the step is skipped and an importable JSON file is printed for you to load via Ghost Admin → Settings → Labs → Import content. A Contributor can be a post's primary author; it just can't publish (the API key does the publishing here). + +**Available options:** +- `-C --count` (default: 10): Number of demo posts to create +- `--featureImages` (default: all): `all`, `none`, or a percentage like `50%` +- `--imageCards` (default: true): Insert 0-3 in-post image cards (weighted low, never before the first paragraph) +- `--maxImageCards` (default: 3): Maximum in-post image cards per post +- `--tags` (default: true): Distribute the five fixed tags (lorem, ipsum, dolor, sit, amet) across posts +- `--extraTags` (default: 0): Additional random tags to generate and distribute (max 30) +- `--collectionTag` (default: lorem): Tag whose archive backs the "Collection" nav item +- `--aboutPage` (default: true): Create or overwrite the `/about` page +- `--styleGuide` (default: true): Create the Style Guide post +- `--addAuthor` (default: false): Create a dummy author and attribute it to the Style Guide (second author) and a share of posts (primary author). Requires an owner/admin staff access token; with an integration key the step is skipped and importable JSON is printed +- `--authorShare` (default: 30): Percentage of posts (0-100) on which the dummy author is the primary author +- `--authorName` (default: Sam Example): Display name for the dummy author +- `--authorEmail`: Email for the dummy author (default: derived from the name, e.g. `sam-example@example.com`) +- `--authorRole` (default: Contributor): Role for the dummy author (`Contributor`, `Author`, `Editor`, `Administrator`) +- `--nav` (default: true): Update the navigation menu (requires an owner/admin staff access token; with an integration key the step is skipped and the menu JSON is printed to paste manually) +- `--authorNav` (default: true): Include the "Author" archive link +- `--collectionNav` (default: true): Include the "Collection" archive link +- `--status` (default: published): Status for created posts (published, draft) +- `--visibility` (default: public): Visibility for created posts (public, members, paid) +- `--dateStart`: Distribute post dates from this date (e.g. `2025-01-01`). Posts are spread evenly across the range with random jitter inside each slot, so they look naturally paced rather than clustered. Without it, every post is dated now. (Interactive mode offers "Past year" / "Past month" / "Custom" presets.) +- `--dateEnd` (default: now): End of the post date range; only used with `--dateStart` +- `--dryRun` (default: false): Preview without writing to the site +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +> **Asset licensing:** None of the seeded media requires attribution. +> - **Images** come from [Lorem Picsum](https://picsum.photos/), drawn from an Unsplash pool collected under CC0 (public domain), so no credit is required. Note this is "CC0-era" Unsplash content — fine for placeholders and demos, but source properly-licensed originals if you need production brand imagery. +> - **Video & photos** from [lorem.media](https://lorem.media/) use the [Pexels](https://www.pexels.com/license/) and [Pixabay](https://pixabay.com/service/license-summary/) licenses — free for commercial use, attribution not required (only appreciated). +> - **Audio** from lorem.media comes from the Internet Archive 78rpm collection and is public domain (recordings from 1925 or earlier). +> +> lorem.media exposes optional per-asset attribution in its response headers (`X-Attribution`, `X-Source`, `X-Source-Url`) and via its `/v2/info/...` JSON endpoint if you ever want to surface credits, but it is not a licensing requirement. As with any random placeholder media, review the generated assets before using a site for a public demo. + +## export-comments + +Export all comments from a Ghost site as a CSV file. The output is compatible with the [Ghost Comment Importer](https://github.com/TryGhost/comment-importer), making it easy to migrate comments between Ghost sites. + +```sh +# See all available options +gctools export-comments --help + +# Export all published comments +gctools export-comments + +# Export comments with a specific status +gctools export-comments --status all + +# Export only hidden comments +gctools export-comments --status hidden + +# Verbose output +gctools export-comments -V +``` + +The exported CSV includes the columns required by the comment importer (`id`, `name`, `email`, `created_at`, `html`, `reply_to`, `post_id`) plus additional reference columns (`post_title`, `post_slug`, `post_url`) for easier review. + +The tool first tries the site-wide Admin API comments endpoint (available on newer Ghost versions). If unavailable, it falls back to fetching comments per-post via the public Members API and resolving member emails through the Admin API — making it compatible with Ghost 5. + +**Available options:** +- `--status` (default: `published`): Filter by comment status (`all`, `published`, `hidden`) +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +## keep-tags + +Delete every tag that is *not* listed in a CSV of tags to keep. Useful for reconciling a site's tags against a canonical list. + +**CSV format:** only the first column is read, and the first row is always treated as a header — whatever it's called. Values are matched ignoring case and surrounding whitespace, and duplicates are collapsed. + +By default values are matched against the tag **slug**: + +```csv +slug +news +how-to +product-updates +``` + +Use `--matchBy name` when your list is of human-readable tag **names**: + +```csv +name +News +How To +Product Updates +``` + +Use `--matchBy either` for a list that mixes the two — each value is tried as a slug first, then as a name: + +```csv +tag +news +How To +product-updates +``` + +Since the header name doesn't matter and extra columns are ignored, a CSV exported from somewhere else works as-is, as long as the column you want to match on is first: + +```csv +name,slug,post_count +News,news,42 +How To,how-to,17 +Product Updates,product-updates,8 +``` + +That file matches on `name`, so it needs `--matchBy name` (or `either`). Reorder the columns to match on `slug` instead. + +Note that a name and a slug often normalise to the same thing — `News` and `news` both become `news` — so a list of simple one-word names will match on slug without any flag. `--matchBy` only matters for tags whose name differs from its slug, like `How To` vs `how-to`. + +A report CSV is always written next to the input CSV, on both dry and real runs. It lists every tag with an `action` of `kept`, `kept_internal`, `would_delete`, `deleted` or `delete_failed`, plus a `not_in_ghost` row for each CSV value that matched no tag. + +> **Safety:** internal tags (`#internal`) are never deleted, regardless of the CSV or `--force`. The command also aborts without deleting anything if the CSV yields no values, or if not a single CSV value matches a tag — either almost always means the wrong column or the wrong `--matchBy`. + +```sh +# See all available options +gctools keep-tags --help + +# Report what would be deleted, without changing anything +gctools keep-tags /path/to/tags.csv --dryRun + +# Delete every tag whose slug is not in the CSV +gctools keep-tags /path/to/tags.csv + +# Match against tag names instead of slugs +gctools keep-tags /path/to/tags.csv --matchBy name + +# Match against either the slug or the name +gctools keep-tags /path/to/tags.csv --matchBy either +``` + +**Available options:** +- `--dryRun` (default: false): Report which tags would be deleted, without deleting anything +- `--matchBy` (default: `slug`): Match CSV values against the tag `slug`, `name`, or `either`. Note that `either` makes the "nothing matched" safety check much less likely to catch a wrong CSV +- `--force` (default: false): Bypass the safety checks that abort when the CSV is empty or matches no tags. Internal tags are still never deleted +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output diff --git a/docs/members.md b/docs/members.md new file mode 100644 index 00000000..7715c2ff --- /dev/null +++ b/docs/members.md @@ -0,0 +1,294 @@ +# Members commands + +Member operations via the Ghost Admin API and member CSV files — complimentary +subscriptions, newsletter preferences, labels, and CSV comparison tools. + +> `gctools --help` is always authoritative for the current flags and defaults. + +## add-member-comp-subscription + +Add complimentary subscriptions for members + +```sh +# Add a complimentary plan to tier ID abcdtierid1234 that expired on May 4th 2025, but only for members with the label slug 'my-member-label-slug' +gctools add-member-comp-subscription --tierId abcdtierid1234 --expireAt '2025-05-04T00:00:00.000Z' --onlyForLabelSlugs my-member-label-slug + +# Custom delay between API calls +gctools add-member-comp-subscription --tierId abcdtierid1234 --delayBetweenCalls 200 +``` + +**Available options:** +- `--tierId`: The ID for the tier to add the subscription to +- `--expireAt`: Expiration date in ISO format +- `--onlyForLabelSlugs`: Filter by member label slugs +- `--delayBetweenCalls` (default: 100): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +## remove-member-comp-subscription + +Remove complimentary subscriptions for members + +```sh +# Remove a complimentary plan to tier ID abcdtierid1234, but only for members with the label slug 'my-member-label-slug' +gctools remove-member-comp-subscription --tierId abcdtierid1234 --onlyForLabelSlugs my-member-label-slug + +# Custom delay between API calls +gctools remove-member-comp-subscription --tierId abcdtierid1234 --delayBetweenCalls 200 +``` + +**Available options:** +- `--tierId`: The ID for the tier to remove the subscription from +- `--onlyForLabelSlugs`: Filter by member label slugs +- `--delayBetweenCalls` (default: 100): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +## add-member-newsletter-subscription + +Add subscription for a specific newsletter + +```sh +# Add subscription to a specific newsletter to all members +gctools add-member-newsletter-subscription + +# Add subscription to a specific newsletter to all members that have a label slug of 'premium-blog' or 'news' +# Note: Slugs are not the same as names. You can get the label slug by filtering members and checking the URL +gctools add-member-newsletter-subscription --onlyForLabelSlugs 'premium-blog,news' +``` + +**Available options:** +- `--onlyForLabelSlugs`: Optional label to filter members +- `--delayBetweenCalls` (default: 100): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +## dedupe-members-csv + +Create new CSV files that only contain new or updated members, by comparing the existing members with the output from the output from [`@tryghost/migrate`](https://github.com/TryGhost/migrate/tree/main/packages/mg-substack-members-csv). + +```sh +# See all available options +gctools dedupe-members-csv --help + +gctools dedupe-members-csv [new-free] [new-comp] [new-paid] +``` + +**Available options:** +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +## compare-member-csv + +Compare two member CSV files to identify new, updated, and unsubscribed members. This tool is useful for tracking membership changes between two exports. + +The tool will generate up to three output files in the same directory as your source files: +- `new.csv` - Members present in the new file but not in the old file (new signups) +- `unsubscribed.csv` - Members present in the old file but not in the new file (cancellations/unsubscribes) +- `updated.csv` - Members present in both files but with changes (e.g., new Stripe customer ID, label changes, subscription status changes) + +```sh +# See all available options +gctools compare-member-csv --help + +# Compare two member CSV files +gctools compare-member-csv + +# Compare with verbose output +gctools compare-member-csv --verbose + +# Example: Compare January and February exports +gctools compare-member-csv /path/to/members-jan-2024.csv /path/to/members-feb-2024.csv +``` + +The comparison uses email addresses as the unique identifier. All columns from the original CSV files are preserved in the output files. The tool handles case-insensitive email matching and ignores entries with missing or empty email fields. + +**What counts as an update:** +- Changes to Stripe customer ID +- Changes to subscription status (subscribed_to_emails) +- Changes to complimentary plan status +- Changes to labels + +**Output:** +- The tool displays statistics showing how many new, updated, and unsubscribed members were found +- Output files are only created when there are differences to report +- Files are saved in the same directory as the old/first CSV file + +**Available options:** +- `-V --verbose` (default: false): Show verbose output + +## delete-labels + +Delete member labels: the ones you name, the ones no member has, or all of them. With `--all` or `--unused` you can pass a list of labels to keep. Labels are matched by name or slug, ignoring case. + +The tool fetches the labels it needs with their member counts first, and stops if any request fails. With `--labels` it asks Ghost only for the labels you named, unless a name contains a quote or backslash; with `--all` or `--unused` it fetches every label. On SQLite, a name typed in a different case from the label in Ghost may not be found, unless it matches the label's slug. Such a label is reported as not in Ghost, and not deleted. The tool then lists what it would delete and asks you to confirm (default: **No**). There is no flag to skip that prompt. With `--unused`, each label's member count is checked again just before it's deleted, and labels that have gained members are skipped. A report CSV of every deleted, kept, skipped and failed label is written to the folder of `--keepFile`, or to the current folder. + +Deleting a label is expensive for Ghost: it loads and detaches the label's members and purges the site cache. Labels are therefore deleted one at a time with a delay between calls. Raise `--concurrency` with care. + +```sh +# See all available options +gctools delete-labels --help + +# Delete specific labels +gctools delete-labels --labels 'First' 'Second' + +# Preview, then delete, every label no member has +gctools delete-labels --unused --dryRun +gctools delete-labels --unused + +# Delete every label except the ones listed +gctools delete-labels --all --keep 'VIP' 'Paid' +gctools delete-labels --all --keepFile /path/to/labels-to-keep.csv + +# Delete every label +gctools delete-labels --all --force +``` + +Also available in `gctools i` as **Delete labels**. + +**Available options:** +- `--labels`: Names or slugs of the labels to delete +- `--all` (default: false): Delete every label. Requires `--keep`, `--keepFile` or `--force` +- `--unused` (default: false): Delete only labels that no member has +- `--keep`: Names or slugs of labels never to delete. Use with `--all` or `--unused` +- `--keepFile`: Path to a single-column CSV of labels never to delete. The first row is always treated as a header +- `--dryRun` (default: false): Report which labels would be deleted, without prompting or deleting +- `--force` (default: false): Bypass the checks for `--all` without a keep list, and for a keep list that is empty or matches nothing +- `--concurrency` (default: 1): How many labels to delete at once. If a delete fails, deletes already started finish and no new ones start +- `--timeout` (default: 60000): How long to wait for Ghost to answer each request, in ms. Raise it if deleting labels with very many members times out +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose`: List every label to delete, not just the first 20 + +## add-member-comp-from-csv + +Add complimentary subscriptions for members from a CSV file + +```sh +# See all available options +gctools add-member-comp-from-csv --help + +# Add complimentary subscriptions from a CSV file +gctools add-member-comp-from-csv + +# Custom delay between API calls +gctools add-member-comp-from-csv --delayBetweenCalls 200 +``` + +The CSV file must have columns: `email`, `expireAt`, `tierName` + +**Available options:** +- `--delayBetweenCalls` (default: 100): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +## member-newsletter-backup + +Backup and restore member newsletter preferences. This tool allows you to save a snapshot of which newsletters each member is subscribed to, and restore those preferences later. + +```sh +# See all available options +gctools member-newsletter-backup --help + +# Backup all members' newsletter preferences to CSV +gctools member-newsletter-backup --backup ./backup.csv + +# Backup only members with specific labels +gctools member-newsletter-backup --backup ./premium-backup.csv --label premium --label vip + +# Preview what would be restored (dry run) +gctools member-newsletter-backup --restore ./backup.csv --dry-run + +# Restore newsletter preferences from backup +gctools member-newsletter-backup --restore ./backup.csv +``` + +The backup CSV contains columns: `id`, `email`, `name`, `newsletter_slugs` + +Newsletter slugs are stored as comma-separated values (e.g., `main-newsletter,weekly-digest`), making the file human-readable and portable across Ghost instances. + +**Available options:** +- `--backup`: Path to save backup CSV +- `--restore`: Path to CSV file to restore from +- `--dry-run` (default: false): Show what would be changed without making changes (restore only) +- `--label`: Filter members by label slug (backup only, can specify multiple) +- `--delayBetweenCalls` (default: 50): Delay between API calls in ms +- `-V --verbose` (default: false): Show verbose output + +**Notes:** +- Cannot combine `--backup` with `--restore` +- Members are matched by email address during restore (more reliable than ID across instances) +- Use `--dry-run` before restoring to preview changes + +## split-members + +Split members into two evenly-distributed halves, and optionally bulk-label each group. Members can be **fetched live from the Ghost Admin API** (optionally filtered) or read from a **CSV file**. They are sorted by `created_at`, then split using a zipper pattern (index 0→A, 1→B, 2→A, 3→B…), producing two balanced groups useful for segmented migrations or A/B campaigns. + +```sh +# See all available options +gctools split-members --help + +# Split an existing members CSV file +gctools split-members /path/to/members.csv + +# Fetch all members from Ghost, then split +gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd + +# Fetch only the members matching a filter, then split. --filter accepts an NQL +# filter, or a URL pasted straight from the Ghost members screen +# (Admin → Members → set a filter → copy the browser URL) +gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd --filter "status:free" +gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd \ + --filter "https://example.com/ghost/#/members?filter=created_at%3A%3C%3D%272026-06-02T03%3A59%3A59.999Z%27%2Bstatus%3Afree" + +# Custom output directory and filename prefix +gctools split-members /path/to/members.csv --output ./output --baseName campaign-members + +# Split AND bulk-add a label to each group (labels are created if they don't exist) +gctools split-members --apiURL https://example.com --adminAPIKey 1234:abcd --filter "status:free" \ + --addLabels --labelA "Experiment A" --labelB "Experiment B" --chunkSize 100 +``` + +This produces three CSV files: +- `members-all.csv` — all members sorted by `created_at` ascending +- `members-a.csv` — even-index half (0, 2, 4…) +- `members-b.csv` — odd-index half (1, 3, 5…) + +Interleaving A and B reconstructs the original sorted order. When fetching from the API, the files use Ghost's native member-export columns (`id`, `email`, `name`, `note`, `subscribed_to_emails`, `complimentary_plan`, `stripe_customer_id`, `created_at`, `deleted_at`, `labels`, `tiers`) so they can be re-imported. When reading a CSV, the columns match whatever is in the input file. + +With `--addLabels`, after the split the tool resolves `--labelA`/`--labelB` to label IDs (creating any that don't exist), then bulk-adds label A to group A and label B to group B via the Admin API `members/bulk/` endpoint, in chunks of `--chunkSize` members per request and rate-limited to `--requestsPerSecond` requests per second. + +**Available options:** +- `--output` (default: `.`): Output directory for CSV files +- `--baseName` (default: `members`): Filename prefix for output files +- `--apiURL`: URL to your Ghost API (required when no CSV path is given) +- `--adminAPIKey`: Admin API key (required when no CSV path is given) +- `--filter`: Filter members when fetching — an NQL filter (e.g. `status:free`) or a pasted Ghost members URL +- `--addLabels` (default: `false`): After splitting, bulk-add a label to each group +- `--labelA`: Label name (or 24-character hex ID) to add to group A (required with `--addLabels`) +- `--labelB`: Label name (or 24-character hex ID) to add to group B (required with `--addLabels`) +- `--chunkSize` (default: `100`): Members per bulk label request +- `--requestsPerSecond` (default: `5`): Maximum bulk label API requests per second +- `-V --verbose` (default: false): Show verbose output + +**Notes:** +- Provide **either** a CSV path **or** `--apiURL` + `--adminAPIKey`. If both are given, the CSV is used. +- `--addLabels` always needs `--apiURL`, `--adminAPIKey`, `--labelA`, and `--labelB` — even when splitting from a CSV (which must contain a member `id` column). +- To label an existing CSV without splitting, use [`add-label-to-members`](#add-label-to-members) instead. + +## add-label-to-members + +Add a label to all members listed in a CSV file, using the Ghost Admin API bulk endpoint. This is useful after `split-members` to apply a label to one of the output groups. + +```sh +# See all available options +gctools add-label-to-members --help + +# Add a label by name (will look up or create the label) +gctools add-label-to-members https://example.com 1234:abcd /path/to/members-a.csv "Group A" + +# Add a label by ID (skips the lookup) +gctools add-label-to-members https://example.com 1234:abcd /path/to/members-a.csv 69bacf7ebd018900018563b5 +``` + +The `