@pixlcore/xycli 1.0.7 → 1.0.8
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/docs/help.md +8 -1
- package/docs/sync.md +23 -2
- package/docs/two-way-git-tutorial.md +14 -2
- package/index.js +10 -4
- package/lib/buckets.js +1 -5
- package/lib/sync-buckets.js +315 -0
- package/lib/sync.js +79 -28
- package/lib/utils.js +2 -1
- package/package.json +2 -2
- package/test/README.md +5 -3
- package/test/sync-buckets-unit.test.js +188 -0
- package/test/sync-buckets.test.js +133 -0
- package/test/sync-unit.test.js +18 -2
- package/test/utils-unit.test.js +50 -0
package/docs/help.md
CHANGED
|
@@ -200,6 +200,8 @@ xy sync ./ --down events,plugins
|
|
|
200
200
|
xy sync ./ --up categories,plugins --down events --dry
|
|
201
201
|
xy sync ./ --up events --down events --dry
|
|
202
202
|
xy sync ./ --up all --dry
|
|
203
|
+
xy sync ./ --up buckets --dry
|
|
204
|
+
xy sync ./ --down buckets
|
|
203
205
|
xy sync --up events,plugins --verbose
|
|
204
206
|
```
|
|
205
207
|
|
|
@@ -212,7 +214,7 @@ Pass one or more base directories, or omit them to scan the current directory. F
|
|
|
212
214
|
- `--dry` previews changes without applying them.
|
|
213
215
|
- `--verbose` shows full diffs and API requests and responses.
|
|
214
216
|
|
|
215
|
-
You must enable at least one direction. `TYPES` is a comma-separated list of `alerts`, `api_keys`, `categories`, `channels`, `events`, `groups`, `monitors`, `plugins`, `tags`, or `web_hooks`. Use `all` to select every supported type. Workflows are included under `events`.
|
|
217
|
+
You must enable at least one direction. `TYPES` is a comma-separated list of `alerts`, `api_keys`, `buckets`, `categories`, `channels`, `events`, `groups`, `monitors`, `plugins`, `tags`, or `web_hooks`. Use `all` to select every supported type. Workflows are included under `events`. Bucket sync requires xyOps v1.1.2 or later. Secrets, Users, and Roles are not supported.
|
|
216
218
|
|
|
217
219
|
Enable both directions for the same type to use experimental two-way sync. The newest modification time wins, including external property files. Git checkouts can reset filesystem timestamps, so prefer one-way sync when reliable conflict handling matters. Dry runs never update xyOps, write files, run completion commands, or send notifications.
|
|
218
220
|
|
|
@@ -222,6 +224,8 @@ Repeated sources with the same type and ID generate warnings, even if their cont
|
|
|
222
224
|
|
|
223
225
|
External string properties are loaded automatically from adjacent files named `BASENAME-PROPERTY.EXT`, such as `My-Plugin-script.js` beside `My-Plugin.json`, or `My-Event-params.script.sh` beside `My-Event.json`. The extension is your choice. Keep only one external file per property, and rename its basename along with the JSON file.
|
|
224
226
|
|
|
227
|
+
Bucket metadata uses a normal XYPDF source. For a Bucket titled `My Bucket Name`, its separate JSON data and uploaded files live beside `buckets/My-Bucket-Name.json` in `buckets/My-Bucket-Name/data.json` and `buckets/My-Bucket-Name/files/`. Both `data.json` and `files/` are required for a selected local bucket. Bucket data upsync replaces the entire remote data object. Bucket files are matched by xyOps filename normalization and transferred according to size and modification time. Two-way bucket sync uses the data record and file timestamps from xyOps; a transfer updates local file times to match. Bucket files missing locally are deleted remotely only with up-only `--delete buckets`.
|
|
228
|
+
|
|
225
229
|
**Delete mode**
|
|
226
230
|
|
|
227
231
|
Add `--delete TYPES` to delete xyOps objects that have no matching source in the scanned directories. Deletion requires up-only sync and a complete local inventory. The CLI rejects deletion if upsync is disabled or any downsync direction is enabled, including saved defaults and dry runs. Use `--down false` to disable inherited downsync. See [Delete mode](sync.md#delete-mode) before using it.
|
|
@@ -282,6 +286,7 @@ Export existing xyOps definitions into a starter sync tree in the current direct
|
|
|
282
286
|
|
|
283
287
|
```sh
|
|
284
288
|
xy sync setup events plugins categories --dry
|
|
289
|
+
xy sync setup buckets
|
|
285
290
|
xy sync setup events plugins categories --file_props script,params.script
|
|
286
291
|
xy sync setup events --file_props params.script --default_ext ps1
|
|
287
292
|
xy sync setup events plugins --new
|
|
@@ -296,6 +301,8 @@ Choose one or more resource types separated by spaces, or use `all`. Supported t
|
|
|
296
301
|
|
|
297
302
|
Setup creates one folder per resource type and one XYPDF JSON file per object, using its title as a filename slug. Selecting `events` exports both Events and workflows, with workflows placed in a separate `workflows` folder. Events and workflows are grouped into subfolders using their Category title as a slug. Both are synced with `--up events` or `--down events`. Setup stops before writing if an Event or workflow selected for export refers to a missing Category.
|
|
298
303
|
|
|
304
|
+
For each Bucket, setup also writes `BUCKET-SLUG/data.json` and downloads its files into `BUCKET-SLUG/files/` beside the XYPDF file. `--new` skips Bucket definitions already present locally, including their content. `--force` permits overwriting existing Bucket content files.
|
|
305
|
+
|
|
299
306
|
- `--file_props PATHS` extracts string properties into adjacent files.
|
|
300
307
|
- `--default_ext EXT` sets the fallback neighbor extension instead of `txt`.
|
|
301
308
|
- `--new` writes only remote definitions not already represented locally.
|
package/docs/sync.md
CHANGED
|
@@ -54,6 +54,7 @@ Use these names with `--up`, `--down`, `--delete`, and setup:
|
|
|
54
54
|
| --- | --- |
|
|
55
55
|
| `alerts` | Alert definitions, rather than their invocations. |
|
|
56
56
|
| `api_keys` | API Key definitions. |
|
|
57
|
+
| `buckets` | Bucket definitions, JSON data, and uploaded files. Requires xyOps 1.1.2 or later. |
|
|
57
58
|
| `categories` | Event Categories. |
|
|
58
59
|
| `channels` | Notification Channels. |
|
|
59
60
|
| `events` | Events and workflows. |
|
|
@@ -65,7 +66,7 @@ Use these names with `--up`, `--down`, `--delete`, and setup:
|
|
|
65
66
|
|
|
66
67
|
`all` selects every supported type. Supply multiple types as a comma-separated string without spaces, such as `events,plugins,categories`.
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
Secret Vaults, Users, and Roles are not supported by sync. Individual workers, jobs, Tickets, Alert history, and snapshots are also outside its definition-sync scope. Workflows use the `events` selection even though setup places them in a separate folder (for convenience).
|
|
69
70
|
|
|
70
71
|
### Permissions
|
|
71
72
|
|
|
@@ -127,6 +128,8 @@ xyops-automation/
|
|
|
127
128
|
|
|
128
129
|
Setup creates one folder per selected resource type and one JSON file per definition. Event and workflow exports are separated into `events/` and `workflows/`, then grouped into subfolders using their Category titles. Both participate in normal sync through the `events` selection.
|
|
129
130
|
|
|
131
|
+
Bucket setup also creates a sibling directory for each Bucket's content. For example, a Bucket titled `My Bucket Name` has `buckets/My-Bucket-Name.json`, `buckets/My-Bucket-Name/data.json`, and `buckets/My-Bucket-Name/files/` for its definition, data, and uploaded files. Move or rename the sibling directory with the XYPDF file. A normal Bucket sync requires both the data file and files directory, even when they contain an empty object and no files. Bucket data upsync replaces the full remote data object, including removed keys. File transfers match xyOps normalized filenames and align local modification times with the server's timestamps after each transfer.
|
|
132
|
+
|
|
130
133
|
Folder and filenames come from Category and definition titles: sequences of non-word characters become hyphens, and leading or trailing hyphens are removed. For example, the Event `Nightly Backup` in the `Operations` Category becomes `events/Operations/Nightly-Backup.json`. Different titles can produce the same path, so review the preview for collisions. Setup stops before writing anything if an Event or workflow selected for export refers to a Category that is not present in xyOps.
|
|
131
134
|
|
|
132
135
|
### 4. Review and version the tree
|
|
@@ -439,7 +442,23 @@ For each differing definition, the engine compares xyOps's `modified` timestamp
|
|
|
439
442
|
|
|
440
443
|
Two-way sync is experimental. Keep clocks synchronized, review dry runs, and preserve a history of both configuration and scripts. Git checkout, clone, restore, or file copying can give old content a new filesystem timestamp.
|
|
441
444
|
|
|
442
|
-
|
|
445
|
+
Bucket data and files use their own modification times, separate from the Bucket definition. When contents differ, two-way sync compares `data.json` with the data record's modification time and each local file with the matching manifest date. Equal timestamps with differing Bucket content are reported as conflicts. After a transfer, xyCLI sets the local modification time to the server's returned time.
|
|
446
|
+
|
|
447
|
+
Bucket file comparison uses size and modification time because the manifest has no content hash. A same-size change with an unchanged timestamp cannot be detected.
|
|
448
|
+
|
|
449
|
+
For automatic two-way operation, use a persistent tree and run every minute. Both directions are considered on each run; you do not need separate alternating up and down jobs. Newly created definitions still need a setup export or individual local source.
|
|
450
|
+
|
|
451
|
+
### Remove a definition during two-way sync
|
|
452
|
+
|
|
453
|
+
Two-way sync does not propagate deletions. Definition deletions are usually infrequent, so coordinating them manually is generally practical. Keep `--delete false` in the scheduled command, and remove a definition from both sides deliberately:
|
|
454
|
+
|
|
455
|
+
1. Delete the definition in the xyOps web interface first.
|
|
456
|
+
2. Remove its local XYPDF JSON file and any neighboring external property files. If the tree is stored in Git, commit and push their removal so the dedicated sync checkout receives the deletion.
|
|
457
|
+
3. Run sync again, or resume the schedule, after the local files are gone. Pause the schedule during these steps if you want to avoid a failed pass between the two removals.
|
|
458
|
+
|
|
459
|
+
If a sync runs after the xyOps deletion but before local cleanup, the leftover source produces a missing-object warning. The run exits with a failure status before updating or downloading any definitions, and a script that stops on failure will not reach a following `sync setup --new` step. The deleted definition is not recreated. Remove the stale files and retry.
|
|
460
|
+
|
|
461
|
+
Do not remove the Git files first while the definition still exists in xyOps. A scheduled `sync setup --new` pass can export the still-existing definition back into the repository. See the [Two-Way Git Sync Tutorial](two-way-git-tutorial.md#delete-a-definition-from-both-sides) for the Git workflow.
|
|
443
462
|
|
|
444
463
|
## Remote-management warnings
|
|
445
464
|
|
|
@@ -499,6 +518,8 @@ Deleting an Event does not automatically remove its dependencies. Remote API rul
|
|
|
499
518
|
|
|
500
519
|
Deletion only begins after every update has completed and the sync-tracking state has been written without warnings or errors. If either phase fails, the entire delete pass is skipped for that run. Earlier successful updates remain in place, so correct the error and retry; definitions already updated will compare equal. Once deletion begins, successful earlier deletions are not rolled back if a later deletion fails.
|
|
501
520
|
|
|
521
|
+
With `--delete buckets`, a remote Bucket file missing from a selected local Bucket's `files/` directory is deleted individually. Removing the Bucket XYPDF source deletes the whole Bucket, including all its data and files. A missing or invalid `data.json`, incomplete file inventory, or failed transfer prevents Bucket file deletion and the whole-object delete pass.
|
|
522
|
+
|
|
502
523
|
An unavailable base directory stops the scan, but an existing empty directory can look like an empty inventory. Do not treat that guard as protection against an empty mount, accidental file removal, or missing exports of your own definitions.
|
|
503
524
|
|
|
504
525
|
Keep deletion disabled in general-purpose cron, Git hooks, and CI until you have a deliberate policy for maintaining and checking the complete inventory. Disable saved deletion for other runs with `--delete false`.
|
|
@@ -108,7 +108,7 @@ This considers both directions for every local Event and workflow source:
|
|
|
108
108
|
- A newer xyOps definition is downloaded to its existing local file.
|
|
109
109
|
- Identical definitions are left alone.
|
|
110
110
|
|
|
111
|
-
The `--delete false` switch is intentional. Two-way sync does not propagate deletions
|
|
111
|
+
The `--delete false` switch is intentional. Two-way sync does not propagate deletions. Keep deletion disabled in the scheduled command, and follow the [coordinated deletion steps](#delete-a-definition-from-both-sides) when retiring an item.
|
|
112
112
|
|
|
113
113
|
Review the displayed diffs carefully. If the wrong side would win, check the clocks and file modification times before continuing.
|
|
114
114
|
|
|
@@ -245,6 +245,18 @@ When someone creates a new Event in xyOps, the ordinary sync pass does not know
|
|
|
245
245
|
|
|
246
246
|
`--new` is additive. It does not overwrite existing sources, and it matches existing definitions by type and exact ID rather than filename. This preserves renamed files and custom folder layouts.
|
|
247
247
|
|
|
248
|
+
### Delete a definition from both sides
|
|
249
|
+
|
|
250
|
+
Event and workflow deletions are usually infrequent, so it is practical to coordinate the xyOps and Git changes manually. Pause the scheduled script if you want to avoid a failed pass while you make both changes:
|
|
251
|
+
|
|
252
|
+
1. Delete the definition in the xyOps web interface first.
|
|
253
|
+
2. Remove its XYPDF JSON file and all neighboring property files from Git, then commit and push the deletion. Make sure the dedicated sync checkout receives that commit before its next sync pass.
|
|
254
|
+
3. Resume the scheduled script and check its next run.
|
|
255
|
+
|
|
256
|
+
If the script runs after the xyOps deletion but before the Git deletion reaches its checkout, ordinary sync reports a missing-object warning and exits with a failure status. It does not recreate the definition or process other changes. The script's `set -e` also prevents the following `sync setup --new` pass. Once the stale files are removed, the next run can proceed normally.
|
|
257
|
+
|
|
258
|
+
Do not remove the Git files first. While the definition still exists in xyOps, `sync setup --new` can export it again and commit the files back to Git. This is a manual deletion procedure, not automatic two-way deletion. See the [Sync Guide](sync.md#remove-a-definition-during-two-way-sync) for the general behavior.
|
|
259
|
+
|
|
248
260
|
## Troubleshooting
|
|
249
261
|
|
|
250
262
|
| Symptom | What to check |
|
|
@@ -255,7 +267,7 @@ When someone creates a new Event in xyOps, the ordinary sync pass does not know
|
|
|
255
267
|
| The wrong side wins a two-way conflict | Check NTP, the xyOps conductor clock, and the modification times of the JSON and neighboring files. A checkout, restore, or file copy can refresh local timestamps. |
|
|
256
268
|
| A new Event is not exported | Confirm `sync setup events --new` runs from the repository root and that the API Key can see the Event. Add the correct property path if its script is stored somewhere other than `params.script`. |
|
|
257
269
|
| A new Git file is not created in xyOps | Ordinary sync updates definitions that already exist on both sides. Create or import the definition into xyOps first, then let sync manage subsequent changes. |
|
|
258
|
-
| A deleted item reappears or causes an error |
|
|
270
|
+
| A deleted item reappears or causes an error | Follow the [coordinated deletion steps](#delete-a-definition-from-both-sides): delete in xyOps first, then remove and commit its local files. A leftover source stops sync until it is removed. |
|
|
259
271
|
| A cron run is skipped | Another cron invocation may hold the `flock` lock. The lock file itself may remain when no lock is held, so check for a running sync process rather than treating the file as stale. |
|
|
260
272
|
| A completion command times out | Increase `--cmd_timeout`, investigate Git network latency, and confirm that Git cannot wait for interactive input. |
|
|
261
273
|
|
package/index.js
CHANGED
|
@@ -39,6 +39,14 @@ Uncatch.on('uncaughtException', function(err) {
|
|
|
39
39
|
cli.progress.end();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
cli.emoji = function(emoji) {
|
|
43
|
+
// override cli.emoji with our own TTY-aware version (FUTURE: Remove this patch after pixl-cli v1.1.7+)
|
|
44
|
+
// Draw the emoji, restore its starting cursor position, then explicitly move
|
|
45
|
+
// forward two cells. This overrides each terminal's automatic cursor advance.
|
|
46
|
+
if (!cli.tty()) return emoji;
|
|
47
|
+
return '\u001b7' + emoji + '\u001b8\u001b[2C';
|
|
48
|
+
};
|
|
49
|
+
|
|
42
50
|
const { api } = require('@pixlcore/xyops-sdk');
|
|
43
51
|
var highlight = require('cli-highlight').highlight;
|
|
44
52
|
const Tools = cli.Tools;
|
|
@@ -357,10 +365,7 @@ const app = {
|
|
|
357
365
|
print( "\n " + cyan.bold("Request:") );
|
|
358
366
|
this.jsonOutput(this.args);
|
|
359
367
|
|
|
360
|
-
if (this.dry)
|
|
361
|
-
this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without sending request.");
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
368
|
+
if (this.dry) return;
|
|
364
369
|
|
|
365
370
|
cli.progress.start({ amount: 1, pct: false, text: gray('→ ' + name) });
|
|
366
371
|
var { err, data } = await this.api[name](this.args);
|
|
@@ -453,6 +458,7 @@ Tools.mergeHashInto( app, require('./lib/webhook.js') );
|
|
|
453
458
|
Tools.mergeHashInto( app, require('./lib/transfer.js') );
|
|
454
459
|
Tools.mergeHashInto( app, require('./lib/marketplace.js') );
|
|
455
460
|
Tools.mergeHashInto( app, require('./lib/sync.js') );
|
|
461
|
+
Tools.mergeHashInto( app, require('./lib/sync-buckets.js') );
|
|
456
462
|
|
|
457
463
|
global.app = app;
|
|
458
464
|
|
package/lib/buckets.js
CHANGED
|
@@ -218,7 +218,6 @@ module.exports = {
|
|
|
218
218
|
if (this.dry) {
|
|
219
219
|
if (files.length) {
|
|
220
220
|
this.jsonOutput({ files: files });
|
|
221
|
-
this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without sending request.");
|
|
222
221
|
}
|
|
223
222
|
return;
|
|
224
223
|
}
|
|
@@ -475,10 +474,7 @@ module.exports = {
|
|
|
475
474
|
output: dest_file
|
|
476
475
|
});
|
|
477
476
|
}
|
|
478
|
-
if (this.dry)
|
|
479
|
-
this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without sending request.");
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
477
|
+
if (this.dry) return;
|
|
482
478
|
|
|
483
479
|
println( "\n " + this.color('theme').bold('BUCKET FILE: ' + file.filename) );
|
|
484
480
|
println( " " + gray("Downloading to file: ") + dest_file );
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Filesystem sync for storage bucket data and binary files.
|
|
2
|
+
// Bucket definitions themselves remain in the ordinary XYPDF sync engine.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const Path = require('path');
|
|
6
|
+
const Crypto = require('crypto');
|
|
7
|
+
const Diff = require('diff');
|
|
8
|
+
const cli = require('pixl-cli');
|
|
9
|
+
const Tools = cli.Tools;
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
|
|
13
|
+
requireBucketSyncVersion() {
|
|
14
|
+
// The replace flag and separate data modification time arrived in 1.1.2.
|
|
15
|
+
// Keep the older minimum version for every other sync resource.
|
|
16
|
+
if (!this.xyopsVersion || this.compareVersions(this.xyopsVersion, '1.1.2') < 0) {
|
|
17
|
+
this.die("Bucket sync requires xyOps v1.1.2 or later (server: " + (this.xyopsVersion || 'unknown') + ").");
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
getSyncBucketPaths(item) {
|
|
22
|
+
// The local XYPDF source owns its sibling content directory. The bucket ID
|
|
23
|
+
// identifies the remote object, so title edits never move existing files.
|
|
24
|
+
var dir = item.file.replace(/\.json$/i, '');
|
|
25
|
+
return { dir, data: Path.join(dir, 'data.json'), files: Path.join(dir, 'files') };
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
cleanSyncBucketFilename(filename) {
|
|
29
|
+
// Match xyOps cleanFilename(), which is used for manifest identity. The
|
|
30
|
+
// server's cleanURLFilename() is only for the storage URL component.
|
|
31
|
+
return filename.replace(/[^\w\-\+\.\,\s\(\)\[\]\{\}\'\"\!\&\^\%\$\#\@\*\?\~]+/g, '_');
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
validateSyncBucketFilename(filename) {
|
|
35
|
+
// A server manifest must never be allowed to escape the bucket files dir.
|
|
36
|
+
if (!filename || (typeof(filename) != 'string') || (filename == '.') || (filename == '..') ||
|
|
37
|
+
filename.includes('/') || filename.includes('\\') || filename.includes('\0') || (Path.basename(filename) != filename)) {
|
|
38
|
+
throw new Error("Unsafe bucket filename: " + filename);
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async getSyncBucket(id) {
|
|
43
|
+
// Read both independently stored content records and their timestamps.
|
|
44
|
+
var { err, data } = await this.api.getBucket({ id });
|
|
45
|
+
if (err) throw err;
|
|
46
|
+
if (!data || !Tools.isaHash(data.data) || !Array.isArray(data.files) ||
|
|
47
|
+
!data.meta || (typeof(data.meta.mod) != 'number') || !Number.isFinite(data.meta.mod)) {
|
|
48
|
+
throw new Error("Incomplete bucket data or file manifest: " + id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
var names = new Set();
|
|
52
|
+
data.files.forEach( file => {
|
|
53
|
+
this.validateSyncBucketFilename(file.filename);
|
|
54
|
+
if ((typeof(file.date) != 'number') || !Number.isFinite(file.date) ||
|
|
55
|
+
(typeof(file.size) != 'number') || !Number.isSafeInteger(file.size) || (file.size < 0) ||
|
|
56
|
+
(typeof(file.path) != 'string') || !file.path.startsWith('files/bucket/' + id + '/') ||
|
|
57
|
+
Path.posix.normalize(file.path) != file.path) {
|
|
58
|
+
throw new Error("Invalid file manifest entry in bucket: " + id);
|
|
59
|
+
}
|
|
60
|
+
if (names.has(file.filename)) throw new Error("Duplicate bucket filename in manifest: " + file.filename);
|
|
61
|
+
names.add(file.filename);
|
|
62
|
+
});
|
|
63
|
+
return data;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
readSyncBucketLocal(item) {
|
|
67
|
+
// A missing data file or files directory is an incomplete inventory. This
|
|
68
|
+
// is an error even when the remote bucket currently happens to be empty.
|
|
69
|
+
var paths = this.getSyncBucketPaths(item);
|
|
70
|
+
var stat = fs.lstatSync(paths.data);
|
|
71
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Invalid bucket data file: " + paths.data);
|
|
72
|
+
var data = JSON.parse(fs.readFileSync(paths.data, 'utf8'));
|
|
73
|
+
if (!Tools.isaHash(data)) throw new Error("Bucket data must be a JSON object: " + paths.data);
|
|
74
|
+
|
|
75
|
+
var dir_stat = fs.lstatSync(paths.files);
|
|
76
|
+
if (!dir_stat.isDirectory() || dir_stat.isSymbolicLink()) throw new Error("Invalid bucket files directory: " + paths.files);
|
|
77
|
+
|
|
78
|
+
var files = new Map();
|
|
79
|
+
fs.readdirSync(paths.files, { withFileTypes: true }).forEach( entry => {
|
|
80
|
+
var path = Path.join(paths.files, entry.name);
|
|
81
|
+
if (!entry.isFile()) throw new Error("Bucket files directory must contain only regular files: " + path);
|
|
82
|
+
var name = this.cleanSyncBucketFilename(entry.name);
|
|
83
|
+
this.validateSyncBucketFilename(name);
|
|
84
|
+
if (files.has(name)) throw new Error("Bucket filenames collide after normalization: " + path);
|
|
85
|
+
files.set(name, { path, stat: fs.statSync(path) });
|
|
86
|
+
});
|
|
87
|
+
return { paths, data, data_stat: stat, files };
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
stampSyncBucketFile(path, epoch) {
|
|
91
|
+
// Keep the access time intact. Server times are Unix seconds; Node accepts
|
|
92
|
+
// the same unit for utimes(), including fractional seconds if provided.
|
|
93
|
+
var stat = fs.statSync(path);
|
|
94
|
+
fs.utimesSync(path, stat.atime, Number(epoch));
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
printSyncBucketHeading(icon, label, title, id, deleting) {
|
|
98
|
+
// Match the standard sync heading: direction, action, display title and
|
|
99
|
+
// a quiet internal ID. Give destructive operations the existing red style.
|
|
100
|
+
var action = deleting ? cli.chalk.red.bold : cli.chalk.green.bold;
|
|
101
|
+
var name = deleting ? cli.chalk.red.bold : cli.chalk.cyan.bold;
|
|
102
|
+
println( "\n " + cli.emoji(icon) + " " + action(label) + ": " + name(title) + cli.chalk.gray(" (" + id + ")") );
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async downloadSyncBucketFile(id, file, destination) {
|
|
106
|
+
// Download to a private sibling first. A failed or partial response must
|
|
107
|
+
// never replace the previous local copy of a bucket file.
|
|
108
|
+
var temp = destination + '.xy-sync-' + Crypto.randomBytes(8).toString('hex') + '.tmp';
|
|
109
|
+
try {
|
|
110
|
+
var { err } = await this.api.file({
|
|
111
|
+
path: file.path.replace(/^files\//, ''),
|
|
112
|
+
download: file.filename
|
|
113
|
+
}, { download: temp });
|
|
114
|
+
if (err) throw err;
|
|
115
|
+
var stat = fs.statSync(temp);
|
|
116
|
+
if (stat.size !== Number(file.size)) throw new Error("Downloaded bucket file size differs from manifest: " + file.filename);
|
|
117
|
+
this.stampSyncBucketFile(temp, file.date);
|
|
118
|
+
fs.renameSync(temp, destination);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
if (fs.existsSync(temp)) fs.unlinkSync(temp);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async setupBucketContents(plans, opts) {
|
|
126
|
+
// Fetch every selected bucket before writing any bucket extras. Honor the
|
|
127
|
+
// ordinary setup --force, --new and --dry rules for all content files.
|
|
128
|
+
var prepared = [];
|
|
129
|
+
var paths_seen = new Set();
|
|
130
|
+
for (const item of plans) {
|
|
131
|
+
var paths = this.getSyncBucketPaths(item);
|
|
132
|
+
if (paths_seen.has(paths.dir)) this.die("Bucket setup paths collide: " + paths.dir);
|
|
133
|
+
paths_seen.add(paths.dir);
|
|
134
|
+
for (const dir of [ paths.dir, paths.files ]) {
|
|
135
|
+
if (fs.existsSync(dir) && (!fs.lstatSync(dir).isDirectory() || fs.lstatSync(dir).isSymbolicLink())) {
|
|
136
|
+
this.die("Invalid bucket setup directory: " + dir);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
var snapshot = await this.getSyncBucket(item.id);
|
|
140
|
+
var files = snapshot.files.map( file => ({ file, path: Path.join(paths.files, file.filename) }) );
|
|
141
|
+
if (!opts.force && !opts.dry) {
|
|
142
|
+
if (fs.existsSync(paths.data)) this.die("File exists, add '--force' to overwrite all: " + paths.data);
|
|
143
|
+
files.forEach( entry => {
|
|
144
|
+
if (fs.existsSync(entry.path)) this.die("File exists, add '--force' to overwrite all: " + entry.path);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
prepared.push({ item, paths, snapshot, files });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const entry of prepared) {
|
|
151
|
+
// Mirror the setup tree above: a colored folder heading, then short
|
|
152
|
+
// names for the data file and nested uploaded-file directory.
|
|
153
|
+
println( "\n " + cli.emoji('🪣') + " " + cli.chalk.bold.cyan(Path.basename(entry.paths.dir) + '/') );
|
|
154
|
+
println( " " + cli.emoji('📄') + " " + cli.chalk.bold.green('data.json') );
|
|
155
|
+
println( " " + cli.emoji('📂') + " " + cli.chalk.bold.cyan('files/') );
|
|
156
|
+
entry.files.forEach( file => {
|
|
157
|
+
println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(file.file.filename) );
|
|
158
|
+
});
|
|
159
|
+
if (opts.dry) continue;
|
|
160
|
+
Tools.mkdirp.sync(entry.paths.files);
|
|
161
|
+
Tools.writeFileAtomicSync(entry.paths.data, JSON.stringify(entry.snapshot.data, null, '\t') + '\n');
|
|
162
|
+
this.stampSyncBucketFile(entry.paths.data, entry.snapshot.meta.mod);
|
|
163
|
+
for (const file of entry.files) {
|
|
164
|
+
await this.downloadSyncBucketFile(entry.item.id, file.file, file.path);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async syncBucketContents(items, sconfig) {
|
|
170
|
+
if (!items.length || this.errors.length || this.warnings.length) return;
|
|
171
|
+
var prepared = [];
|
|
172
|
+
|
|
173
|
+
// Preflight every selected local inventory and every remote manifest before
|
|
174
|
+
// applying changes. A missing data.json or unreadable files dir must never
|
|
175
|
+
// turn into a remote deletion candidate.
|
|
176
|
+
try {
|
|
177
|
+
for (const item of items) {
|
|
178
|
+
prepared.push({ item, local: this.readSyncBucketLocal(item), remote: await this.getSyncBucket(item.data.id) });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
this.logSyncError("Bucket sync preflight failed: " + err);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
var up = sconfig.up && sconfig.up.includes('buckets');
|
|
187
|
+
var down = sconfig.down && sconfig.down.includes('buckets');
|
|
188
|
+
var delete_files = sconfig.delete && sconfig.delete.includes('buckets');
|
|
189
|
+
var deletes = [];
|
|
190
|
+
|
|
191
|
+
for (const entry of prepared) {
|
|
192
|
+
var id = entry.item.data.id;
|
|
193
|
+
var local = entry.local;
|
|
194
|
+
var remote = entry.remote;
|
|
195
|
+
var title = (remote.bucket && remote.bucket.title) || entry.item.data.title || id;
|
|
196
|
+
var same_data = Tools.stableStringify(local.data) === Tools.stableStringify(remote.data);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (!same_data) {
|
|
200
|
+
var local_time = local.data_stat.mtimeMs / 1000;
|
|
201
|
+
var remote_time = Number(remote.meta.mod);
|
|
202
|
+
if (up && down && local_time == remote_time) throw new Error("Bucket data conflict with equal timestamps: " + id);
|
|
203
|
+
var data_direction = up && down ? (local_time > remote_time ? 'up' : 'down') : (up ? 'up' : 'down');
|
|
204
|
+
var data_changes = Diff.diffLines(
|
|
205
|
+
Tools.stablePrettyStringify(remote.data),
|
|
206
|
+
Tools.stablePrettyStringify(local.data)
|
|
207
|
+
);
|
|
208
|
+
this.printSyncBucketHeading(data_direction == 'up' ? '⬆️' : '⬇️', 'Updating bucket data', title, id);
|
|
209
|
+
println( "\n" + this.markdown(this.formatSyncDiff(data_changes)) );
|
|
210
|
+
if (!this.dry && data_direction == 'up') {
|
|
211
|
+
await this.callStandardAPI('writeBucketData', { id, data: local.data, replace: true }, { text: 'Writing bucket data...', throw: true });
|
|
212
|
+
var updated = await this.getSyncBucket(id);
|
|
213
|
+
if (Tools.stableStringify(updated.data) !== Tools.stableStringify(local.data)) {
|
|
214
|
+
throw new Error("Bucket data changed during upload: " + id);
|
|
215
|
+
}
|
|
216
|
+
if (Tools.stableStringify(JSON.parse(fs.readFileSync(local.paths.data, 'utf8'))) !== Tools.stableStringify(local.data)) {
|
|
217
|
+
throw new Error("Local bucket data changed during upload: " + local.paths.data);
|
|
218
|
+
}
|
|
219
|
+
this.stampSyncBucketFile(local.paths.data, updated.meta.mod);
|
|
220
|
+
this.upSynced = true;
|
|
221
|
+
}
|
|
222
|
+
else if (!this.dry) {
|
|
223
|
+
Tools.writeFileAtomicSync(local.paths.data, JSON.stringify(remote.data, null, '\t') + '\n');
|
|
224
|
+
this.stampSyncBucketFile(local.paths.data, remote.meta.mod);
|
|
225
|
+
this.downSynced = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
var manifest = new Map(remote.files.map( file => [file.filename, file] ));
|
|
230
|
+
var uploads = [];
|
|
231
|
+
var downloads = [];
|
|
232
|
+
for (const [name, source] of local.files) {
|
|
233
|
+
var target = manifest.get(name);
|
|
234
|
+
if (!target) {
|
|
235
|
+
if (up) uploads.push({ name, source });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
var local_time = source.stat.mtimeMs / 1000;
|
|
239
|
+
var remote_time = Number(target.date);
|
|
240
|
+
var size_differs = source.stat.size !== Number(target.size);
|
|
241
|
+
if (up && down) {
|
|
242
|
+
if (local_time > remote_time) uploads.push({ name, source });
|
|
243
|
+
else if (remote_time > local_time) downloads.push({ name, target, path: source.path });
|
|
244
|
+
else if (size_differs) throw new Error("Bucket file size conflict with equal timestamps: " + source.path);
|
|
245
|
+
}
|
|
246
|
+
else if (up && (size_differs || local_time > remote_time)) uploads.push({ name, source });
|
|
247
|
+
else if (down && (size_differs || remote_time > local_time)) downloads.push({ name, target, path: source.path });
|
|
248
|
+
}
|
|
249
|
+
for (const file of remote.files) {
|
|
250
|
+
if (!local.files.has(file.filename)) {
|
|
251
|
+
if (down) downloads.push({ name: file.filename, target: file, path: Path.join(local.paths.files, file.filename) });
|
|
252
|
+
else if (delete_files) deletes.push({ id, title, file });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (uploads.length) {
|
|
257
|
+
var upload_label = "Uploading " + uploads.length + " bucket " + (uploads.length == 1 ? 'file' : 'files');
|
|
258
|
+
this.printSyncBucketHeading('⬆️', upload_label, title, id);
|
|
259
|
+
uploads.forEach( entry => {
|
|
260
|
+
println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(Path.basename(entry.source.path)) );
|
|
261
|
+
});
|
|
262
|
+
if (!this.dry) {
|
|
263
|
+
var result = await this.callStandardAPI('uploadBucketFiles', { id }, {
|
|
264
|
+
text: 'Uploading bucket files...', files: uploads.map( entry => entry.source.path ), throw: true
|
|
265
|
+
});
|
|
266
|
+
var updated = new Map((result.files || []).map( file => [file.filename, file] ));
|
|
267
|
+
for (const entry of uploads) {
|
|
268
|
+
var file = updated.get(entry.name);
|
|
269
|
+
if (!file || (typeof(file.date) != 'number') || !Number.isFinite(file.date)) {
|
|
270
|
+
throw new Error("Uploaded bucket file missing or invalid in response: " + entry.name);
|
|
271
|
+
}
|
|
272
|
+
var current = fs.statSync(entry.source.path);
|
|
273
|
+
if ((current.size !== entry.source.stat.size) || (current.mtimeMs !== entry.source.stat.mtimeMs)) {
|
|
274
|
+
throw new Error("Local bucket file changed during upload: " + entry.source.path);
|
|
275
|
+
}
|
|
276
|
+
this.stampSyncBucketFile(entry.source.path, file.date);
|
|
277
|
+
}
|
|
278
|
+
this.upSynced = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const entry of downloads) {
|
|
282
|
+
this.printSyncBucketHeading('⬇️', 'Downloading bucket file', title, id);
|
|
283
|
+
println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(entry.name) );
|
|
284
|
+
if (!this.dry) {
|
|
285
|
+
await this.downloadSyncBucketFile(id, entry.target, entry.path);
|
|
286
|
+
this.downSynced = true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
this.logSyncError("Bucket sync failed for " + id + ": " + err);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Deletions are the final content phase. Any earlier warning or error
|
|
296
|
+
// suppresses all bucket file deletes, just like whole-object deletion.
|
|
297
|
+
if (deletes.length && (this.errors.length || this.warnings.length)) {
|
|
298
|
+
this.logSyncWarning("Bucket file delete pass skipped because sync encountered warnings or errors.");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const entry of deletes) {
|
|
302
|
+
this.printSyncBucketHeading('🗑️', 'Deleting bucket file', entry.title, entry.id, true);
|
|
303
|
+
println( " " + cli.emoji('📎') + " " + cli.chalk.bold.yellow(entry.file.filename) );
|
|
304
|
+
if (this.dry) continue;
|
|
305
|
+
try {
|
|
306
|
+
await this.callStandardAPI('deleteBucketFile', { id: entry.id, path: entry.file.path }, { text: 'Deleting bucket file...', throw: true });
|
|
307
|
+
this.upSynced = true;
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
this.logSyncError("Bucket file deletion failed: " + err);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
package/lib/sync.js
CHANGED
|
@@ -44,7 +44,7 @@ module.exports = {
|
|
|
44
44
|
|
|
45
45
|
println( "\n " + cli.emoji('🔄') + ' ' + this.color('theme').bold("XYOPS SYNC ENGINE v1") );
|
|
46
46
|
|
|
47
|
-
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(
|
|
47
|
+
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
|
|
48
48
|
|
|
49
49
|
// allow sync config in config files and/or cli args
|
|
50
50
|
var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
@@ -77,6 +77,13 @@ module.exports = {
|
|
|
77
77
|
this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Bucket content needs the replacement API and data timestamps introduced
|
|
81
|
+
// in xyOps 1.1.2. Check before any ordinary sync changes are applied.
|
|
82
|
+
if ((sconfig.up && sconfig.up.includes('buckets')) || (sconfig.down && sconfig.down.includes('buckets')) ||
|
|
83
|
+
(sconfig.delete && sconfig.delete.includes('buckets'))) {
|
|
84
|
+
this.requireBucketSyncVersion();
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
// scan all base directories, default to cwd
|
|
81
88
|
var files = [];
|
|
82
89
|
if (!this.args.other || !this.args.other.length) {
|
|
@@ -106,11 +113,15 @@ module.exports = {
|
|
|
106
113
|
// longest-first lets each external property file belong to the most specific
|
|
107
114
|
// source basename instead of also being claimed by every shorter prefix.
|
|
108
115
|
var source_stems_by_dir = new Map();
|
|
116
|
+
var bucket_extra_dirs = [];
|
|
109
117
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
110
118
|
var xypdf = null;
|
|
111
119
|
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
112
120
|
catch (err) { return; } // the normal scan below reports malformed JSON
|
|
113
121
|
if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
|
|
122
|
+
if (Array.isArray(xypdf.items) && xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
|
|
123
|
+
bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
|
|
124
|
+
}
|
|
114
125
|
|
|
115
126
|
var dir = Path.dirname(file.path);
|
|
116
127
|
var stems = source_stems_by_dir.get(dir);
|
|
@@ -127,7 +138,7 @@ module.exports = {
|
|
|
127
138
|
// load all xypdf files
|
|
128
139
|
var items = [];
|
|
129
140
|
|
|
130
|
-
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
141
|
+
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
131
142
|
// file: { path, size, mtime }
|
|
132
143
|
var dir = Path.dirname(file.path);
|
|
133
144
|
var filename = Path.basename(file.path);
|
|
@@ -175,7 +186,7 @@ module.exports = {
|
|
|
175
186
|
this.logSyncWarning("Malformed item type in file: " + file.path);
|
|
176
187
|
return;
|
|
177
188
|
}
|
|
178
|
-
if (item.type.match(/^(
|
|
189
|
+
if (item.type.match(/^(secret|user|role)$/)) {
|
|
179
190
|
this.logSyncWarning("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
180
191
|
return;
|
|
181
192
|
}
|
|
@@ -313,24 +324,8 @@ module.exports = {
|
|
|
313
324
|
var changes = this.diffSyncItems(xy_item, item.data);
|
|
314
325
|
if (!changes.some( change => change.added || change.removed )) continue; // identical
|
|
315
326
|
|
|
316
|
-
//
|
|
317
|
-
var diff =
|
|
318
|
-
changes.forEach( change => {
|
|
319
|
-
var prefix = change.added ? '+' : change.removed ? '-' : ' ';
|
|
320
|
-
var lines = change.value.replace(/\n$/, '').split(/\n/);
|
|
321
|
-
|
|
322
|
-
if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
|
|
323
|
-
// reduce context lines to 1 above and 1 below, unless in verbose mode
|
|
324
|
-
var top = lines.shift();
|
|
325
|
-
var bottom = lines.pop();
|
|
326
|
-
lines = [ top, "...", bottom ];
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
lines.forEach( line => {
|
|
330
|
-
diff += prefix + line.trimEnd() + "\n";
|
|
331
|
-
} );
|
|
332
|
-
} );
|
|
333
|
-
diff += "```\n";
|
|
327
|
+
// Use the same highlighted, context-trimmed diff for every sync item.
|
|
328
|
+
var diff = this.formatSyncDiff(changes);
|
|
334
329
|
|
|
335
330
|
// figure out which direction to go
|
|
336
331
|
var direction = false;
|
|
@@ -358,7 +353,7 @@ module.exports = {
|
|
|
358
353
|
if (direction == 'up') {
|
|
359
354
|
// sync change up to xyops
|
|
360
355
|
try {
|
|
361
|
-
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true });
|
|
356
|
+
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true, sync: true });
|
|
362
357
|
|
|
363
358
|
// add flag to run optional command at completion
|
|
364
359
|
this.upSynced = true;
|
|
@@ -383,6 +378,12 @@ module.exports = {
|
|
|
383
378
|
|
|
384
379
|
// update state db
|
|
385
380
|
await this.syncState(state);
|
|
381
|
+
|
|
382
|
+
// Bucket metadata is handled above like every other XYPDF item. Its data
|
|
383
|
+
// and binary files have their own storage records and reconciliation pass.
|
|
384
|
+
if (sconfig.up && sconfig.up.includes('buckets') || sconfig.down && sconfig.down.includes('buckets')) {
|
|
385
|
+
await this.syncBucketContents(items.filter( item => item.type == 'bucket' ), sconfig);
|
|
386
|
+
}
|
|
386
387
|
|
|
387
388
|
// Deletion is the destructive final phase, so only begin it after every
|
|
388
389
|
// update and the sync-state write completed cleanly. Earlier successful
|
|
@@ -418,7 +419,7 @@ module.exports = {
|
|
|
418
419
|
println( "\n " + cli.emoji('🗑️') + ' ' + red.bold("Deleting " + type_def.id) + ": " + red.bold(xy_item.title) + gray(" (" + xy_item.id + ")") );
|
|
419
420
|
|
|
420
421
|
try {
|
|
421
|
-
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true });
|
|
422
|
+
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true, sync: true });
|
|
422
423
|
|
|
423
424
|
// add flag to run optional command at completion
|
|
424
425
|
this.upSynced = true;
|
|
@@ -743,6 +744,27 @@ module.exports = {
|
|
|
743
744
|
);
|
|
744
745
|
},
|
|
745
746
|
|
|
747
|
+
formatSyncDiff(changes) {
|
|
748
|
+
// Render a diff code block for the CLI Markdown highlighter. Keep long
|
|
749
|
+
// unchanged sections compact unless the user requested verbose output.
|
|
750
|
+
var diff = "```diff\n";
|
|
751
|
+
changes.forEach( change => {
|
|
752
|
+
var prefix = change.added ? '+' : change.removed ? '-' : ' ';
|
|
753
|
+
var lines = change.value.replace(/\n$/, '').split(/\n/);
|
|
754
|
+
|
|
755
|
+
if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
|
|
756
|
+
var top = lines.shift();
|
|
757
|
+
var bottom = lines.pop();
|
|
758
|
+
lines = [ top, "...", bottom ];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
lines.forEach( line => {
|
|
762
|
+
diff += prefix + line.trimEnd() + "\n";
|
|
763
|
+
} );
|
|
764
|
+
} );
|
|
765
|
+
return diff + "```\n";
|
|
766
|
+
},
|
|
767
|
+
|
|
746
768
|
logSyncWarning(msg) {
|
|
747
769
|
this.warnings.push(msg);
|
|
748
770
|
|
|
@@ -769,14 +791,14 @@ module.exports = {
|
|
|
769
791
|
// xy sync --setup events --new
|
|
770
792
|
// xy sync --setup all --marketplace --stock --force
|
|
771
793
|
if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
|
|
772
|
-
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(
|
|
794
|
+
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
|
|
773
795
|
}
|
|
774
796
|
|
|
775
797
|
if (typeof(this.args.setup) == 'string') this.args.setup = this.args.setup.split(/\,/);
|
|
776
798
|
if (!Array.isArray(this.args.setup)) this.dieUsage('sync setup');
|
|
777
799
|
|
|
778
|
-
if (Tools.includesAny(this.args.setup, ['
|
|
779
|
-
this.die("Sorry, the sync engine cannot process
|
|
800
|
+
if (Tools.includesAny(this.args.setup, ['secrets', 'users', 'roles'])) {
|
|
801
|
+
this.die("Sorry, the sync engine cannot process secrets, users, or roles.");
|
|
780
802
|
}
|
|
781
803
|
|
|
782
804
|
if (!Tools.includesAll(this.config.ui.list_list.map( list => list.id ), this.args.setup)) {
|
|
@@ -784,6 +806,7 @@ module.exports = {
|
|
|
784
806
|
}
|
|
785
807
|
|
|
786
808
|
if (!this.args.setup.length) this.die("No item types specified to set up.");
|
|
809
|
+
if (this.args.setup.includes('buckets')) this.requireBucketSyncVersion();
|
|
787
810
|
|
|
788
811
|
if (this.args.file_props) {
|
|
789
812
|
if (typeof(this.args.file_props) == 'string') this.args.file_props = this.args.file_props.split(/\,/);
|
|
@@ -828,6 +851,13 @@ module.exports = {
|
|
|
828
851
|
force: !!this.args.force,
|
|
829
852
|
dry: !!this.dry
|
|
830
853
|
});
|
|
854
|
+
|
|
855
|
+
if (this.args.setup.includes('buckets')) {
|
|
856
|
+
await this.setupBucketContents(result.bucket_plans, {
|
|
857
|
+
force: !!this.args.force,
|
|
858
|
+
dry: !!this.dry
|
|
859
|
+
});
|
|
860
|
+
}
|
|
831
861
|
|
|
832
862
|
if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
|
|
833
863
|
|
|
@@ -870,7 +900,18 @@ module.exports = {
|
|
|
870
900
|
});
|
|
871
901
|
|
|
872
902
|
var index = {};
|
|
903
|
+
var bucket_extra_dirs = [];
|
|
873
904
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
905
|
+
try {
|
|
906
|
+
var xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') );
|
|
907
|
+
if (xypdf && xypdf.type == 'xypdf' && Array.isArray(xypdf.items) &&
|
|
908
|
+
xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
|
|
909
|
+
bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
catch (err) {;} // report malformed source files during the real scan
|
|
913
|
+
});
|
|
914
|
+
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
874
915
|
var xypdf = null;
|
|
875
916
|
try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
|
|
876
917
|
catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
|
|
@@ -889,7 +930,7 @@ module.exports = {
|
|
|
889
930
|
if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
|
|
890
931
|
this.die("Malformed item type in file: " + file.path);
|
|
891
932
|
}
|
|
892
|
-
if (item.type.match(/^(
|
|
933
|
+
if (item.type.match(/^(secret|user|role)$/)) {
|
|
893
934
|
this.die("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
894
935
|
}
|
|
895
936
|
if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
|
|
@@ -914,6 +955,15 @@ module.exports = {
|
|
|
914
955
|
return index;
|
|
915
956
|
},
|
|
916
957
|
|
|
958
|
+
isSyncBucketExtraFile(file, dirs) {
|
|
959
|
+
// Bucket data and uploaded files are opaque to the XYPDF parser, even if
|
|
960
|
+
// a user uploaded a .json file with non-JSON contents.
|
|
961
|
+
return dirs.some( dir => {
|
|
962
|
+
var relative = Path.relative(dir, file);
|
|
963
|
+
return relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
|
|
964
|
+
});
|
|
965
|
+
},
|
|
966
|
+
|
|
917
967
|
writeSyncSetupFiles(list_names, opts = {}) {
|
|
918
968
|
// Write selected remote definitions using setup's suggested layout. Keeping
|
|
919
969
|
// this operation separate allows a future downsync to append newly discovered
|
|
@@ -935,7 +985,7 @@ module.exports = {
|
|
|
935
985
|
if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
|
|
936
986
|
});
|
|
937
987
|
|
|
938
|
-
var result = { written: 0, skipped: 0 };
|
|
988
|
+
var result = { written: 0, skipped: 0, bucket_plans: [] };
|
|
939
989
|
var existing_keys = opts.existing_keys || {};
|
|
940
990
|
var base_dir = opts.base_dir || '.';
|
|
941
991
|
var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
|
|
@@ -1029,6 +1079,7 @@ module.exports = {
|
|
|
1029
1079
|
var title = plan.title;
|
|
1030
1080
|
var filename = title + '.json';
|
|
1031
1081
|
var file = Path.join(dir, filename);
|
|
1082
|
+
if (target.type == 'bucket') result.bucket_plans.push({ id: item.id, file: file });
|
|
1032
1083
|
|
|
1033
1084
|
if (!opts.dry) {
|
|
1034
1085
|
if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
|
package/lib/utils.js
CHANGED
|
@@ -1057,7 +1057,8 @@ module.exports = {
|
|
|
1057
1057
|
async callStandardAPI(name, req, opts = {}) {
|
|
1058
1058
|
// wrapper around APIs that make a change
|
|
1059
1059
|
// indeterminate progress w/text, handle dry run, verbose req/res, etc.
|
|
1060
|
-
this.validateStandardAPIRequest(name, req);
|
|
1060
|
+
if (!opts.sync) this.validateStandardAPIRequest(name, req);
|
|
1061
|
+
delete opts.sync;
|
|
1061
1062
|
|
|
1062
1063
|
// Sensitive callers may supply a redacted display copy while the original
|
|
1063
1064
|
// request continues through validation and transport unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixlcore/xycli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "A complete command-line interface for the xyOps workflow automation system.",
|
|
5
5
|
"author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
|
|
6
6
|
"homepage": "https://github.com/pixlcore/xycli",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"test": "node test.js",
|
|
24
|
-
"test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
|
|
24
|
+
"test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
|
|
25
25
|
"changelog": "node tools/changelog.js"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
package/test/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Use Node.js 22 or newer and install the CLI dependencies with `npm install`.
|
|
|
8
8
|
|
|
9
9
|
Start xyOps at `http://localhost:5522` and configure an administrator API key using your normal CLI config file or the `XYOPS_API_KEY` environment variable. The tests read the same configuration as `xy`, including `XYOPS_BASE_URL` overrides. They refuse to run against a different server address.
|
|
10
10
|
|
|
11
|
-
Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
|
|
11
|
+
Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The `sync-buckets` suite requires xyOps 1.1.2 or later. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
|
|
12
12
|
|
|
13
13
|
## Running Tests
|
|
14
14
|
|
|
@@ -42,10 +42,10 @@ node --test --test-reporter=spec test/monitors.test.js
|
|
|
42
42
|
|
|
43
43
|
The controller exits with a nonzero status if any suite fails. A failed lifecycle check stops the remaining dependent steps in that suite, runs cleanup, and allows the next suite to proceed.
|
|
44
44
|
|
|
45
|
-
The configuration and sync unit suites run offline with disposable directories and do not contact xyOps. Run them directly without configuring a development server:
|
|
45
|
+
The configuration and sync unit suites, including `sync-buckets-unit`, run offline with disposable directories and do not contact xyOps. Run them directly without configuring a development server:
|
|
46
46
|
|
|
47
47
|
```sh
|
|
48
|
-
node --test test/config.test.js test/sync-unit.test.js
|
|
48
|
+
node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.test.js
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
## Coverage
|
|
@@ -67,6 +67,8 @@ node --test test/config.test.js test/sync-unit.test.js
|
|
|
67
67
|
| `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
|
|
68
68
|
| `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
|
|
69
69
|
| `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
|
|
70
|
+
| `sync-buckets` | Disposable Bucket setup, JSON replacement, file upload and download, and timestamp alignment on xyOps 1.1.2 or later |
|
|
71
|
+
| `sync-buckets-unit` | Offline Bucket version gating, content setup, upload and download, timestamp alignment, and pre-delete safety |
|
|
70
72
|
| `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
|
|
71
73
|
| `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
|
|
72
74
|
| `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const Path = require('node:path');
|
|
6
|
+
const bucketSync = require('../lib/sync-buckets.js');
|
|
7
|
+
const sync = require('../lib/sync.js');
|
|
8
|
+
const utils = require('../lib/utils.js');
|
|
9
|
+
|
|
10
|
+
// The bucket pass uses the ordinary sync logging functions, whose output is
|
|
11
|
+
// provided by pixl-cli in production. Keep the test output quiet and local.
|
|
12
|
+
global.println = () => {};
|
|
13
|
+
|
|
14
|
+
function fixture(t, options = {}) {
|
|
15
|
+
const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-bucket-sync-unit-'));
|
|
16
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
17
|
+
const item = { type: 'bucket', data: { id: 'bucket_unit', title: 'Changed Title' }, file: Path.join(dir, 'custom', 'Original-Name.json') };
|
|
18
|
+
const paths = bucketSync.getSyncBucketPaths(item);
|
|
19
|
+
fs.mkdirSync(paths.files, { recursive: true });
|
|
20
|
+
fs.writeFileSync(item.file, '{}');
|
|
21
|
+
fs.writeFileSync(paths.data, JSON.stringify(options.localData || { keep: true, removed: true }));
|
|
22
|
+
fs.utimesSync(paths.data, 100, options.localDataTime || 100);
|
|
23
|
+
|
|
24
|
+
const remote = {
|
|
25
|
+
bucket: { id: item.data.id, title: item.data.title },
|
|
26
|
+
data: options.remoteData || { keep: false, server: true },
|
|
27
|
+
meta: { mod: options.remoteDataTime || 90 },
|
|
28
|
+
files: options.remoteFiles || []
|
|
29
|
+
};
|
|
30
|
+
const bytes = options.remoteBytes || {};
|
|
31
|
+
const calls = [];
|
|
32
|
+
const app = {
|
|
33
|
+
...bucketSync,
|
|
34
|
+
...sync,
|
|
35
|
+
compareVersions: utils.compareVersions,
|
|
36
|
+
xyopsVersion: options.version || '1.1.2',
|
|
37
|
+
dry: !!options.dry,
|
|
38
|
+
errors: [],
|
|
39
|
+
warnings: [],
|
|
40
|
+
api: {
|
|
41
|
+
async getBucket() { return { data: structuredClone(remote) }; },
|
|
42
|
+
async file(request, settings) {
|
|
43
|
+
const path = 'files/' + request.path;
|
|
44
|
+
if (!Object.hasOwn(bytes, path)) return { err: new Error('Missing remote bytes') };
|
|
45
|
+
fs.writeFileSync(settings.download, bytes[path]);
|
|
46
|
+
return { data: {} };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async callStandardAPI(method, request, settings = {}) {
|
|
50
|
+
calls.push({ method, request, settings });
|
|
51
|
+
if (method == 'writeBucketData') {
|
|
52
|
+
remote.data = structuredClone(request.data);
|
|
53
|
+
remote.meta.mod = 120;
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
if (method == 'uploadBucketFiles') {
|
|
57
|
+
settings.files.forEach(path => {
|
|
58
|
+
const name = app.cleanSyncBucketFilename(Path.basename(path));
|
|
59
|
+
let file = remote.files.find(entry => entry.filename == name);
|
|
60
|
+
if (!file) {
|
|
61
|
+
file = { filename: name, path: 'files/bucket/bucket_unit/new/' + name };
|
|
62
|
+
remote.files.push(file);
|
|
63
|
+
}
|
|
64
|
+
file.size = fs.statSync(path).size;
|
|
65
|
+
file.date = 130;
|
|
66
|
+
});
|
|
67
|
+
return { files: structuredClone(remote.files) };
|
|
68
|
+
}
|
|
69
|
+
if (method == 'deleteBucketFile') {
|
|
70
|
+
remote.files = remote.files.filter(file => file.path != request.path);
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
throw new Error('Unexpected API: ' + method);
|
|
74
|
+
},
|
|
75
|
+
logSyncError(message) { this.errors.push(message); },
|
|
76
|
+
logSyncWarning(message) { this.warnings.push(message); },
|
|
77
|
+
markdown(text) { return text; },
|
|
78
|
+
die(message) { throw new Error(message); }
|
|
79
|
+
};
|
|
80
|
+
return { dir, item, paths, remote, calls, app };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('bucket sync rejects xyOps versions older than 1.1.2', t => {
|
|
84
|
+
const current = fixture(t);
|
|
85
|
+
assert.doesNotThrow(() => current.app.requireBucketSyncVersion());
|
|
86
|
+
current.app.xyopsVersion = '1.1.1';
|
|
87
|
+
assert.throws(() => current.app.requireBucketSyncVersion(), /v1\.1\.2 or later/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('bucket setup writes data and binary files with server mtimes beside the XYPDF source', async t => {
|
|
91
|
+
const file = { filename: 'Sample.JSON', path: 'files/bucket/bucket_unit/key/Sample.JSON', size: 8, date: 80 };
|
|
92
|
+
const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'not json' } });
|
|
93
|
+
fs.rmSync(env.paths.dir, { recursive: true, force: true });
|
|
94
|
+
await env.app.setupBucketContents([{ id: env.item.data.id, file: env.item.file }], { dry: false, force: false });
|
|
95
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
|
|
96
|
+
assert.equal(fs.readFileSync(Path.join(env.paths.files, 'Sample.JSON'), 'utf8'), 'not json');
|
|
97
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 90);
|
|
98
|
+
assert.equal(fs.statSync(Path.join(env.paths.files, 'Sample.JSON')).mtimeMs / 1000, 80);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('upsync replaces data, normalizes manifest names, batches uploads, and deletes missing files last', async t => {
|
|
102
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
103
|
+
const env = fixture(t, { remoteFiles: [removed] });
|
|
104
|
+
// Semicolon needs server filename normalization but remains a regular file on Windows.
|
|
105
|
+
const upload = Path.join(env.paths.files, 'New; File.txt');
|
|
106
|
+
fs.writeFileSync(upload, 'new');
|
|
107
|
+
fs.utimesSync(upload, 110, 110);
|
|
108
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
109
|
+
assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData', 'uploadBucketFiles', 'deleteBucketFile']);
|
|
110
|
+
assert.equal(env.calls[0].request.replace, true);
|
|
111
|
+
assert.deepEqual(env.remote.data, { keep: true, removed: true });
|
|
112
|
+
assert.equal(env.remote.files[0].filename, 'New_ File.txt');
|
|
113
|
+
assert.equal(fs.statSync(upload).mtimeMs / 1000, 130);
|
|
114
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 120);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('downsync replaces changed files and preserves the server timestamp', async t => {
|
|
118
|
+
const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
|
|
119
|
+
const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'remote' }, remoteDataTime: 150 });
|
|
120
|
+
const localFile = Path.join(env.paths.files, file.filename);
|
|
121
|
+
fs.writeFileSync(localFile, 'old');
|
|
122
|
+
fs.utimesSync(localFile, 100, 100);
|
|
123
|
+
await env.app.syncBucketContents([env.item], { down: ['buckets'] });
|
|
124
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
|
|
125
|
+
assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
|
|
126
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
|
|
127
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 150);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('two-way sync chooses the newer data and file copies independently', async t => {
|
|
131
|
+
const old = { filename: 'remote.txt', path: 'files/bucket/bucket_unit/key/remote.txt', size: 6, date: 140 };
|
|
132
|
+
const env = fixture(t, {
|
|
133
|
+
localDataTime: 160,
|
|
134
|
+
remoteDataTime: 150,
|
|
135
|
+
remoteFiles: [old],
|
|
136
|
+
remoteBytes: { [old.path]: 'remote'
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
const localFile = Path.join(env.paths.files, old.filename);
|
|
140
|
+
fs.writeFileSync(localFile, 'stale');
|
|
141
|
+
fs.utimesSync(localFile, 100, 100);
|
|
142
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
|
|
143
|
+
assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData']);
|
|
144
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
|
|
145
|
+
assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
|
|
146
|
+
assert.deepEqual(env.remote.data, env.app.readSyncBucketLocal(env.item).data);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('two-way sync reports equal-time content conflicts instead of guessing', async t => {
|
|
150
|
+
const env = fixture(t, { remoteDataTime: 100 });
|
|
151
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
|
|
152
|
+
assert.equal(env.app.errors.length, 1);
|
|
153
|
+
assert.match(env.app.errors[0], /equal timestamps/);
|
|
154
|
+
assert.deepEqual(env.calls, []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('invalid local data blocks every bucket mutation including file deletion', async t => {
|
|
158
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
159
|
+
const env = fixture(t, { remoteFiles: [removed] });
|
|
160
|
+
fs.writeFileSync(env.paths.data, '{invalid');
|
|
161
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
162
|
+
assert.equal(env.app.errors.length, 1);
|
|
163
|
+
assert.deepEqual(env.calls, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('bucket dry runs leave data, files, and server records unchanged', async t => {
|
|
167
|
+
const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
|
|
168
|
+
const env = fixture(t, { remoteFiles: [removed], dry: true });
|
|
169
|
+
const added = Path.join(env.paths.files, 'new.txt');
|
|
170
|
+
fs.writeFileSync(added, 'new');
|
|
171
|
+
const originalTime = fs.statSync(env.paths.data).mtimeMs;
|
|
172
|
+
await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
|
|
173
|
+
assert.deepEqual(env.calls, []);
|
|
174
|
+
assert.equal(env.remote.files.length, 1);
|
|
175
|
+
assert.deepEqual(env.remote.data, { keep: false, server: true });
|
|
176
|
+
assert.equal(fs.statSync(env.paths.data).mtimeMs, originalTime);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a failed download keeps the previous local file intact', async t => {
|
|
180
|
+
const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
|
|
181
|
+
const env = fixture(t, { remoteFiles: [file] });
|
|
182
|
+
const localFile = Path.join(env.paths.files, file.filename);
|
|
183
|
+
fs.writeFileSync(localFile, 'before');
|
|
184
|
+
fs.utimesSync(localFile, 100, 100);
|
|
185
|
+
await env.app.syncBucketContents([env.item], { down: ['buckets'] });
|
|
186
|
+
assert.equal(env.app.errors.length, 1);
|
|
187
|
+
assert.equal(fs.readFileSync(localFile, 'utf8'), 'before');
|
|
188
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const Path = require('node:path');
|
|
5
|
+
const { api } = require('@pixlcore/xyops-sdk');
|
|
6
|
+
const { loadTestConfig, createCheck, createTempDir, xy, call } = require('./helpers/common.js');
|
|
7
|
+
|
|
8
|
+
test('bucket filesystem sync', async t => {
|
|
9
|
+
loadTestConfig();
|
|
10
|
+
const check = createCheck(t);
|
|
11
|
+
const temp = createTempDir(t, 'xycli-bucket-sync-live-');
|
|
12
|
+
const setupRoot = Path.join(temp, 'setup');
|
|
13
|
+
const activeRoot = Path.join(temp, 'active');
|
|
14
|
+
const stamp = Date.now();
|
|
15
|
+
const id = 'cli_bucket_sync_test_' + stamp;
|
|
16
|
+
const title = 'CLI Bucket Sync Test ' + stamp;
|
|
17
|
+
const slug = title.replace(/\W+/g, '-').replace(/^-|-$/g, '');
|
|
18
|
+
const seed = Path.join(temp, 'seed.json');
|
|
19
|
+
let originalState = null;
|
|
20
|
+
let created = false;
|
|
21
|
+
|
|
22
|
+
fs.mkdirSync(setupRoot, { recursive: true });
|
|
23
|
+
fs.mkdirSync(activeRoot, { recursive: true });
|
|
24
|
+
fs.writeFileSync(seed, 'seed');
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await check('create an isolated Bucket fixture', async () => {
|
|
28
|
+
const before = await call('getMultiple', { lists: 'buckets', state: 1 });
|
|
29
|
+
originalState = structuredClone((before.state && before.state.sync) || {});
|
|
30
|
+
|
|
31
|
+
// --new will skip existing Buckets without downloading their content.
|
|
32
|
+
for (const bucket of before.buckets) {
|
|
33
|
+
fs.writeFileSync(Path.join(setupRoot, 'skip-' + bucket.id + '.json'), JSON.stringify({
|
|
34
|
+
type: 'xypdf', version: '1.0', items: [{ type: 'bucket', data: bucket }]
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
await call('createBucket', { id, title, enabled: false, data: { keep: true, remove: true } });
|
|
38
|
+
created = true;
|
|
39
|
+
const uploaded = await api.uploadBucketFiles({ id }, { files: [seed] });
|
|
40
|
+
assert.ok(!uploaded.err && uploaded.data && uploaded.data.code === 0, 'Seed upload succeeded');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await check('setup exports Bucket data and files', async () => {
|
|
44
|
+
const output = xy(['sync', 'setup', 'buckets', '--new'], { cwd: setupRoot });
|
|
45
|
+
assert.match(output, /🪣.*\/\s*\n/);
|
|
46
|
+
assert.match(output, /📄.*data\.json/);
|
|
47
|
+
assert.match(output, /📂.*files\//);
|
|
48
|
+
assert.match(output, /📎.*seed\.json/);
|
|
49
|
+
assert.doesNotMatch(output, /Bucket content:/);
|
|
50
|
+
const source = Path.join(setupRoot, 'buckets', slug);
|
|
51
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(Path.join(source, 'data.json'), 'utf8')), { keep: true, remove: true });
|
|
52
|
+
assert.equal(fs.readFileSync(Path.join(source, 'files', 'seed.json'), 'utf8'), 'seed');
|
|
53
|
+
fs.mkdirSync(Path.join(activeRoot, 'buckets'), { recursive: true });
|
|
54
|
+
fs.cpSync(source + '.json', Path.join(activeRoot, 'buckets', slug + '.json'));
|
|
55
|
+
fs.cpSync(source, Path.join(activeRoot, 'buckets', slug), { recursive: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await check('upsync replaces data and uploads a new file', async () => {
|
|
59
|
+
const content = Path.join(activeRoot, 'buckets', slug);
|
|
60
|
+
// Semicolon exercises filename normalization on Unix and Windows alike.
|
|
61
|
+
const added = Path.join(content, 'files', 'New; File.txt');
|
|
62
|
+
const source = Path.join(activeRoot, 'buckets', slug + '.json');
|
|
63
|
+
const xypdf = JSON.parse(fs.readFileSync(source, 'utf8'));
|
|
64
|
+
xypdf.items[0].data.notes = 'Local Bucket notes';
|
|
65
|
+
fs.writeFileSync(source, JSON.stringify(xypdf));
|
|
66
|
+
fs.writeFileSync(Path.join(content, 'data.json'), JSON.stringify({ keep: 'local' }));
|
|
67
|
+
fs.writeFileSync(added, 'new content');
|
|
68
|
+
const output = xy(['sync', activeRoot, '--up', 'buckets', '--down', 'false', '--delete', 'false']);
|
|
69
|
+
assert.match(output, new RegExp('Updating bucket: ' + title + ' \\(' + id + '\\)'));
|
|
70
|
+
assert.match(output, new RegExp('\\n\\n ⬆️ Updating bucket data: ' + title + ' \\(' + id + '\\)'));
|
|
71
|
+
assert.match(output, /-.*"keep": true/);
|
|
72
|
+
assert.match(output, /\+.*"keep": "local"/);
|
|
73
|
+
assert.match(output, new RegExp('\\n\\n ⬆️ Uploading \\d+ bucket files?: ' + title + ' \\(' + id + '\\)'));
|
|
74
|
+
assert.match(output, /📎.*New; File\.txt/);
|
|
75
|
+
assert.ok(output.indexOf('Updating bucket data:') > output.indexOf('Updating bucket:'), 'Bucket data has its own section after the definition');
|
|
76
|
+
const bucket = await call('getBucket', { id });
|
|
77
|
+
assert.equal(bucket.bucket.notes, 'Local Bucket notes');
|
|
78
|
+
assert.deepEqual(bucket.data, { keep: 'local' });
|
|
79
|
+
const file = bucket.files.find(entry => entry.filename === 'New_ File.txt');
|
|
80
|
+
assert.ok(file, 'Normalized upload is in the manifest');
|
|
81
|
+
assert.equal(Math.floor(fs.statSync(added).mtimeMs / 1000), file.date);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await check('downsync pulls remote data and file changes', async () => {
|
|
85
|
+
await call('updateBucket', { id, notes: 'Remote Bucket notes' });
|
|
86
|
+
await call('writeBucketData', { id, replace: true, data: { keep: 'remote' } });
|
|
87
|
+
fs.writeFileSync(seed, 'remote replacement');
|
|
88
|
+
const uploaded = await api.uploadBucketFiles({ id }, { files: [seed] });
|
|
89
|
+
assert.ok(!uploaded.err && uploaded.data && uploaded.data.code === 0, 'Replacement upload succeeded');
|
|
90
|
+
xy(['sync', activeRoot, '--up', 'false', '--down', 'buckets', '--delete', 'false']);
|
|
91
|
+
const content = Path.join(activeRoot, 'buckets', slug);
|
|
92
|
+
const xypdf = JSON.parse(fs.readFileSync(content + '.json', 'utf8'));
|
|
93
|
+
assert.equal(xypdf.items[0].data.notes, 'Remote Bucket notes');
|
|
94
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(Path.join(content, 'data.json'), 'utf8')), { keep: 'remote' });
|
|
95
|
+
assert.equal(fs.readFileSync(Path.join(content, 'files', 'seed.json'), 'utf8'), 'remote replacement');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await check('two-way sync pushes newer local content and aligns timestamps', async () => {
|
|
99
|
+
const content = Path.join(activeRoot, 'buckets', slug);
|
|
100
|
+
const dataFile = Path.join(content, 'data.json');
|
|
101
|
+
const file = Path.join(content, 'files', 'seed.json');
|
|
102
|
+
const newer = Math.ceil(Date.now() / 1000) + 5;
|
|
103
|
+
fs.writeFileSync(dataFile, JSON.stringify({ keep: 'two-way' }));
|
|
104
|
+
fs.writeFileSync(file, 'two-way file');
|
|
105
|
+
fs.utimesSync(dataFile, newer, newer);
|
|
106
|
+
fs.utimesSync(file, newer, newer);
|
|
107
|
+
const output = xy(['sync', activeRoot, '--up', 'buckets', '--down', 'buckets', '--delete', 'false']);
|
|
108
|
+
assert.match(output, new RegExp('\\n\\n ⬆️ Updating bucket data: ' + title + ' \\(' + id + '\\)'));
|
|
109
|
+
assert.match(output, /-.*"keep": "remote"/);
|
|
110
|
+
assert.match(output, /\+.*"keep": "two-way"/);
|
|
111
|
+
const bucket = await call('getBucket', { id });
|
|
112
|
+
assert.deepEqual(bucket.data, { keep: 'two-way' });
|
|
113
|
+
const remoteFile = bucket.files.find(entry => entry.filename === 'seed.json');
|
|
114
|
+
assert.equal(remoteFile.size, Buffer.byteLength('two-way file'));
|
|
115
|
+
assert.equal(Math.floor(fs.statSync(file).mtimeMs / 1000), remoteFile.date);
|
|
116
|
+
assert.equal(Math.floor(fs.statSync(dataFile).mtimeMs / 1000), bucket.meta.mod);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
// Ordinary sync writes a shared management map. Restore it exactly, then
|
|
121
|
+
// delete only this uniquely named Bucket and its content.
|
|
122
|
+
const cleanupErrors = [];
|
|
123
|
+
if (originalState !== null) {
|
|
124
|
+
try { await call('update_global_state', { sync: originalState }); }
|
|
125
|
+
catch (error) { cleanupErrors.push(error); }
|
|
126
|
+
}
|
|
127
|
+
if (created) {
|
|
128
|
+
try { await call('deleteBucket', { id }); }
|
|
129
|
+
catch (error) { cleanupErrors.push(error); }
|
|
130
|
+
}
|
|
131
|
+
if (cleanupErrors.length) throw new AggregateError(cleanupErrors, 'Bucket sync fixture cleanup failed');
|
|
132
|
+
}
|
|
133
|
+
});
|
package/test/sync-unit.test.js
CHANGED
|
@@ -18,6 +18,7 @@ const script = `
|
|
|
18
18
|
if (options.setupCwd) process.chdir(options.setupCwd);
|
|
19
19
|
const calls = [];
|
|
20
20
|
const requests = [];
|
|
21
|
+
const apiOptions = [];
|
|
21
22
|
const app = {
|
|
22
23
|
...sync,
|
|
23
24
|
args: options.args,
|
|
@@ -41,9 +42,10 @@ const script = `
|
|
|
41
42
|
compareVersions() { return 0; },
|
|
42
43
|
async getMultiple() {},
|
|
43
44
|
die(message) { throw new Error(message); },
|
|
44
|
-
async callStandardAPI(method, data) {
|
|
45
|
+
async callStandardAPI(method, data, opts) {
|
|
45
46
|
calls.push(method);
|
|
46
47
|
requests.push({ method, data });
|
|
48
|
+
apiOptions.push({ method, opts });
|
|
47
49
|
if (options.failAPI === method) throw new Error('Fixture API failure');
|
|
48
50
|
}
|
|
49
51
|
};
|
|
@@ -51,7 +53,7 @@ const script = `
|
|
|
51
53
|
console.error(err.message);
|
|
52
54
|
process.exitCode = 1;
|
|
53
55
|
}).finally(() => {
|
|
54
|
-
console.log(JSON.stringify({ calls, requests, errors: app.errors || [], warnings: app.warnings || [] }));
|
|
56
|
+
console.log(JSON.stringify({ calls, requests, apiOptions, errors: app.errors || [], warnings: app.warnings || [] }));
|
|
55
57
|
});
|
|
56
58
|
`;
|
|
57
59
|
|
|
@@ -225,6 +227,20 @@ test('sync errors exit nonzero even in quiet mode or a dry run', t => {
|
|
|
225
227
|
}
|
|
226
228
|
});
|
|
227
229
|
|
|
230
|
+
test('sync sends existing Plugin properties through the sync API path', t => {
|
|
231
|
+
const remote = { id: 'legacy', title: 'Legacy', type: 'event', command: 'node', cwd: '/opt/legacy', custom_field: { keep: true }, notes: 'Remote' };
|
|
232
|
+
const local = { ...remote, notes: 'Local' };
|
|
233
|
+
const result = runSync(t, {
|
|
234
|
+
plugins: [remote],
|
|
235
|
+
existingSources: [{ path: 'Legacy.json', type: 'plugin', data: local }],
|
|
236
|
+
args: { up: 'plugins' }
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
assert.equal(result.status, 0);
|
|
240
|
+
assert.deepEqual(result.report.requests.find(request => request.method === 'update_plugin').data, local);
|
|
241
|
+
assert.equal(result.report.apiOptions.find(call => call.method === 'update_plugin').opts.sync, true);
|
|
242
|
+
});
|
|
243
|
+
|
|
228
244
|
test('sync API errors preserve failure status while still sending both notifications', t => {
|
|
229
245
|
const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
|
|
230
246
|
for (const failAPI of ['update_category', 'update_global_state', 'delete_category']) {
|
package/test/utils-unit.test.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const { test } = require('node:test');
|
|
2
2
|
const assert = require('node:assert/strict');
|
|
3
|
+
const cli = require('pixl-cli');
|
|
3
4
|
const utils = require('../lib/utils.js');
|
|
4
5
|
|
|
6
|
+
cli.global();
|
|
7
|
+
|
|
5
8
|
test('executable language detection accepts Unix and Windows command forms', () => {
|
|
6
9
|
// Plugin definitions are portable, so exercise both path styles regardless of
|
|
7
10
|
// the operating system running this test.
|
|
@@ -17,3 +20,50 @@ test('executable language detection accepts Unix and Windows command forms', ()
|
|
|
17
20
|
assert.equal(utils.getLangFromBinary('unknown-binary.exe'), null);
|
|
18
21
|
assert.equal(utils.getLangFromBinary(null), null);
|
|
19
22
|
});
|
|
23
|
+
|
|
24
|
+
test('sync API updates preserve stored properties while manual updates retain typo checks', async () => {
|
|
25
|
+
const calls = [];
|
|
26
|
+
const context = {
|
|
27
|
+
...utils,
|
|
28
|
+
dry: false,
|
|
29
|
+
verbose: false,
|
|
30
|
+
api: {
|
|
31
|
+
async update_plugin(request, opts) {
|
|
32
|
+
calls.push({ method: 'update_plugin', request, opts });
|
|
33
|
+
return { err: null, data: { code: 0 } };
|
|
34
|
+
},
|
|
35
|
+
async update_event(request, opts) {
|
|
36
|
+
calls.push({ method: 'update_event', request, opts });
|
|
37
|
+
return { err: null, data: { code: 0 } };
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
die(message) { throw new Error(message); }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (const [method, request] of [
|
|
44
|
+
['update_plugin', { id: 'legacy_plugin', cwd: '/tmp/legacy' }],
|
|
45
|
+
['update_event', { id: 'legacy_event', custom_field: { enabled: true } }]
|
|
46
|
+
]) {
|
|
47
|
+
// A hand-typed update still rejects an unknown top-level property.
|
|
48
|
+
const prior_calls = calls.length;
|
|
49
|
+
await assert.rejects(context.callStandardAPI(method, request), /Unsupported property/);
|
|
50
|
+
assert.equal(calls.length, prior_calls);
|
|
51
|
+
|
|
52
|
+
// Sync forwards the complete stored object to xyOps for its own validation.
|
|
53
|
+
await context.callStandardAPI(method, request, { sync: true });
|
|
54
|
+
assert.equal(calls.length, prior_calls + 1);
|
|
55
|
+
assert.deepEqual(calls.at(-1), { method, request, opts: {} });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The original report failed during dry sync, before any xyOps request.
|
|
59
|
+
// Preview the same Plugin payload and verify that it makes no SDK call.
|
|
60
|
+
const plugin_request = { id: 'legacy_plugin', cwd: '/tmp/legacy' };
|
|
61
|
+
const previews = [];
|
|
62
|
+
const prior_calls = calls.length;
|
|
63
|
+
context.dry = true;
|
|
64
|
+
context.color = () => cli.chalk.white;
|
|
65
|
+
context.jsonOutput = request => { previews.push(request); };
|
|
66
|
+
await context.callStandardAPI('update_plugin', plugin_request, { sync: true });
|
|
67
|
+
assert.deepEqual(previews, [plugin_request]);
|
|
68
|
+
assert.equal(calls.length, prior_calls);
|
|
69
|
+
});
|