@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 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`. Buckets, Secrets, Users, and Roles are not supported.
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
- Buckets, 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
+ 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
- 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, and remotely deleted definitions leave their old source files behind until you remove them.
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 safely. Delete an item deliberately on both sides, and keep deletion disabled in the scheduled command.
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 | Two-way deletion is not supported. Remove the local source and the xyOps definition deliberately as one coordinated operation. |
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(/^(buckets|secrets|users|roles)$/) );
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(/^(bucket|secret|user|role)$/)) {
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
- // render diff
317
- var diff = "```diff\n";
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(/^(buckets|secrets|users|roles)$/) );
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, ['buckets', 'secrets', 'users', 'roles'])) {
779
- this.die("Sorry, the sync engine cannot process buckets, secrets, users, or roles.");
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(/^(bucket|secret|user|role)$/)) {
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.7",
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
+ });
@@ -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']) {
@@ -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
+ });