@pintawebware/strapi-sync 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,322 @@
1
+ # strapi-sync
2
+
3
+ Export Strapi schemas and content to a snapshot file and apply snapshot changes back to Strapi.
4
+
5
+ ## Configuration
6
+
7
+ Optional config file in the project root: `.strapi-sync.json`, `strapi-sync.config.json`, or `strapi-sync.json`.
8
+
9
+ `strapi-sync` also loads `.env` from the project root before resolving environment fallback values.
10
+
11
+ `.env` example:
12
+
13
+ ```env
14
+ STRAPI_URL=https://cms.example.com
15
+ STRAPI_API_TOKEN=your-api-token
16
+ STRAPI_PROJECT_PATH=/var/www/strapi-app
17
+ STRAPI_SSH_HOST=cms.example.com
18
+ STRAPI_SSH_USER=deploy
19
+ STRAPI_SSH_PORT=22
20
+ STRAPI_SSH_PASSWORD=your-password
21
+ STRAPI_SSH_PRIVATE_KEY_PATH=C:/Users/Ivan/.ssh/id_rsa
22
+ ```
23
+
24
+ Minimal config example:
25
+
26
+ ```json
27
+ {
28
+ "strapiUrl": "https://your-strapi.example.com",
29
+ "apiToken": "your-api-token",
30
+ "strapiProjectPath": "/var/www/strapi-app",
31
+ "strapiSSHUser": "deploy",
32
+ "strapiSSHPassword": "your-password",
33
+ "strapiSSHPrivateKeyPath": "C:/Users/Ivan/.ssh/id_rsa"
34
+ }
35
+ ```
36
+
37
+ For local schema writes, set `strapiProjectPath` to the local project path.
38
+
39
+ For remote schema writes, set `strapiProjectPath` to the remote project path and add `strapiSSHHost` with either `strapiSSHPassword` or `strapiSSHPrivateKeyPath`.
40
+
41
+ Config values are applied in this order, from lower priority to higher priority:
42
+
43
+ 1. `.env` or process environment values for fallback-supported fields
44
+ 2. Project json config file
45
+ 3. CLI flags
46
+
47
+ Supported environment variables:
48
+
49
+ - `STRAPI_URL`
50
+ - `STRAPI_API_TOKEN`
51
+ - `STRAPI_PROJECT_PATH`
52
+ - `STRAPI_SSH_HOST`
53
+ - `STRAPI_SSH_USER`
54
+ - `STRAPI_SSH_PORT`
55
+ - `STRAPI_SSH_PASSWORD`
56
+ - `STRAPI_SSH_PRIVATE_KEY_PATH`
57
+
58
+ Only these exact environment variable names are read by the CLI.
59
+
60
+ Environment variables are used only when the corresponding CLI or config value is missing.
61
+
62
+ Supported config fields:
63
+
64
+ - `strapiUrl` - required
65
+ - `apiToken` - required
66
+ - `output` - optional, default `strapi-snapshot.json`
67
+ - `strapiProjectPath` - optional for export and content-only sync; required for local or remote schema writes
68
+ - `strapiSSHHost` - optional, enables remote SSH schema writes when set
69
+ - `updateSchema` - optional, default `true`
70
+ - `strapiSSHUser` - optional, default `root` for remote SSH writes
71
+ - `strapiSSHPort` - optional, default `22` for remote SSH writes
72
+ - `strapiSSHPassword` - optional, no default
73
+ - `strapiSSHPrivateKeyPath` - optional, no default
74
+
75
+ ## Usage
76
+
77
+ Export a snapshot from Strapi:
78
+
79
+ ```bash
80
+ strapi-sync --export
81
+ ```
82
+
83
+ Apply the current snapshot:
84
+
85
+ ```bash
86
+ strapi-sync
87
+ ```
88
+
89
+ Show package version:
90
+
91
+ ```bash
92
+ strapi-sync --version
93
+ ```
94
+
95
+ ## Options
96
+
97
+ - `--export` – Optional. Export from Strapi to snapshot and exit. Default behavior without this flag is apply mode.
98
+ - `--output <path>` – Optional. Snapshot file path. Default `strapi-snapshot.json`.
99
+ - `-p, --project <path>` – Optional. Project directory used for config and snapshot resolution. Default is current working directory.
100
+ - `-u, --strapi-url <url>` – Optional if `strapiUrl` exists in config. Otherwise required.
101
+ - `--api-token <token>` – Optional if `apiToken` exists in config. Otherwise required.
102
+ - `--config <path>` – Optional. Use a custom JSON config file instead of the auto-discovered one.
103
+ - `--strapi-project <path>` – Optional. Override `strapiProjectPath` from config or `STRAPI_PROJECT_PATH`.
104
+ - `--strapi-ssh-host <host>` – Optional. Set the SSH host for remote schema writes.
105
+ - `--strapi-ssh-user <user>` – Optional. Override the SSH user for remote schema writes.
106
+ - `--strapi-ssh-port <port>` – Optional. Override the SSH port for remote schema writes.
107
+ - `--strapi-ssh-password <pwd>` – Optional. Use SSH password auth for remote schema writes.
108
+ - `--strapi-ssh-key <path>` – Optional. Use a private key file for remote schema writes.
109
+ - `-h, --help` – Optional. Show help and exit.
110
+ - `-v, --version` – Optional. Show the installed package version and exit.
111
+
112
+ ## Behavior
113
+
114
+ - Running `strapi-sync` always prints a preview before confirmation.
115
+ - Running `strapi-sync --export` asks for confirmation before overwriting the snapshot file.
116
+ - Schema writes are controlled by `updateSchema` in config.
117
+ - When `strapiSSHHost` is set, `strapiProjectPath` is treated as a remote filesystem path and schema writes are executed over SSH.
118
+ - When `strapiSSHHost` is not set, `strapiProjectPath` is treated as a local filesystem path.
119
+ - Remote schema writes support password auth or private key auth.
120
+ - After any successful change, the snapshot is exported again from Strapi.
121
+ - Adding a field to schema without adding a value for that field in snapshot entries is treated as a schema-only change.
122
+ - Media fields are exported and shown in schema definitions, but content writes skip them.
123
+ - Unknown CLI arguments stop the command with an error.
124
+ - Invalid snapshot syntax stops the command with detailed validation errors.
125
+
126
+ ## Snapshot Format
127
+
128
+ The snapshot file contains:
129
+
130
+ - `version`
131
+ - `contentTypes`
132
+ - `components`
133
+
134
+ Component definitions live in the top-level `components` block:
135
+
136
+ ```json
137
+ {
138
+ "components": {
139
+ "shared.seo": {
140
+ "attributes": {
141
+ "metaTitle": "string",
142
+ "metaDescription": "string"
143
+ }
144
+ }
145
+ }
146
+ }
147
+ ```
148
+
149
+ Each content type includes `singleType`, `attributes`, and `entries`.
150
+
151
+ Collection type example:
152
+
153
+ ```json
154
+ {
155
+ "singleType": false,
156
+ "attributes": {
157
+ "title": "string"
158
+ },
159
+ "entries": [
160
+ {
161
+ "title": "Example"
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ Localized collection type example:
168
+
169
+ ```json
170
+ {
171
+ "singleType": false,
172
+ "attributes": {
173
+ "title": "string"
174
+ },
175
+ "entries": {
176
+ "en": [
177
+ {
178
+ "title": "Example"
179
+ }
180
+ ],
181
+ "uk": [
182
+ {
183
+ "title": "Приклад"
184
+ }
185
+ ]
186
+ }
187
+ }
188
+ ```
189
+
190
+ Single type example:
191
+
192
+ ```json
193
+ {
194
+ "singleType": true,
195
+ "attributes": {
196
+ "title": "string"
197
+ },
198
+ "entries": {
199
+ "title": "Example"
200
+ }
201
+ }
202
+ ```
203
+
204
+ Localized single type example:
205
+
206
+ ```json
207
+ {
208
+ "singleType": true,
209
+ "attributes": {
210
+ "title": "string"
211
+ },
212
+ "entries": {
213
+ "en": {
214
+ "title": "Example"
215
+ },
216
+ "uk": {
217
+ "title": "Приклад"
218
+ }
219
+ }
220
+ }
221
+ ```
222
+
223
+ For `singleType: false`, `entries` must be an array or an object with locale keys.
224
+
225
+ For `singleType: true`, `entries` must be a single object or an object with locale keys that point to objects.
226
+
227
+ Supported attribute examples:
228
+
229
+ - `string`
230
+ - `number`
231
+ - `boolean`
232
+ - `date`
233
+ - `json`
234
+ - `media`
235
+ - `media[]`
236
+ - `component:shared.seo`
237
+ - `component:shared.gallery:repeatable`
238
+ - `["shared.media", "shared.rich-text"]` for dynamic zones
239
+ - `relation:article:oneToMany`
240
+
241
+ Component field example:
242
+
243
+ ```json
244
+ {
245
+ "singleType": false,
246
+ "attributes": {
247
+ "seo": "component:shared.seo"
248
+ },
249
+ "entries": [
250
+ {
251
+ "seo": {
252
+ "metaTitle": "Example",
253
+ "metaDescription": "Example description"
254
+ }
255
+ }
256
+ ]
257
+ }
258
+ ```
259
+
260
+ Repeatable component example:
261
+
262
+ ```json
263
+ {
264
+ "singleType": false,
265
+ "attributes": {
266
+ "gallery": "component:shared.gallery:repeatable"
267
+ },
268
+ "entries": [
269
+ {
270
+ "gallery": [
271
+ {
272
+ "title": "Slide 1"
273
+ },
274
+ {
275
+ "title": "Slide 2"
276
+ }
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ ```
282
+
283
+ Dynamic zone example:
284
+
285
+ ```json
286
+ {
287
+ "singleType": false,
288
+ "attributes": {
289
+ "blocks": [
290
+ "shared.media",
291
+ "shared.rich-text"
292
+ ]
293
+ },
294
+ "entries": [
295
+ {
296
+ "blocks": [
297
+ {
298
+ "__component": "shared.rich-text",
299
+ "body": "Example text"
300
+ },
301
+ {
302
+ "__component": "shared.media",
303
+ "caption": "Example image"
304
+ }
305
+ ]
306
+ }
307
+ ]
308
+ }
309
+ ```
310
+
311
+ Relation attributes must use the short form like `relation:article:oneToMany`. During schema writes it is expanded to the full Strapi UID automatically.
312
+
313
+ Supported relation examples:
314
+
315
+ - `relation:article:oneToOne`
316
+ - `relation:article:oneToMany`
317
+ - `relation:article:manyToOne`
318
+ - `relation:article:manyToMany`
319
+ - `relation:article:oneWay`
320
+ - `relation:article:manyWay`
321
+ - `relation:article:morphOne`
322
+ - `relation:article:morphMany`
package/bin/cli.js ADDED
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ const readline = require('readline');
5
+ const { version } = require('../package.json');
6
+ const { StrapiSyncClient } = require('../lib/strapi-client');
7
+ const { getSchemaUpdater } = require('../lib/schema-writer');
8
+ const { parseArgs } = require('../lib/config');
9
+ const { exportSnapshot, loadSnapshotFile } = require('../lib/snapshot-io');
10
+ const { contentTypeDisplayName, formatContentForLog } = require('../lib/format');
11
+ const { computeChangeMaps, printPreview } = require('../lib/preview');
12
+ const { getContentTypeId } = require('../lib/snapshot-utils');
13
+ const {
14
+ fetchStrapiState,
15
+ computeComponentChanges,
16
+ getPreviewCounts,
17
+ getNonActionableNotes,
18
+ applySchemaChanges,
19
+ waitForStrapiReload,
20
+ applyContentChanges
21
+ } = require('../lib/sync-engine');
22
+
23
+ function askConfirmation(question) {
24
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
+ return new Promise((resolve) => {
26
+ rl.question(question, (answer) => {
27
+ rl.close();
28
+ resolve(/^y(es)?$/i.test(answer.trim()));
29
+ });
30
+ });
31
+ }
32
+
33
+ function printHelp() {
34
+ console.log(`
35
+ Usage: strapi-sync [options]
36
+
37
+ Commands:
38
+ strapi-sync Preview and apply snapshot changes
39
+ strapi-sync --export Export Strapi data to snapshot and exit
40
+
41
+ Options:
42
+ --output <path> Snapshot file path (default: strapi-snapshot.json)
43
+ -p, --project <path> Project directory used for config and snapshot paths
44
+ -u, --strapi-url <url> Strapi server URL
45
+ --api-token <token> Strapi API token
46
+ --config <path> Custom JSON config file
47
+ --strapi-project <path> Strapi project path for schema updates
48
+ --strapi-ssh-host <host> SSH host for remote schema updates
49
+ --strapi-ssh-user <user> SSH user for remote schema updates
50
+ --strapi-ssh-port <port> SSH port for remote schema updates
51
+ --strapi-ssh-password <pwd> SSH password for remote schema updates
52
+ --strapi-ssh-key <path> SSH private key for remote schema updates
53
+ -h, --help Show this help
54
+ -v, --version Show package version
55
+
56
+ Notes:
57
+ Media fields are exported but not applied back to Strapi.
58
+ `);
59
+ }
60
+
61
+ function resolveOutputPath(output, projectPath) {
62
+ return path.isAbsolute(output) ? output : path.resolve(projectPath, output);
63
+ }
64
+
65
+ async function main() {
66
+ const options = parseArgs();
67
+ if (options.help) {
68
+ printHelp();
69
+ process.exit(0);
70
+ }
71
+ if (options.version) {
72
+ console.log(version);
73
+ process.exit(0);
74
+ }
75
+ if (!options.apiToken) {
76
+ console.error('❌ Error: API token not specified. Set it in config file or pass via parameters.');
77
+ process.exit(1);
78
+ }
79
+ if (!options.strapiUrl) {
80
+ console.error('❌ Error: Strapi URL not specified. Set it in config file or use --strapi-url.');
81
+ process.exit(1);
82
+ }
83
+
84
+ const client = new StrapiSyncClient({
85
+ baseURL: options.strapiUrl,
86
+ apiToken: options.apiToken
87
+ });
88
+
89
+ if (options.export) {
90
+ try {
91
+ const outputPath = resolveOutputPath(options.output, options.projectPath);
92
+ console.log('');
93
+ const confirmed = await askConfirmation(`Export from Strapi and overwrite ${path.basename(outputPath)}? (y/N) `);
94
+ if (!confirmed) {
95
+ console.log('Aborted.');
96
+ process.exit(0);
97
+ }
98
+ console.log('📥 Exporting schema and content from Strapi...');
99
+ await exportSnapshot(client, outputPath);
100
+ console.log(`✓ Snapshot written: ${outputPath}`);
101
+ process.exit(0);
102
+ } catch (error) {
103
+ console.error('❌', error.message);
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ let groupedObjects = {};
109
+ let snapshotContentTypes = {};
110
+ let snapshotComponents = {};
111
+ let snapshotPath;
112
+
113
+ try {
114
+ snapshotPath = resolveOutputPath(options.output, options.projectPath);
115
+ console.log('📂 Loading snapshot...');
116
+ const loaded = loadSnapshotFile(snapshotPath);
117
+ groupedObjects = loaded.groupedObjects;
118
+ snapshotContentTypes = loaded.snapshotContentTypes ?? {};
119
+ snapshotComponents = loaded.snapshotComponents ?? {};
120
+ console.log(`✓ Loaded ${Object.keys(groupedObjects).length} content types`);
121
+ if (Object.keys(snapshotComponents).length > 0) {
122
+ console.log(`✓ Loaded ${Object.keys(snapshotComponents).length} components`);
123
+ }
124
+ if (Object.keys(groupedObjects).length === 0) {
125
+ console.log('⚠️ Snapshot has no content types.');
126
+ process.exit(0);
127
+ }
128
+ } catch (error) {
129
+ logCliError('❌', error.message);
130
+ process.exit(1);
131
+ }
132
+
133
+ console.log('📥 Fetching content types and entries from Strapi...');
134
+ const { apiTypes, existingSchemas, existingEntriesByType, apiIdByType } = await fetchStrapiState(
135
+ client,
136
+ groupedObjects
137
+ );
138
+ console.log('✓ Fetched\n');
139
+
140
+ const { schemaChangesByType, contentChangesByType, localizationChangesByType, singleTypeChangesByType } = computeChangeMaps(
141
+ groupedObjects,
142
+ snapshotContentTypes,
143
+ existingSchemas,
144
+ existingEntriesByType,
145
+ getContentTypeId
146
+ );
147
+ const onlyInStrapiContentTypes = apiTypes.map(getContentTypeId).filter((ct) => !(ct in groupedObjects));
148
+ const schemaUpdater = options.updateSchema && getSchemaUpdater(options);
149
+ let componentChanges = {
150
+ onlyInStrapiComponents: [],
151
+ onlyInSnapshotComponents: [],
152
+ componentSchemaChangesByUid: new Map()
153
+ };
154
+ try {
155
+ componentChanges = await computeComponentChanges(client, snapshotComponents, Boolean(schemaUpdater));
156
+ } catch (_) {}
157
+ const previewCounts = getPreviewCounts(
158
+ groupedObjects,
159
+ schemaChangesByType,
160
+ contentChangesByType,
161
+ onlyInStrapiContentTypes,
162
+ componentChanges,
163
+ Boolean(schemaUpdater)
164
+ );
165
+
166
+ if (previewCounts.detected > 0) {
167
+ printPreview(
168
+ groupedObjects,
169
+ snapshotContentTypes,
170
+ existingSchemas,
171
+ existingEntriesByType,
172
+ onlyInStrapiContentTypes,
173
+ schemaChangesByType,
174
+ contentChangesByType,
175
+ contentTypeDisplayName,
176
+ formatContentForLog,
177
+ componentChanges.onlyInStrapiComponents,
178
+ componentChanges.onlyInSnapshotComponents,
179
+ componentChanges.componentSchemaChangesByUid,
180
+ localizationChangesByType,
181
+ singleTypeChangesByType
182
+ );
183
+ }
184
+
185
+ getNonActionableNotes(
186
+ groupedObjects,
187
+ schemaChangesByType,
188
+ onlyInStrapiContentTypes,
189
+ componentChanges,
190
+ Boolean(schemaUpdater)
191
+ ).forEach((note) => console.log(`ℹ️ ${note}`));
192
+
193
+ if (previewCounts.actionable === 0) {
194
+ console.log('No actionable changes to apply.\n');
195
+ process.exit(0);
196
+ }
197
+
198
+ const confirmed = await askConfirmation('Proceed with actionable changes? (y/N) ');
199
+ if (!confirmed) {
200
+ console.log('Aborted.');
201
+ process.exit(0);
202
+ }
203
+ console.log('');
204
+
205
+ const schemaWasUpdated = await applySchemaChanges({
206
+ schemaUpdater,
207
+ snapshotComponents,
208
+ snapshotContentTypes,
209
+ onlyInSnapshotComponents: componentChanges.onlyInSnapshotComponents,
210
+ componentSchemaChangesByUid: componentChanges.componentSchemaChangesByUid,
211
+ schemaChangesByType,
212
+ localizationChangesByType,
213
+ singleTypeChangesByType,
214
+ existingSchemas,
215
+ onlyInStrapiContentTypes,
216
+ onlyInStrapiComponents: componentChanges.onlyInStrapiComponents
217
+ });
218
+
219
+ if (schemaWasUpdated) {
220
+ const ready = await waitForStrapiReload(client, options);
221
+ if (!ready) {
222
+ console.log('\n✅ Done (schema only).');
223
+ process.exit(0);
224
+ }
225
+ console.log('');
226
+ }
227
+
228
+ let activeSchemas = existingSchemas;
229
+ let activeEntriesByType = existingEntriesByType;
230
+ let activeApiIdByType = apiIdByType;
231
+ let activeContentChangesByType = contentChangesByType;
232
+
233
+ if (schemaWasUpdated) {
234
+ try {
235
+ const refetched = await fetchStrapiState(client, groupedObjects);
236
+ activeSchemas = refetched.existingSchemas;
237
+ activeEntriesByType = refetched.existingEntriesByType;
238
+ activeApiIdByType = refetched.apiIdByType;
239
+ const recomputed = computeChangeMaps(
240
+ groupedObjects,
241
+ snapshotContentTypes,
242
+ activeSchemas,
243
+ activeEntriesByType,
244
+ getContentTypeId
245
+ );
246
+ activeContentChangesByType = recomputed.contentChangesByType;
247
+ } catch (_) {}
248
+ }
249
+
250
+ const changesApplied = await applyContentChanges(
251
+ client,
252
+ groupedObjects,
253
+ snapshotContentTypes,
254
+ activeSchemas,
255
+ activeEntriesByType,
256
+ activeApiIdByType,
257
+ activeContentChangesByType,
258
+ schemaChangesByType
259
+ );
260
+
261
+ if (snapshotPath && (changesApplied || schemaWasUpdated)) {
262
+ console.log('\n📤 Updating snapshot from Strapi...');
263
+ await exportSnapshot(client, snapshotPath);
264
+ console.log(`✓ Snapshot updated: ${snapshotPath}`);
265
+ }
266
+
267
+ console.log('\n✅ Done.');
268
+ }
269
+
270
+ if (require.main === module) {
271
+ main().catch((error) => {
272
+ console.error('❌', error.message);
273
+ process.exit(1);
274
+ });
275
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ const { StrapiSyncClient } = require('./lib/strapi-client');
2
+
3
+ module.exports = {
4
+ StrapiSyncClient
5
+ };
package/lib/async.js ADDED
@@ -0,0 +1,22 @@
1
+ async function mapWithConcurrency(items, limit, iteratee) {
2
+ const list = Array.isArray(items) ? items : [];
3
+ const size = Math.max(1, Number(limit) || 1);
4
+ const results = new Array(list.length);
5
+ let nextIndex = 0;
6
+
7
+ async function worker() {
8
+ while (nextIndex < list.length) {
9
+ const currentIndex = nextIndex;
10
+ nextIndex += 1;
11
+ results[currentIndex] = await iteratee(list[currentIndex], currentIndex);
12
+ }
13
+ }
14
+
15
+ const workerCount = Math.min(size, list.length || 1);
16
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
17
+ return results;
18
+ }
19
+
20
+ module.exports = {
21
+ mapWithConcurrency
22
+ };