Skip to content

Conversation

@maoberlehner
Copy link
Contributor

@maoberlehner maoberlehner commented Dec 15, 2025

Add stories pull command to pull stories from a space.
Fixes WDX-216

Add stories push CLI command to upload local JSON stories to a
space, correctly remapping story references using component schemas and
a manifest for resumable runs. This enables safe space-to-space story
sync, including circular references and third‑party IDs.
Fixes WDX-134


Note

Introduces end-to-end CLI workflows for migrating content between spaces or from external systems.

  • New assets and stories commands with pull/push subcommands, docs, and extensive tests
  • Assets: folder fetch/create/update, signed S3 uploads/finalize, local file+JSON pairing, SHA-256 diffing for updates, optional cleanup, and JSONL manifest append for resumable runs
  • Stories: pull paginated lists to per-story JSON; push local stories with component-schema–based reference mapping; optional --publish and --update-stories
  • Pipelines/streams: high-throughput fetch/download/write and upsert flows with progress bars and summaries
  • Operational fixes: use directories.logs/reports paths in logs/reports commands; refactor error imports to utils/error/*; type updates to use MAPI resource types

Written by Cursor Bugbot for commit f368eb1. This will update automatically on new commits. Configure here.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces CLI commands for pulling and pushing stories between Storyblok spaces, enabling space-to-space synchronization and third-party CMS migrations. The implementation includes reference mapping capabilities to handle circular dependencies and story relationships, along with resumable push operations via a manifest system.

Key Changes:

  • Added stories pull command to download stories from a space as JSON files
  • Added stories push command to upload local stories with automatic reference remapping
  • Implemented reference mapping system for story IDs and UUIDs across fields (richtext, multilink, bloks, options)

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/cli/src/commands/stories/pull/index.ts Implements the pull command to fetch and save stories locally
packages/cli/src/commands/stories/push/index.ts Implements the push command with two-pass processing for story creation and reference updates
packages/cli/src/commands/stories/streams.ts Provides stream-based processing utilities for fetching, reading, mapping, and writing stories
packages/cli/src/commands/stories/ref-mapper.ts Core reference mapping logic that traverses story content and remaps IDs using component schemas
packages/cli/src/commands/stories/utils.ts Utility functions including component schema discovery
packages/cli/src/utils/filesystem.ts Enhanced filesystem utilities with write locking for concurrent operations
packages/cli/src/constants.ts Added stories directory constant and color palette entry
packages/cli/src/program.ts Fixed directory constant references from singular to plural form

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

import type { SpaceDatasource } from '../constants';
import { vol } from 'memfs';
import { FileSystemError } from '../../../utils';
import { FileSystemError } from '../../../utils/error/filesystem-error';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed to make those changes because importing over the utils/index.ts file leads to circular imports breaking the tests.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is being reviewed by Cursor Bugbot

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 15, 2025

Open in StackBlitz

@storyblok/astro

npm i https://pkg.pr.new/@storyblok/astro@397

storyblok

npm i https://pkg.pr.new/storyblok@397

@storyblok/eslint-config

npm i https://pkg.pr.new/@storyblok/eslint-config@397

@storyblok/js

npm i https://pkg.pr.new/@storyblok/js@397

storyblok-js-client

npm i https://pkg.pr.new/storyblok-js-client@397

@storyblok/management-api-client

npm i https://pkg.pr.new/@storyblok/management-api-client@397

@storyblok/nuxt

npm i https://pkg.pr.new/@storyblok/nuxt@397

@storyblok/react

npm i https://pkg.pr.new/@storyblok/react@397

@storyblok/region-helper

npm i https://pkg.pr.new/@storyblok/region-helper@397

@storyblok/richtext

npm i https://pkg.pr.new/@storyblok/richtext@397

@storyblok/svelte

npm i https://pkg.pr.new/@storyblok/svelte@397

@storyblok/vue

npm i https://pkg.pr.new/@storyblok/vue@397

commit: fa26422

finally {
logger.info('Pushing stories finished', summary);
ui.stopAllProgressBars();
const failedStories = Math.max(summary.creationResults.failed, summary.processResults.failed, summary.updateResults.failed);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Failure count uses max instead of sum across stages

The failure count calculation uses Math.max across different stages, but each stage tracks failures for different stories. When Story A fails at creation and Story B fails at update, Math.max(1, 0, 1) returns 1 instead of the correct total of 2 failures. The same issue exists in the pull command where Math.max(summary.fetchStories.failed, summary.save.failed) undercounts when stories fail at both fetch and save stages. The failures across stages should be summed, not max'd, since they represent distinct story failures.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maoberlehner what do you think on this point? seems legit

const mappedRemoteStory = mappedStoryId && await getRemoteStory({ spaceId, storyId: Number(mappedStoryId) });
// We check the UUID to make sure it is the exact same story and not just a
// story with the same numeric ID in a different space.
if (mappedRemoteStory) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Missing UUID check despite comment claiming verification

The comment on lines 312-313 states "We check the UUID to make sure it is the exact same story and not just a story with the same numeric ID in a different space." However, line 314 only checks if (mappedRemoteStory) without any UUID verification. Contrast this with lines 323-325 where the UUID check is correctly performed (existingRemoteStory.uuid === localStory.uuid). The missing check could cause incorrect behavior when resuming a push if the manifest points to a story ID that exists in the target space but belongs to a different story.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maoberlehner could the comment be outdated?

.filter(f => path.extname(f) === '.json')
.map((f) => {
const filePath = path.join(directoryPath, f);
const fileContent = readFileSync(filePath, 'utf-8');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): just to be extra safe in case there are many large files, using readFile (async) would prevent from blocking main thread

logger.info('Pushing stories finished', summary);
ui.stopAllProgressBars();
const failedStories = Math.max(summary.creationResults.failed, summary.processResults.failed, summary.updateResults.failed);
ui.info(`Push results: ${summary.creationResults.total} ${summary.creationResults.total === 1 ? 'story' : 'stories'} pushed, ${failedStories} ${failedStories === 1 ? 'story' : 'stories'} failed`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: creationResults are the stories placeholders created, but not the full story pushed, correct? If so, maybe we can instead inform with something like stories succeded/skipped to avoid confusion?

Copy link
Contributor

@alexjoverm alexjoverm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sent an internal message with feedback on the approach - blocking the merge for now until discussion.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is being reviewed by Cursor Bugbot

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

* Map Asset References in Stories
*/
// TODO test
const hasNewFileReferences = maps.assets.entries().some(([k, v]) => k !== v);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterator helper .some() may fail on older Node.js

High Severity

The call maps.assets.entries().some(([k, v]) => k !== v) uses iterator helpers (.some() on an iterator) which are only available in Node.js 22+ (V8 12.x). Without an engines field in package.json requiring Node.js 22+, users on Node.js 18 or 20 LTS will get a runtime TypeError: maps.assets.entries(...).some is not a function. The fix would be to convert to an array first, e.g. [...maps.assets.entries()].some(...) or Array.from(maps.assets).some(...).

Fix in Cursor Fix in Web

fetchStoryPages: { total: 0, succeeded: 0, failed: 0 },
fetchStories: { total: 0, succeeded: 0, failed: 0 },
processResults: { total: 0, succeeded: 0, failed: 0 },
updateResults: { total: 0, succeeded: 0, failed: 0 },
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary totals never initialized, causing negative values

Medium Severity

In mapAssetReferencesInStoriesPipeline, summaries.processResults.total and summaries.updateResults.total are initialized to 0 but never set to the actual story count from setTotalStories. However, onStoryError decrements summaries.updateResults.total by 1 (line 260), causing negative totals. The progress bar is then set to this negative value (line 264), resulting in broken progress reporting. The TODO comment at line 210 indicates this is incomplete work.

Additional Locations (1)

Fix in Cursor Fix in Web

const { headers } = result;
const total = Number(headers.get('Total'));
perPage = Number(headers.get('Per-Page'));
totalPages = Math.ceil(total / perPage);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stories stream missing header fallback causes pagination failure

Medium Severity

The fetchStoriesStream function lacks defensive handling that exists in the equivalent fetchAssetsStream. Line 55 sets perPage = Number(headers.get('Per-Page')) without a fallback (unlike assets which uses || perPage), and line 56 calculates totalPages without Math.max(1, ...). If the API response lacks a Per-Page header, perPage becomes NaN, causing totalPages to be NaN. The loop condition page <= NaN evaluates to false, causing the stream to exit after processing only the first page even when more pages exist.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants