@pixlcore/xycli 1.0.8 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docs/help.md +14 -2
- package/docs/sync.md +50 -9
- package/docs/two-way-git-tutorial.md +10 -6
- package/index.js +22 -1
- package/lib/apikey.js +1 -1
- package/lib/marketplace.js +1 -1
- package/lib/sync-syshook.js +207 -0
- package/lib/sync.js +49 -15
- package/lib/transfer.js +3 -3
- package/lib/utils.js +6 -2
- package/package.json +2 -2
- package/test/help.test.js +56 -0
- package/test/sync-syshook-unit.test.js +231 -0
- package/test/sync-unit.test.js +100 -0
- package/test/toast.test.js +3 -3
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ These switches control output or execution across many commands. Add them to the
|
|
|
134
134
|
| Switch | Short form | What it does |
|
|
135
135
|
| --- | --- | --- |
|
|
136
136
|
| `--verbose` | `-v` | Show extra detail. Resource views may expand scripts, parameters, or monitoring sections; many mutation commands also display API requests and responses. |
|
|
137
|
-
| `--quiet` | `-q` | Suppress ordinary terminal output and progress displays. JSON data still prints, and
|
|
137
|
+
| `--quiet` | `-q` | Suppress ordinary terminal output and progress displays. JSON data still prints, and human-readable warnings and errors remain visible on stderr. |
|
|
138
138
|
| `--dry` | | Preview a supported operation without applying it. Resource mutations, imports, exports, sync, and API calls use this to show planned changes or requests. It does not prevent connection checks or reads, and does not apply to local `xy config` updates. |
|
|
139
139
|
| `--format json` | `-f json` | Display supported results as indented JSON instead of the usual report or table. |
|
|
140
140
|
| `--format jsonc` | `-f jsonc` | Display supported results as compact JSON on one line. Here, `jsonc` means compact JSON. |
|
|
@@ -157,7 +157,7 @@ XYOPS_COLOR=false xy events --format json --quiet > ./events.json
|
|
|
157
157
|
xy categories -f jsonc -q > ./categories.json
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
Human-readable warnings and errors print to stderr even in quiet mode, so redirecting JSON output to a file leaves those diagnostics visible in your terminal. Structured JSON results may also include warnings and errors. Sync warnings and errors still produce a nonzero exit status. Check exit status and any structured results when scripting.
|
|
161
161
|
|
|
162
162
|
JSON output varies by command: some local collection views return all matches even when their table view is paginated, while database searches may return only the selected page. Some commands print multiple JSON values, such as the request and response from `xy api`. Check the command reference when using the output in a script.
|
|
163
163
|
|
package/docs/help.md
CHANGED
|
@@ -213,6 +213,7 @@ Pass one or more base directories, or omit them to scan the current directory. F
|
|
|
213
213
|
- `--down TYPES` updates local files from xyOps.
|
|
214
214
|
- `--dry` previews changes without applying them.
|
|
215
215
|
- `--verbose` shows full diffs and API requests and responses.
|
|
216
|
+
- `--ignore_missing` skips local sources whose type and ID are absent from xyOps, without a warning or failure. It leaves their files untouched.
|
|
216
217
|
|
|
217
218
|
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.
|
|
218
219
|
|
|
@@ -239,6 +240,16 @@ xy sync ./ --up events,plugins --delete events
|
|
|
239
240
|
|
|
240
241
|
**The scanned directories must contain a complete inventory of the other objects you intend to keep for every type selected for deletion.** Delete mode is not limited to previously synced objects. A missing file, incomplete export, or unavailable mount can cause unintended deletions of your own definitions. Verify that all source directories are available and inspect a dry run before removing `--dry`. Source warnings or scan errors stop the run before any updates begin. If an update or sync-tracking API operation fails, earlier successful updates remain in place, but the entire delete pass is skipped for that run.
|
|
241
242
|
|
|
243
|
+
**System Hook deletions**
|
|
244
|
+
|
|
245
|
+
Use `--syshook ACTION` when a matching xyOps System Hook launches the CLI. This supports deletion hooks for every standard sync definition type except Buckets. It reads the activity JSON from STDIN and removes only the XYPDF with the matching type and ID, plus its neighboring property files. It does not run ordinary sync, compare definitions, or call the xyOps API. Pass exactly one sync directory, and use an absolute path because System Hooks start in a temporary directory. See the [supported actions](sync.md#system-hook-deletion) for the exact hook names, including `apikey_delete` for API Keys.
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
xy sync /srv/xyops-automation --syshook event_delete --down_cmd /usr/local/sbin/commit-xyops-deletion
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--down_cmd` runs from the sync directory only after files are actually removed. A missing local source is a successful no-op; duplicate matching sources stop the command without deleting either one. `--dry` previews the files without deleting them or running `down_cmd`. No xyOps URL or API Key is needed. See [System Hook deletion](sync.md#system-hook-deletion) for configuration and operational details.
|
|
252
|
+
|
|
242
253
|
**Completion commands and notifications**
|
|
243
254
|
|
|
244
255
|
```sh
|
|
@@ -254,7 +265,7 @@ xy sync ./ --up events,plugins \
|
|
|
254
265
|
- `--error_email ADDRESS` sends warnings and errors by email through xyOps.
|
|
255
266
|
- `--error_event EVENT_ID` runs an Event on warnings or errors.
|
|
256
267
|
|
|
257
|
-
Completion commands run in a local shell in the first base directory.
|
|
268
|
+
Completion commands run in a local shell in the first base directory. Successful up-only deletion triggers `up_cmd`; local System Hook deletion triggers an explicitly supplied `down_cmd`. Event notifications receive `input.data.errors` and `input.data.warnings`.
|
|
258
269
|
|
|
259
270
|
The configured API Key needs the appropriate resource permissions and `update_state` for sync tracking updates. Notification actions also need their usual permissions. Earlier successful changes are not rolled back if a later operation fails. Retry the sync after correcting an error; previously applied updates compare equal, and deletion remains disabled until the pre-delete phase completes without warnings or errors.
|
|
260
271
|
|
|
@@ -306,6 +317,7 @@ For each Bucket, setup also writes `BUCKET-SLUG/data.json` and downloads its fil
|
|
|
306
317
|
- `--file_props PATHS` extracts string properties into adjacent files.
|
|
307
318
|
- `--default_ext EXT` sets the fallback neighbor extension instead of `txt`.
|
|
308
319
|
- `--new` writes only remote definitions not already represented locally.
|
|
320
|
+
- `--ignore_missing` lets `--new` skip local sources whose type and ID are absent from xyOps.
|
|
309
321
|
- `--down_cmd COMMAND` runs after setup writes one or more definitions.
|
|
310
322
|
- `--cmd_timeout SECONDS` changes the command timeout from 30 seconds.
|
|
311
323
|
- `--stock` includes stock objects, which are omitted by default.
|
|
@@ -317,7 +329,7 @@ For `--file_props`, use comma-separated property names or dot paths, such as `sc
|
|
|
317
329
|
|
|
318
330
|
The same property selection also checks every workflow node automatically. For example, `params.script` in a node's `data` becomes a neighbor such as `My-Workflow-workflow.nt3sr3y4.params.script.sh`. The node ID in the filename identifies which node owns the property. No additional setup option is needed.
|
|
319
331
|
|
|
320
|
-
Use `--new` from the root of an existing sync tree to add definitions that exist in xyOps but have no local source. Setup scans recursively and matches existing sources by item type and exact ID, so renamed files and custom folder layouts are preserved. New definitions use the normal suggested type and Category folders. Malformed, duplicate, unsupported, or orphaned local sources stop the operation before any new files are written. `--new` cannot be combined with `--force`, and it does not create new definitions in xyOps.
|
|
332
|
+
Use `--new` from the root of an existing sync tree to add definitions that exist in xyOps but have no local source. Setup scans recursively and matches existing sources by item type and exact ID, so renamed files and custom folder layouts are preserved. New definitions use the normal suggested type and Category folders. Malformed, duplicate, unsupported, or orphaned local sources stop the operation before any new files are written. With `--ignore_missing`, only sources whose type and ID are absent from xyOps are skipped. `--new` cannot be combined with `--force`, and it does not create new definitions in xyOps.
|
|
321
333
|
|
|
322
334
|
An explicitly supplied `--down_cmd` runs from the current setup directory after at least one definition is written. It does not run when `--new` finds nothing or during a dry run. Saved sync completion commands are not inherited by setup.
|
|
323
335
|
|
package/docs/sync.md
CHANGED
|
@@ -172,7 +172,7 @@ xy sync setup events plugins categories --new --file_props script,params.script
|
|
|
172
172
|
xy sync setup events plugins categories --new --file_props script,params.script
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
New-only setup scans the current directory recursively and indexes existing XYPDF sources by item type and exact ID. Renamed files and custom folders are preserved. Remote definitions not found in that index are written to setup's normal type and Category folders; existing definitions are skipped without being rewritten. Malformed, duplicate, unsupported, or locally orphaned sources stop the operation before new files are written. `--new` cannot be combined with `--force`.
|
|
175
|
+
New-only setup scans the current directory recursively and indexes existing XYPDF sources by item type and exact ID. Renamed files and custom folders are preserved. Remote definitions not found in that index are written to setup's normal type and Category folders; existing definitions are skipped without being rewritten. Malformed, duplicate, unsupported, or locally orphaned sources stop the operation before new files are written. Add `--ignore_missing` to skip only local sources whose type and ID are absent from xyOps. `--new` cannot be combined with `--force`.
|
|
176
176
|
|
|
177
177
|
This is an additive local export. It does not create definitions in xyOps, and ordinary upsync or downsync still updates only definitions that already exist on both sides.
|
|
178
178
|
|
|
@@ -394,15 +394,17 @@ Relative paths resolve against the CLI's current working directory. Completion c
|
|
|
394
394
|
| `--up TYPES` | `sync.up` | Disabled | Sync upwards to xyOps from local sources for these types. |
|
|
395
395
|
| `--down TYPES` | `sync.down` | Disabled | Down downwards to existing local sources from xyOps for these types. |
|
|
396
396
|
| `--delete TYPES` | `sync.delete` | Disabled | Delete remote objects with no local source. Use only in an up-only workflow; see [Delete mode](#delete-mode). |
|
|
397
|
+
| `--syshook ACTION` | n/a | Disabled | Handle one supported definition deletion activity from STDIN as a local-only deletion; see [System Hook deletion](#system-hook-deletion). |
|
|
398
|
+
| `--ignore_missing` | `sync.ignore_missing` | Off | Skip local sources whose type and ID are absent from xyOps, without a warning or failure. Leave their files untouched. |
|
|
397
399
|
| `--up_cmd COMMAND` | `sync.up_cmd` | None | Run a local shell command after at least one successful upsync. |
|
|
398
|
-
| `--down_cmd COMMAND` | `sync.down_cmd` | None | Run a local shell command after at least one successful downsync. |
|
|
400
|
+
| `--down_cmd COMMAND` | `sync.down_cmd` | None | Run a local shell command after at least one successful downsync, or an explicit command after a System Hook deletion. |
|
|
399
401
|
| `--cmd_timeout SECONDS` | `sync.cmd_timeout` | `30` | Timeout for each completion command. |
|
|
400
402
|
| `--error_email ADDRESS` | `sync.error_email` | None | Email collected warnings and errors through xyOps. |
|
|
401
403
|
| `--error_event EVENT_ID` | `sync.error_event` | None | Launch an Event with collected warnings and errors in its input data. |
|
|
402
404
|
| `--lock_file PATH` | `sync.lock_file` | Temporary file keyed by `base_url` | Use an explicit path for the built-in PID lock. |
|
|
403
405
|
| `--dry` | n/a | Off | Dry-Run Mode: Preview without writes, completion commands, or notifications. |
|
|
404
406
|
| `--verbose`, `-v` | n/a | Off | Show complete diffs and additional request, response, and write details. |
|
|
405
|
-
| `--quiet`, `-q` | n/a | Off | Suppress routine output and progress.
|
|
407
|
+
| `--quiet`, `-q` | n/a | Off | Suppress routine output and progress. Warnings and errors remain visible on standard error. |
|
|
406
408
|
|
|
407
409
|
At least one direction must be enabled. The command is a single run, not a background watcher; repeat it through a scheduler or hook for automatic operation.
|
|
408
410
|
|
|
@@ -417,6 +419,7 @@ At least one direction must be enabled. The command is a single run, not a backg
|
|
|
417
419
|
| `--file_props PATHS` | Extract string properties into adjacent neighbor files. Accepts comma-separated dot paths or a JSON array. |
|
|
418
420
|
| `--default_ext EXT` | Use this extension instead of `txt` when a neighbor's type cannot be detected. A leading dot is optional. |
|
|
419
421
|
| `--new` | Export only remote definitions whose type and ID are not already present in the current local tree. |
|
|
422
|
+
| `--ignore_missing` | With `--new`, skip local sources whose type and ID are absent from xyOps. Leave their files untouched. |
|
|
420
423
|
| `--down_cmd COMMAND` | Run a local shell command after setup writes one or more definitions. |
|
|
421
424
|
| `--cmd_timeout SECONDS` | Set the setup completion-command timeout in seconds. The default is `30`. |
|
|
422
425
|
| `--stock` | Include stock definitions that ships wit xyOps (e.g. "Shell Plugin"), which setup normally omits. |
|
|
@@ -454,12 +457,50 @@ Two-way sync does not propagate deletions. Definition deletions are usually infr
|
|
|
454
457
|
|
|
455
458
|
1. Delete the definition in the xyOps web interface first.
|
|
456
459
|
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.
|
|
460
|
+
3. Run sync again, or resume the schedule, after the local files are gone. Without `--ignore_missing`, pause the schedule during these steps if you want to avoid a failed pass between the two removals.
|
|
458
461
|
|
|
459
|
-
If a sync runs after the xyOps deletion but before local cleanup, the leftover source produces a missing-object warning
|
|
462
|
+
If a sync runs after the xyOps deletion but before local cleanup, the leftover source normally produces a missing-object warning and stops the run before any definition changes. Add `--ignore_missing` to ordinary sync and any following `sync setup --new` command to skip that source and keep processing other definitions. This does not remove the local files or propagate the deletion to Git. Remove those files yourself to complete the deletion. Other scan warnings and errors still stop the run.
|
|
460
463
|
|
|
461
464
|
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.
|
|
462
465
|
|
|
466
|
+
### System Hook deletion
|
|
467
|
+
|
|
468
|
+
xyOps [System Hooks](https://docs.xyops.io/#Docs/syshooks) can launch xyCLI when a standard synced definition is deleted. Configure each deletion action you need separately. The `--syshook` command reads the activity JSON from STDIN, checks that its action matches the command, and uses the deleted object's type and exact ID to find one local XYPDF source. It removes that source and its neighboring external property files. Other local definitions, including new ones not yet in xyOps, are left alone.
|
|
469
|
+
|
|
470
|
+
| Sync selection | System Hook action |
|
|
471
|
+
| --- | --- |
|
|
472
|
+
| `alerts` | `alert_delete` |
|
|
473
|
+
| `api_keys` | `apikey_delete` |
|
|
474
|
+
| `categories` | `category_delete` |
|
|
475
|
+
| `channels` | `channel_delete` |
|
|
476
|
+
| `events` (including workflows) | `event_delete` |
|
|
477
|
+
| `groups` | `group_delete` |
|
|
478
|
+
| `monitors` | `monitor_delete` |
|
|
479
|
+
| `plugins` | `plugin_delete` |
|
|
480
|
+
| `tags` | `tag_delete` |
|
|
481
|
+
| `web_hooks` | `web_hook_delete` |
|
|
482
|
+
|
|
483
|
+
Buckets are excluded because a Bucket may have separate JSON data and uploaded files alongside its XYPDF source. The `bucket_delete` activity is not supported by `--syshook`.
|
|
484
|
+
|
|
485
|
+
For example, configure the following hooks on the primary conductor, adjusting executable and checkout paths:
|
|
486
|
+
|
|
487
|
+
```json
|
|
488
|
+
{
|
|
489
|
+
"hooks": {
|
|
490
|
+
"event_delete": {
|
|
491
|
+
"shell_exec": "/usr/local/bin/xy sync /srv/xyops-automation --syshook event_delete --down_cmd /usr/local/sbin/commit-xyops-deletion"
|
|
492
|
+
},
|
|
493
|
+
"plugin_delete": {
|
|
494
|
+
"shell_exec": "/usr/local/bin/xy sync /srv/xyops-automation --syshook plugin_delete --down_cmd /usr/local/sbin/commit-xyops-deletion"
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
The `--down_cmd` script can stage, commit, and push the deletions in a dedicated Git checkout. It runs only after at least one file was removed. A missing source is a successful no-op, and duplicate matching sources stop the command before any files are removed. Use `--dry` to preview the selected files without changing them or running the completion command.
|
|
501
|
+
|
|
502
|
+
This mode does not run ordinary sync, fetch remote definitions, or change xyOps. It ignores saved sync direction settings and requires exactly one directory. No xyOps URL or API Key is needed. The CLI still uses a host-local lock, with a fixed default lock key when no `base_url` is configured. System shell hooks run on the primary conductor from a temporary directory, and xyOps limits them to 60 seconds. The conductor must be able to access the checkout and Git credentials. If regular sync runs on another host, its local lock cannot protect this checkout; arrange for both operations to use the same host and lock if they share a checkout. Watch hook failures and Git push failures, because a failed hook is not automatically retried.
|
|
503
|
+
|
|
463
504
|
## Remote-management warnings
|
|
464
505
|
|
|
465
506
|
Up-only sync records the managed definitions in xyOps's global sync state. The web interface and xyCLI update commands use this information to warn that a definition is under remote management and should not be edited directly.
|
|
@@ -544,7 +585,7 @@ xy sync ./ --down events,plugins --down_cmd "./commit-downloads.sh" --cmd_timeou
|
|
|
544
585
|
### When commands run
|
|
545
586
|
|
|
546
587
|
- `up_cmd` runs if at least one object was successfully upsynced (this includes deletes).
|
|
547
|
-
- `down_cmd` runs if at least one object was successfully downsynced.
|
|
588
|
+
- `down_cmd` runs if at least one object was successfully downsynced. In `--syshook` mode, an explicitly supplied `down_cmd` runs after the matching local files are deleted.
|
|
548
589
|
- During setup, an explicitly supplied `down_cmd` runs if at least one definition was written.
|
|
549
590
|
- If both apply, `up_cmd` runs first, then `down_cmd`.
|
|
550
591
|
- Neither runs for an identical/no-op run, a scan that stopped before changes, or a dry run.
|
|
@@ -635,7 +676,7 @@ Runs with no warnings or errors exit with status `0`. Scan warnings prevent chan
|
|
|
635
676
|
|
|
636
677
|
Every `xy sync` command acquires a host-local PID lock before it loads remote definitions or touches local files. This includes ordinary up, down, two-way, delete, dry-run, and setup operations. The lock remains held through completion commands and error notifications, so every CLI entry point uses the same overlap protection automatically.
|
|
637
678
|
|
|
638
|
-
By default, xyCLI creates a lock file in the operating system's temporary directory. Its filename contains a hash of the normalized `base_url`, so syncs targeting the same xyOps instance on one host serialize even when they use different directories or API Keys. Syncs for different instances use different default locks.
|
|
679
|
+
By default, xyCLI creates a lock file in the operating system's temporary directory. Its filename contains a hash of the normalized `base_url`, so syncs targeting the same xyOps instance on one host serialize even when they use different directories or API Keys. Syncs for different instances use different default locks. System Hook deletions without a configured `base_url` use a fixed local key instead.
|
|
639
680
|
|
|
640
681
|
When another live sync process owns the lock, the new command stops before doing any work, prints the owning PID and lock path, and exits nonzero. It does not wait or queue. Schedulers can try again on their next run. If a process was forcibly terminated and left its PID file behind, the next invocation detects that the PID is dead, removes the stale file, atomically acquires a replacement, and continues.
|
|
641
682
|
|
|
@@ -691,7 +732,7 @@ PATH=/usr/local/bin:/usr/bin:/bin
|
|
|
691
732
|
|
|
692
733
|
Choose only the item types your tree manages. This example explicitly disables downsync and deletion. Add `--error_event EVENT_ID` if you prefer an error-handling Event, or configure notifications in the account's CLI config file.
|
|
693
734
|
|
|
694
|
-
`--quiet` keeps routine progress and diffs out of cron's output, avoiding an email for every ordinary run.
|
|
735
|
+
`--quiet` keeps routine progress and diffs out of cron's output, avoiding an email for every ordinary run. Warnings and errors still print to standard error. Configure notifications if you also want xyOps to send a message or launch an Event when sync encounters a problem.
|
|
695
736
|
|
|
696
737
|
If the previous run is still active, the built-in PID lock rejects the overlapping invocation with a nonzero exit status. The next minute tries again. A single sync can therefore take longer than a minute without starting another copy.
|
|
697
738
|
|
|
@@ -946,7 +987,7 @@ Repository concurrency controls these workflow runs. Coordinate all writers to t
|
|
|
946
987
|
| `down_cmd` does not run | Normal sync requires a successful download. Setup requires at least one written definition and an explicit `--down_cmd`. No-op and dry runs do not trigger it. |
|
|
947
988
|
| A download commit launches another sync | Disable deployment Git hooks for commits made by `down_cmd`. The nested sync will otherwise be rejected by the built-in PID lock. |
|
|
948
989
|
| Sync reports another PID is running | Another local sync owns the lock. Let it finish. If the PID is no longer alive, the next run recovers the stale file automatically. If the PID belongs to an unrelated recycled process, verify that no sync is active before removing the reported lock file. |
|
|
949
|
-
| A scheduled run prints nothing | `--quiet` suppresses routine output and
|
|
990
|
+
| A scheduled run prints nothing | `--quiet` suppresses routine output. Warnings and errors still print to standard error. Check that the scheduler captures it. |
|
|
950
991
|
| Sync exits nonzero after a warning report | Scan warnings prevent changes from being applied, so warnings and errors both exit with status `1`. Resolve the reported problem before retrying. |
|
|
951
992
|
| Notifications never arrive | Check API access, email setup and privileges, the error Event's manual trigger, and failures occurring before reporting. |
|
|
952
993
|
| API Key updates behave differently after import | Portable definitions do not transfer usable secret key material. Treat key credentials separately from their definitions. |
|
|
@@ -151,6 +151,7 @@ GIT_DOWN_CMD="git add --all && git commit -m 'Sync from xyOps' && git push"
|
|
|
151
151
|
--up events \
|
|
152
152
|
--down events \
|
|
153
153
|
--delete false \
|
|
154
|
+
--ignore_missing \
|
|
154
155
|
--down_cmd "$GIT_DOWN_CMD" \
|
|
155
156
|
--cmd_timeout 120
|
|
156
157
|
|
|
@@ -159,6 +160,7 @@ GIT_DOWN_CMD="git add --all && git commit -m 'Sync from xyOps' && git push"
|
|
|
159
160
|
# The setup completion command runs only when new files were actually written.
|
|
160
161
|
"$XY_BIN" sync setup events \
|
|
161
162
|
--new \
|
|
163
|
+
--ignore_missing \
|
|
162
164
|
--file_props script,params.script \
|
|
163
165
|
--default_ext ps1 \
|
|
164
166
|
--down_cmd "$GIT_DOWN_CMD" \
|
|
@@ -185,6 +187,8 @@ The two uses of `--down_cmd` are deliberately identical:
|
|
|
185
187
|
- `sync setup --new` runs it only after at least one new local definition is written.
|
|
186
188
|
- A no-op or dry run does not create a commit or push.
|
|
187
189
|
|
|
190
|
+
The two `--ignore_missing` switches let the script continue while a definition deleted in xyOps still has a local XYPDF source. Other definitions can sync and new xyOps definitions can be exported during that interval. The stale source and its neighboring files remain in Git until you remove and push them. Other scan problems still stop the run.
|
|
191
|
+
|
|
188
192
|
The command stages the entire dedicated repository with `git add --all`. Do not use this script in a checkout containing unrelated work. If your repository contains other material, narrow the Git paths in `GIT_DOWN_CMD`.
|
|
189
193
|
|
|
190
194
|
## 5. Schedule the script with cron
|
|
@@ -207,7 +211,7 @@ Run the script every minute, with `flock` outside the script:
|
|
|
207
211
|
* * * * * /usr/bin/flock --nonblock --conflict-exit-code 0 /tmp/xyops-git-sync.lock /usr/local/sbin/xyops-git-sync >/dev/null
|
|
208
212
|
```
|
|
209
213
|
|
|
210
|
-
Cron normally emails job output when local mail delivery is configured. This entry discards routine standard output so successful runs remain silent, but leaves standard error alone so failures can still trigger mail. The xyCLI commands
|
|
214
|
+
Cron normally emails job output when local mail delivery is configured. This entry discards routine standard output so successful runs remain silent, but leaves standard error alone so failures can still trigger mail. The xyCLI commands show progress when run manually; you can add `--quiet` to suppress that progress while keeping warnings and errors on standard error.
|
|
211
215
|
|
|
212
216
|
To keep successful output in a log instead, replace `>/dev/null` with an append redirect to a file that the sync account can write. Do not place that log inside the Git repository because the completion command stages the whole tree.
|
|
213
217
|
|
|
@@ -247,13 +251,13 @@ When someone creates a new Event in xyOps, the ordinary sync pass does not know
|
|
|
247
251
|
|
|
248
252
|
### Delete a definition from both sides
|
|
249
253
|
|
|
250
|
-
|
|
254
|
+
You can automate deletions made in xyOps with [System Hook deletion](sync.md#system-hook-deletion). For manual coordination, the `--ignore_missing` switches let the scheduled script keep running while you complete these steps:
|
|
251
255
|
|
|
252
256
|
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.
|
|
254
|
-
3.
|
|
257
|
+
2. Remove its XYPDF JSON file and all neighboring property files from Git, then commit and push the deletion.
|
|
258
|
+
3. Check that the dedicated sync checkout receives the commit and completes a later sync run.
|
|
255
259
|
|
|
256
|
-
If the script runs after the xyOps deletion but before the Git deletion reaches its checkout,
|
|
260
|
+
If the script runs after the xyOps deletion but before the Git deletion reaches its checkout, both sync commands skip that local source without treating it as a warning. They leave its files in place and continue processing other definitions. Remove and push the stale files to complete the deletion. Without `--ignore_missing` on both commands, the missing source stops the script.
|
|
257
261
|
|
|
258
262
|
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
263
|
|
|
@@ -267,7 +271,7 @@ Do not remove the Git files first. While the definition still exists in xyOps, `
|
|
|
267
271
|
| 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. |
|
|
268
272
|
| 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`. |
|
|
269
273
|
| 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. |
|
|
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.
|
|
274
|
+
| 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. Use `--ignore_missing` on both sync commands to keep the script running during the gap. |
|
|
271
275
|
| 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. |
|
|
272
276
|
| A completion command times out | Increase `--cmd_timeout`, investigate Git network latency, and confirm that Git cannot wait for interactive input. |
|
|
273
277
|
|
package/index.js
CHANGED
|
@@ -18,6 +18,15 @@ if (!process.env.XYOPS_USER_AGENT) {
|
|
|
18
18
|
process.env.XYOPS_USER_AGENT = `xyOps CLI v${pkg.version} (Node.js); ${os.hostname()} (${os.platform()}/${os.arch()})`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Warnings and errors must reach stderr even in quiet mode. Install this
|
|
22
|
+
// before cli.global() so its global warn() helper is bound to cli as usual.
|
|
23
|
+
cli.warn = function(msg) {
|
|
24
|
+
if (this.progress.running) this.progress.erase();
|
|
25
|
+
process.stderr.write(msg);
|
|
26
|
+
if (this.progress.running) this.progress.draw();
|
|
27
|
+
this.log(msg);
|
|
28
|
+
};
|
|
29
|
+
|
|
21
30
|
cli.global();
|
|
22
31
|
|
|
23
32
|
cli.mapArgs({
|
|
@@ -26,7 +35,8 @@ cli.mapArgs({
|
|
|
26
35
|
'd': 'debug',
|
|
27
36
|
'f': 'format',
|
|
28
37
|
'r': 'raw',
|
|
29
|
-
'h': 'help'
|
|
38
|
+
'h': 'help',
|
|
39
|
+
'ignore-missing': 'ignore_missing'
|
|
30
40
|
});
|
|
31
41
|
|
|
32
42
|
process.on('SIGINT', function() { cli.progress.end(); process.exit(130); } );
|
|
@@ -240,6 +250,14 @@ const app = {
|
|
|
240
250
|
delete args.quiet;
|
|
241
251
|
delete args.verbose;
|
|
242
252
|
|
|
253
|
+
// A System Hook deletion uses only its STDIN payload and local XYPDF
|
|
254
|
+
// files. Run it before the normal xyOps API credential and config fetch.
|
|
255
|
+
if ((cmd == 'sync') && ('syshook' in args)) {
|
|
256
|
+
await this.cmd_sync();
|
|
257
|
+
print("\n");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
243
261
|
// create temp dir if needed
|
|
244
262
|
this.tempDir = this.config.temp_dir || Path.join( os.tmpdir(), 'xyops', 'cli' );
|
|
245
263
|
if (!fs.existsSync(this.tempDir)) Tools.mkdirpSync( this.tempDir );
|
|
@@ -413,6 +431,8 @@ const app = {
|
|
|
413
431
|
|
|
414
432
|
dieUsage(heading) {
|
|
415
433
|
// print help and exit
|
|
434
|
+
if (cli.args.quiet) this.die("Invalid usage. Run 'xy help " + heading + "' for details.");
|
|
435
|
+
|
|
416
436
|
this.printHelp(heading);
|
|
417
437
|
println("");
|
|
418
438
|
process.exit(1);
|
|
@@ -459,6 +479,7 @@ Tools.mergeHashInto( app, require('./lib/transfer.js') );
|
|
|
459
479
|
Tools.mergeHashInto( app, require('./lib/marketplace.js') );
|
|
460
480
|
Tools.mergeHashInto( app, require('./lib/sync.js') );
|
|
461
481
|
Tools.mergeHashInto( app, require('./lib/sync-buckets.js') );
|
|
482
|
+
Tools.mergeHashInto( app, require('./lib/sync-syshook.js') );
|
|
462
483
|
|
|
463
484
|
global.app = app;
|
|
464
485
|
|
package/lib/apikey.js
CHANGED
|
@@ -241,7 +241,7 @@ module.exports = {
|
|
|
241
241
|
println( "\n " + cyan.bold("API Key Secret: ") + bold(data.plain_key) );
|
|
242
242
|
println( " " + yellow.bold("Save this secret now. It will never be displayed again.") );
|
|
243
243
|
if (state_error) {
|
|
244
|
-
|
|
244
|
+
warnln( "\n " + red.bold("Disable Error: ") + (state_error.message || state_error) );
|
|
245
245
|
process.exitCode = 1;
|
|
246
246
|
return;
|
|
247
247
|
}
|
package/lib/marketplace.js
CHANGED
|
@@ -235,7 +235,7 @@ module.exports = {
|
|
|
235
235
|
]
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
-
if (warning)
|
|
238
|
+
if (warning) warnln("\n " + yellow(warning));
|
|
239
239
|
println("\n This is the Plugin definition that will be " + (operation == 'create' ? 'created' : 'used to update the installed Plugin') + " when you confirm:");
|
|
240
240
|
this.jsonOutput(display.plugin);
|
|
241
241
|
if (display.script) this.printPluginScript(plugin);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Local XYPDF deletion for xyOps System Hooks.
|
|
2
|
+
// This command never updates definitions or queries the remote server.
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const Path = require('path');
|
|
6
|
+
const cli = require('pixl-cli');
|
|
7
|
+
const Tools = cli.Tools;
|
|
8
|
+
|
|
9
|
+
const MAX_HOOK_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
// Map each supported xyOps deletion activity to both its STDIN payload key
|
|
12
|
+
// and its XYPDF item type. API Keys use "apikey" in the activity name but
|
|
13
|
+
// "api_key" in the payload; workflows are stored as Event definitions.
|
|
14
|
+
// Buckets are excluded because their data and uploaded files need separate
|
|
15
|
+
// deletion handling beyond the single XYPDF source and its property files.
|
|
16
|
+
const HOOK_TYPES = {
|
|
17
|
+
alert_delete: 'alert',
|
|
18
|
+
apikey_delete: 'api_key',
|
|
19
|
+
category_delete: 'category',
|
|
20
|
+
channel_delete: 'channel',
|
|
21
|
+
event_delete: 'event',
|
|
22
|
+
group_delete: 'group',
|
|
23
|
+
monitor_delete: 'monitor',
|
|
24
|
+
plugin_delete: 'plugin',
|
|
25
|
+
tag_delete: 'tag',
|
|
26
|
+
web_hook_delete: 'web_hook'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function readHookPayload() {
|
|
30
|
+
// System Hooks send one JSON activity object followed by a newline. Read
|
|
31
|
+
// until EOF so a partial pipe write cannot be mistaken for a valid event.
|
|
32
|
+
if (process.stdin.isTTY) throw new Error("System Hook sync requires an activity payload on STDIN.");
|
|
33
|
+
|
|
34
|
+
var chunks = [];
|
|
35
|
+
var size = 0;
|
|
36
|
+
for await (const chunk of process.stdin) {
|
|
37
|
+
var buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
38
|
+
size += buffer.length;
|
|
39
|
+
if (size > MAX_HOOK_BYTES) throw new Error("System Hook activity payload is too large.");
|
|
40
|
+
chunks.push(buffer);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!size) throw new Error("System Hook activity payload is empty.");
|
|
44
|
+
|
|
45
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
46
|
+
catch (err) { throw new Error("Invalid System Hook activity JSON: " + err.message); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listLocalFiles(dir) {
|
|
50
|
+
// Match ordinary sync's visible-file scan, but never follow symbolic links.
|
|
51
|
+
// A local delete must not cross into an unrelated tree through a link.
|
|
52
|
+
var files = [];
|
|
53
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
54
|
+
if (entry.name.startsWith('.')) continue;
|
|
55
|
+
var file = Path.join(dir, entry.name);
|
|
56
|
+
if (entry.isDirectory()) files.push(...listLocalFiles(file));
|
|
57
|
+
else if (entry.isFile()) files.push(file);
|
|
58
|
+
}
|
|
59
|
+
return files;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isInsideDir(file, dir) {
|
|
63
|
+
var relative = Path.relative(dir, file);
|
|
64
|
+
return !!relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scanSources(files) {
|
|
68
|
+
// Index every valid XYPDF stem, not only the target item. A source named
|
|
69
|
+
// "Foo" must not claim property files belonging to "Foo-V2".
|
|
70
|
+
var sources = [];
|
|
71
|
+
var bucket_dirs = [];
|
|
72
|
+
var parse_errors = [];
|
|
73
|
+
for (const file of files.filter( file => file.match(/\.json$/i) )) {
|
|
74
|
+
var raw = null;
|
|
75
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
76
|
+
catch (err) { throw new Error("Failed to read JSON file: " + file + ": " + err.message); }
|
|
77
|
+
|
|
78
|
+
var xypdf = null;
|
|
79
|
+
try { xypdf = JSON.parse(raw); }
|
|
80
|
+
catch (err) {
|
|
81
|
+
parse_errors.push({ file, err });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!xypdf || (xypdf.type !== 'xypdf')) continue;
|
|
85
|
+
|
|
86
|
+
var stem = Path.basename(file).replace(/\.json$/i, '');
|
|
87
|
+
var item = Array.isArray(xypdf.items) && (xypdf.items.length === 1) ? xypdf.items[0] : null;
|
|
88
|
+
if (item && (item.type === 'bucket')) bucket_dirs.push(file.replace(/\.json$/i, ''));
|
|
89
|
+
sources.push({ file, stem, xypdf, item });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Bucket uploaded files are opaque content even when named *.json. They
|
|
93
|
+
// must not participate in this definition source inventory.
|
|
94
|
+
for (const entry of parse_errors) {
|
|
95
|
+
if (bucket_dirs.some( dir => isInsideDir(entry.file, dir) )) continue;
|
|
96
|
+
throw new Error("Failed to parse JSON file: " + entry.file + ": " + entry.err.message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return sources.filter( source => !bucket_dirs.some( dir => isInsideDir(source.file, dir) ) );
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findOwnedNeighbors(app, files, sources, source) {
|
|
103
|
+
var dir = Path.dirname(source.file);
|
|
104
|
+
var stem = source.stem;
|
|
105
|
+
var source_stems = sources.filter( item => Path.dirname(item.file) === dir )
|
|
106
|
+
.map( item => item.stem ).sort( (a, b) => b.length - a.length );
|
|
107
|
+
var neighbors = [];
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (Path.dirname(file) !== dir || file === source.file) continue;
|
|
111
|
+
var name = Path.basename(file);
|
|
112
|
+
var name_stem = name.replace(/\.\w+$/, '');
|
|
113
|
+
if (!name_stem.startsWith(stem + '-')) continue;
|
|
114
|
+
|
|
115
|
+
// The longest XYPDF prefix owns a neighboring property file. Another
|
|
116
|
+
// XYPDF is always its own source, even if its name looks like a neighbor.
|
|
117
|
+
var owner = source_stems.find( other => name_stem.startsWith(other + '-') );
|
|
118
|
+
if (owner && (owner !== stem)) continue;
|
|
119
|
+
if (sources.some( other => other.file === file )) continue;
|
|
120
|
+
|
|
121
|
+
var prop_path = name_stem.substring(stem.length + 1);
|
|
122
|
+
if (!prop_path.match(/^[\w\.\-]+$/) ||
|
|
123
|
+
(typeof(app.getSyncPropByPath(source.item.data, prop_path)) !== 'string')) {
|
|
124
|
+
throw new Error("Neighbor file does not match a string property: " + file);
|
|
125
|
+
}
|
|
126
|
+
neighbors.push(file);
|
|
127
|
+
}
|
|
128
|
+
return neighbors;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
|
|
133
|
+
async runSyncSyshook() {
|
|
134
|
+
// The argument is an exact activity action, not a general sync direction.
|
|
135
|
+
// Ignore saved up/down/delete settings by never entering ordinary sync.
|
|
136
|
+
var action = this.args.syshook;
|
|
137
|
+
var type = HOOK_TYPES[action];
|
|
138
|
+
if (!type) this.die("Invalid --syshook action (use one of: " + Object.keys(HOOK_TYPES).join(', ') + ").");
|
|
139
|
+
if (['up', 'down', 'delete', 'setup', 'new'].some( key => key in this.args )) {
|
|
140
|
+
this.die("--syshook cannot be combined with ordinary sync modes.");
|
|
141
|
+
}
|
|
142
|
+
if (!this.args.other || (this.args.other.length !== 1)) {
|
|
143
|
+
this.die("--syshook requires exactly one sync directory.");
|
|
144
|
+
}
|
|
145
|
+
if (this.args.down_cmd && (typeof(this.args.down_cmd) !== 'string')) {
|
|
146
|
+
this.die("--down_cmd must be a shell command.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
var dir = Path.resolve(this.args.other[0]);
|
|
150
|
+
var stat = null;
|
|
151
|
+
try { stat = fs.statSync(dir); }
|
|
152
|
+
catch (err) { this.die("Sync directory is not accessible: " + dir + ": " + err.message); }
|
|
153
|
+
if (!stat.isDirectory()) this.die("Sync path is not a directory: " + dir);
|
|
154
|
+
this.args.other = [dir]; // completion commands run from this directory
|
|
155
|
+
this.sconfig = Tools.mergeHashes(this.config.sync || {}, this.args);
|
|
156
|
+
this.warnings = [];
|
|
157
|
+
this.errors = [];
|
|
158
|
+
|
|
159
|
+
var payload = null;
|
|
160
|
+
try { payload = await readHookPayload(); }
|
|
161
|
+
catch (err) { this.die(err.message); }
|
|
162
|
+
if (!payload || (typeof(payload) !== 'object') || Array.isArray(payload) ||
|
|
163
|
+
(payload.xy !== 1) || (payload.action !== action)) {
|
|
164
|
+
this.die("System Hook activity does not match --syshook " + action + ".");
|
|
165
|
+
}
|
|
166
|
+
var object = payload[type];
|
|
167
|
+
if (!object || (typeof(object) !== 'object') || Array.isArray(object) ||
|
|
168
|
+
(typeof(object.id) !== 'string') || !object.id.match(/^[a-z0-9_]+$/)) {
|
|
169
|
+
this.die("System Hook activity is missing a valid " + type + " ID.");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Identify the single local source by its stored type and ID, never by
|
|
173
|
+
// title or filename. Read the entire local inventory before changing it.
|
|
174
|
+
var files = [];
|
|
175
|
+
var sources = [];
|
|
176
|
+
try {
|
|
177
|
+
files = listLocalFiles(dir);
|
|
178
|
+
sources = scanSources(files);
|
|
179
|
+
}
|
|
180
|
+
catch (err) { this.die("Failed to scan sync directory: " + err.message); }
|
|
181
|
+
var matches = sources.filter( source => source.item && (source.item.type === type) &&
|
|
182
|
+
source.item.data && (source.item.data.id === object.id) );
|
|
183
|
+
if (matches.length > 1) this.die("Duplicate XYPDF sources for " + type + " ID " + object.id + ".");
|
|
184
|
+
if (!matches.length) {
|
|
185
|
+
println("No local XYPDF source found for " + type + " ID " + object.id + ".");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
var source = matches[0];
|
|
189
|
+
if (source.xypdf.version !== '1.0') this.die("Cannot delete incompatible XYPDF source: " + source.file);
|
|
190
|
+
|
|
191
|
+
var neighbors = [];
|
|
192
|
+
try { neighbors = findOwnedNeighbors(this, files, sources, source); }
|
|
193
|
+
catch (err) { this.die(err.message); }
|
|
194
|
+
var targets = [...neighbors, source.file]; // keep JSON last for safe retries
|
|
195
|
+
|
|
196
|
+
for (const file of targets) {
|
|
197
|
+
println((this.dry ? "Would delete: " : "Deleting: ") + file);
|
|
198
|
+
if (this.dry) continue;
|
|
199
|
+
try { fs.unlinkSync(file); }
|
|
200
|
+
catch (err) { this.die("Failed to delete local file: " + file + ": " + err.message); }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A dry run and a missing local source never trigger Git commands. Run
|
|
204
|
+
// only the explicitly supplied down_cmd after a real local deletion.
|
|
205
|
+
if (!this.dry && this.args.down_cmd) this.syncFinishCmd(this.args.down_cmd);
|
|
206
|
+
}
|
|
207
|
+
};
|
package/lib/sync.js
CHANGED
|
@@ -28,6 +28,10 @@ module.exports = {
|
|
|
28
28
|
// sync a filesystem of XYPDF files to xyOps resources
|
|
29
29
|
// xy sync --up all --dry
|
|
30
30
|
// xy sync --up "events,plugins" --delete plugins
|
|
31
|
+
// System Hook deletion is a separate, local-only command. Dispatch it
|
|
32
|
+
// before loading remote lists or interpreting ordinary sync directions.
|
|
33
|
+
if ('syshook' in this.args) return await this.runSyncSyshook();
|
|
34
|
+
|
|
31
35
|
await this.getMultiple();
|
|
32
36
|
|
|
33
37
|
// Both ordinary sync and setup completion commands report failures through
|
|
@@ -48,6 +52,7 @@ module.exports = {
|
|
|
48
52
|
|
|
49
53
|
// allow sync config in config files and/or cli args
|
|
50
54
|
var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
55
|
+
var ignore_missing = !!sconfig.ignore_missing;
|
|
51
56
|
if (!sconfig.up && !sconfig.down) {
|
|
52
57
|
this.die("Please enable sync up and/or down modes. Type 'xy help sync' for details.");
|
|
53
58
|
}
|
|
@@ -137,6 +142,7 @@ module.exports = {
|
|
|
137
142
|
|
|
138
143
|
// load all xypdf files
|
|
139
144
|
var items = [];
|
|
145
|
+
var seen_sources = new Map();
|
|
140
146
|
|
|
141
147
|
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
142
148
|
// file: { path, size, mtime }
|
|
@@ -211,16 +217,14 @@ module.exports = {
|
|
|
211
217
|
return;
|
|
212
218
|
}
|
|
213
219
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (Tools.findObjectDeep(items, { 'type': item.type, 'data.id': item.data.id })) {
|
|
220
|
+
// Check duplicate identities even when their remote definition is
|
|
221
|
+
// missing, so --ignore_missing only relaxes the missing-ID check.
|
|
222
|
+
var source_key = item.type + '\0' + item.data.id;
|
|
223
|
+
if (seen_sources.has(source_key)) {
|
|
221
224
|
this.logSyncWarning("Duplicate source item found: " + file.path + " (skipping)");
|
|
222
225
|
return;
|
|
223
226
|
}
|
|
227
|
+
seen_sources.set(source_key, file.path);
|
|
224
228
|
|
|
225
229
|
// locate any external neighbor prop files
|
|
226
230
|
var neighbor_error = false;
|
|
@@ -287,6 +291,19 @@ module.exports = {
|
|
|
287
291
|
// do not include item if neighbor loading or validation failed
|
|
288
292
|
if (neighbor_error) return;
|
|
289
293
|
|
|
294
|
+
var xy_item = Tools.findObject(list, { id: item.data.id });
|
|
295
|
+
if (!xy_item) {
|
|
296
|
+
// An opted-in Git sync can briefly retain a source after its xyOps
|
|
297
|
+
// definition is deleted. Skip only that source and keep scanning.
|
|
298
|
+
if (ignore_missing) {
|
|
299
|
+
println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
290
307
|
// include effective mtime in item for two-way sync
|
|
291
308
|
item.mtime = file.mtime;
|
|
292
309
|
|
|
@@ -435,11 +452,14 @@ module.exports = {
|
|
|
435
452
|
|
|
436
453
|
getSyncLockFile() {
|
|
437
454
|
// Allow a fixed path for service installations. Otherwise, serialize all
|
|
438
|
-
// local syncs targeting the exact same configured xyOps base URL.
|
|
455
|
+
// local syncs targeting the exact same configured xyOps base URL. Local
|
|
456
|
+
// System Hook deletions need no server config, so share a fixed lock key
|
|
457
|
+
// when no base URL is available.
|
|
439
458
|
var sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
440
459
|
if (sconfig.lock_file) return Path.resolve('' + sconfig.lock_file);
|
|
441
460
|
|
|
442
|
-
var
|
|
461
|
+
var base_url = this.config.base_url || (('syshook' in this.args) ? 'syshook-local' : '');
|
|
462
|
+
var digest = Tools.digestHex( base_url, 'sha256', 16);
|
|
443
463
|
return Path.join( os.tmpdir(), 'xyops-cli-sync-' + digest + '.pid' );
|
|
444
464
|
},
|
|
445
465
|
|
|
@@ -824,8 +844,9 @@ module.exports = {
|
|
|
824
844
|
}
|
|
825
845
|
if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
|
|
826
846
|
|
|
847
|
+
var ignore_missing = !!this.args.ignore_missing;
|
|
827
848
|
var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
|
|
828
|
-
var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
|
|
849
|
+
var existing_keys = new_only ? this.scanSyncSourceIndex(['.'], { ignore_missing }) : {};
|
|
829
850
|
|
|
830
851
|
this.printBoxList({
|
|
831
852
|
title: 'Sync Setup',
|
|
@@ -889,7 +910,7 @@ module.exports = {
|
|
|
889
910
|
return ext;
|
|
890
911
|
},
|
|
891
912
|
|
|
892
|
-
scanSyncSourceIndex(base_dirs) {
|
|
913
|
+
scanSyncSourceIndex(base_dirs, opts = {}) {
|
|
893
914
|
// Build an identity index from an existing sync tree. Filenames and folders
|
|
894
915
|
// are deliberately ignored, because sync sources may be freely reorganized.
|
|
895
916
|
var files = [];
|
|
@@ -900,6 +921,7 @@ module.exports = {
|
|
|
900
921
|
});
|
|
901
922
|
|
|
902
923
|
var index = {};
|
|
924
|
+
var seen_sources = new Map();
|
|
903
925
|
var bucket_extra_dirs = [];
|
|
904
926
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
905
927
|
try {
|
|
@@ -941,14 +963,26 @@ module.exports = {
|
|
|
941
963
|
if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
|
|
942
964
|
var list = this[type_def.list];
|
|
943
965
|
if (!list) this.die("Cannot locate internal list: " + type_def.list);
|
|
966
|
+
|
|
967
|
+
// Keep duplicate detection independent of remote existence, including
|
|
968
|
+
// for sources intentionally skipped with --ignore_missing.
|
|
969
|
+
var key = item.type + '-' + item.data.id;
|
|
970
|
+
if (seen_sources.has(key)) {
|
|
971
|
+
this.die("Duplicate source item found: " + file.path + " (already found in " + seen_sources.get(key) + ")");
|
|
972
|
+
}
|
|
973
|
+
seen_sources.set(key, file.path);
|
|
974
|
+
|
|
944
975
|
if (!Tools.findObject(list, { id: item.data.id })) {
|
|
976
|
+
// New-only setup needs the same grace period as ordinary sync.
|
|
977
|
+
// A missing local source does not represent a new remote item.
|
|
978
|
+
if (opts.ignore_missing) {
|
|
979
|
+
println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
945
983
|
this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
946
984
|
}
|
|
947
985
|
|
|
948
|
-
var key = item.type + '-' + item.data.id;
|
|
949
|
-
if (index[key]) {
|
|
950
|
-
this.die("Duplicate source item found: " + file.path + " (already found in " + index[key].file + ")");
|
|
951
|
-
}
|
|
952
986
|
index[key] = { file: file.path, item: item };
|
|
953
987
|
});
|
|
954
988
|
|
package/lib/transfer.js
CHANGED
|
@@ -86,7 +86,7 @@ module.exports = {
|
|
|
86
86
|
if (this.format.match(/json/)) return this.jsonOutput({ code: 0, file: filename, count: items.length, warnings: warnings });
|
|
87
87
|
|
|
88
88
|
this.toast('✅', 'green', "Exported " + items.length + " item(s) to: " + filename);
|
|
89
|
-
warnings.forEach( warning =>
|
|
89
|
+
warnings.forEach( warning => warnln(yellow(warning)) );
|
|
90
90
|
},
|
|
91
91
|
|
|
92
92
|
parseExportDependencies(value) {
|
|
@@ -316,7 +316,7 @@ module.exports = {
|
|
|
316
316
|
|
|
317
317
|
if (this.format.match(/json/)) return this.jsonOutput(report);
|
|
318
318
|
|
|
319
|
-
warnings.forEach( warning =>
|
|
319
|
+
warnings.forEach( warning => warnln(yellow(warning)) );
|
|
320
320
|
|
|
321
321
|
this.printBoxTable({
|
|
322
322
|
title: 'Import Results',
|
|
@@ -324,7 +324,7 @@ module.exports = {
|
|
|
324
324
|
rows: results.map( item => [item.type, item.id || '(New)', item.title, item.status] )
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
if (error)
|
|
327
|
+
if (error) warnln("\n " + red('Import stopped: ' + error) + '\n Earlier successful changes remain in place.');
|
|
328
328
|
else this.printSuggestedCommands({
|
|
329
329
|
"List events": "xy events",
|
|
330
330
|
"List categories": "xy categories",
|
package/lib/utils.js
CHANGED
|
@@ -1011,7 +1011,7 @@ module.exports = {
|
|
|
1011
1011
|
// load client config from xyops and cache for a while
|
|
1012
1012
|
var { err, data } = await this.cacheAPI('config', { format: 'json' }, { cache_ttl: 3600 });
|
|
1013
1013
|
if (err) {
|
|
1014
|
-
|
|
1014
|
+
warnln( "\n" + bold.red("Failed to contact xyOps at: ") + this.config.base_url );
|
|
1015
1015
|
this.die(err);
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
@@ -1265,7 +1265,11 @@ module.exports = {
|
|
|
1265
1265
|
// cli.box performs word wrapping at this content width and never ellipsizes.
|
|
1266
1266
|
var available_width = Math.max( 1, (cli.width() || 80) - 7 );
|
|
1267
1267
|
var max_width = Math.min( available_width, cli.stringWidth(final_text) );
|
|
1268
|
-
|
|
1268
|
+
var message = "\n" + cli.box(final_text, { width: max_width, hspace: 2, vspace: 1, indent: 1, styles: [this.color(color)] });
|
|
1269
|
+
|
|
1270
|
+
// Keep warning and error toasts visible when routine output is quiet.
|
|
1271
|
+
if (['⚠️', '🛑', '❌', '☢️'].includes(emoji)) warnln(message);
|
|
1272
|
+
else println(message);
|
|
1269
1273
|
},
|
|
1270
1274
|
|
|
1271
1275
|
printMutationSummary(opts) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixlcore/xycli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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-buckets-unit.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-syshook-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/help.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { test } = require('node:test');
|
|
2
2
|
const assert = require('node:assert/strict');
|
|
3
3
|
const Path = require('node:path');
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
4
5
|
const { xy } = require('./helpers/common.js');
|
|
5
6
|
|
|
6
7
|
// Deliberately shadow any developer-machine configuration. Help and command
|
|
@@ -51,3 +52,58 @@ test('unknown top-level command does not suggest an unrelated command', () => {
|
|
|
51
52
|
assert.match(output, /Unknown command: completely_unrelated/);
|
|
52
53
|
assert.doesNotMatch(output, /Did you mean/);
|
|
53
54
|
});
|
|
55
|
+
|
|
56
|
+
test('quiet mode prints sync diagnostics and bound warnings on stderr', () => {
|
|
57
|
+
// Run the real CLI startup so this checks the warn override and the global
|
|
58
|
+
// binding installed by cli.global(), without contacting a server.
|
|
59
|
+
const script = `
|
|
60
|
+
process.argv = [process.execPath, require.resolve('./index.js'), 'help', '--quiet'];
|
|
61
|
+
process.on('beforeExit', () => {
|
|
62
|
+
const cli = require('pixl-cli');
|
|
63
|
+
cli.warnln('Fixture cli warning');
|
|
64
|
+
warnln('Fixture warning');
|
|
65
|
+
warn('Fixture direct warning\\n');
|
|
66
|
+
const utils = require('./lib/utils.js');
|
|
67
|
+
const context = { colors: { orange: [255, 105, 0], red: [251, 44, 54], green: [0, 201, 80] }, color: utils.color };
|
|
68
|
+
utils.toast.call(context, '⚠️', 'orange', 'Fixture toast warning');
|
|
69
|
+
utils.toast.call(context, '🛑', 'red', 'Fixture toast error');
|
|
70
|
+
utils.toast.call(context, '✅', 'green', 'Fixture success');
|
|
71
|
+
const sync = require('./lib/sync.js');
|
|
72
|
+
sync.logSyncWarning.call({ warnings: [] }, 'Fixture sync warning');
|
|
73
|
+
sync.logSyncError.call({ errors: [] }, 'Fixture sync error');
|
|
74
|
+
println('Routine output');
|
|
75
|
+
});
|
|
76
|
+
require('./index.js');
|
|
77
|
+
`;
|
|
78
|
+
const result = spawnSync(process.execPath, ['-e', script], {
|
|
79
|
+
cwd: Path.join(__dirname, '..'),
|
|
80
|
+
encoding: 'utf8',
|
|
81
|
+
env: { ...process.env, ...offlineEnv },
|
|
82
|
+
windowsHide: true
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.ifError(result.error);
|
|
86
|
+
assert.equal(result.status, 1);
|
|
87
|
+
assert.equal(result.stdout, '');
|
|
88
|
+
assert.match(result.stderr, /Fixture cli warning/);
|
|
89
|
+
assert.match(result.stderr, /Fixture warning/);
|
|
90
|
+
assert.match(result.stderr, /Fixture direct warning/);
|
|
91
|
+
assert.match(result.stderr, /Fixture toast warning/);
|
|
92
|
+
assert.match(result.stderr, /Fixture toast error/);
|
|
93
|
+
assert.match(result.stderr, /Fixture sync warning/);
|
|
94
|
+
assert.match(result.stderr, /Fixture sync error/);
|
|
95
|
+
assert.doesNotMatch(result.stderr, /Fixture success/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('quiet invalid usage reports an error on stderr', () => {
|
|
99
|
+
const result = spawnSync(process.execPath, [Path.join(__dirname, '..', 'index.js'), 'upgrade', 'unexpected', '--quiet'], {
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
env: { ...process.env, ...offlineEnv },
|
|
102
|
+
windowsHide: true
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.ifError(result.error);
|
|
106
|
+
assert.equal(result.status, 1);
|
|
107
|
+
assert.equal(result.stdout, '');
|
|
108
|
+
assert.match(result.stderr, /Invalid usage.*xy help upgrade/);
|
|
109
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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 { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
function makeTree(t) {
|
|
9
|
+
var dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-syshook-test-'));
|
|
10
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeSource(dir, name, type, data) {
|
|
15
|
+
var file = Path.join(dir, name);
|
|
16
|
+
fs.mkdirSync(Path.dirname(file), { recursive: true });
|
|
17
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
18
|
+
type: 'xypdf', version: '1.0', items: [{ type, data }]
|
|
19
|
+
}));
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runHook(dir, action, payload, extra = [], use_default_lock = false) {
|
|
24
|
+
// Strip xyOps connection variables to match the conductor's shell-hook
|
|
25
|
+
// environment. The hook must complete without credentials or API access.
|
|
26
|
+
var env = { ...process.env, HOME: dir };
|
|
27
|
+
for (const key of Object.keys(env)) {
|
|
28
|
+
if (key.startsWith('XYOPS_')) delete env[key];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var args = [Path.join(__dirname, '..', 'index.js'), 'sync', dir, '--syshook', action];
|
|
32
|
+
if (!use_default_lock) args.push('--lock_file', Path.join(dir, '.sync-lock'));
|
|
33
|
+
args.push(...extra);
|
|
34
|
+
|
|
35
|
+
return spawnSync(process.execPath, args, {
|
|
36
|
+
input: payload === null ? '' : JSON.stringify(payload) + '\n',
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
timeout: 10000,
|
|
39
|
+
env
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function markerCommand(name) {
|
|
44
|
+
var code = `require('node:fs').writeFileSync(${JSON.stringify(name)}, process.cwd())`;
|
|
45
|
+
return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(code)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('syshook deletes only the exact Event and its owned neighbors', t => {
|
|
49
|
+
var dir = makeTree(t);
|
|
50
|
+
var event = writeSource(dir, 'events/Ops/Nightly.json', 'event', {
|
|
51
|
+
id: 'evt_one', title: 'Nightly', params: { script: '(External)' }
|
|
52
|
+
});
|
|
53
|
+
var neighbor = Path.join(dir, 'events/Ops/Nightly-params.script.sh');
|
|
54
|
+
fs.writeFileSync(neighbor, 'echo one\n');
|
|
55
|
+
var other = writeSource(dir, 'events/Ops/Nightly-V2.json', 'event', {
|
|
56
|
+
id: 'evt_two', title: 'Nightly V2', params: { script: '(External)' }
|
|
57
|
+
});
|
|
58
|
+
var other_neighbor = Path.join(dir, 'events/Ops/Nightly-V2-params.script.sh');
|
|
59
|
+
fs.writeFileSync(other_neighbor, 'echo two\n');
|
|
60
|
+
var new_event = writeSource(dir, 'events/Ops/New.json', 'event', { id: 'evt_new', title: 'New' });
|
|
61
|
+
var same_id_plugin = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'evt_one', title: 'Shared' });
|
|
62
|
+
var marker = Path.join(dir, 'completed.txt');
|
|
63
|
+
|
|
64
|
+
var result = runHook(dir, 'event_delete', {
|
|
65
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_one', title: 'Nightly' }
|
|
66
|
+
}, ['--down_cmd', markerCommand('completed.txt')]);
|
|
67
|
+
|
|
68
|
+
assert.ifError(result.error);
|
|
69
|
+
assert.equal(result.status, 0, result.stderr);
|
|
70
|
+
assert.equal(fs.existsSync(event), false);
|
|
71
|
+
assert.equal(fs.existsSync(neighbor), false);
|
|
72
|
+
assert.equal(fs.existsSync(other), true);
|
|
73
|
+
assert.equal(fs.existsSync(other_neighbor), true);
|
|
74
|
+
assert.equal(fs.existsSync(new_event), true);
|
|
75
|
+
assert.equal(fs.existsSync(same_id_plugin), true);
|
|
76
|
+
assert.equal(fs.readFileSync(marker, 'utf8'), fs.realpathSync(dir));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('syshook uses its default lock without a configured xyOps URL or API Key', t => {
|
|
80
|
+
var dir = makeTree(t);
|
|
81
|
+
var event = writeSource(dir, 'events/Delete-Me.json', 'event', { id: 'evt_no_config' });
|
|
82
|
+
var result = runHook(dir, 'event_delete', {
|
|
83
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_no_config' }
|
|
84
|
+
}, [], true);
|
|
85
|
+
|
|
86
|
+
assert.equal(result.status, 0, result.stderr);
|
|
87
|
+
assert.equal(fs.existsSync(event), false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('syshook rejects a mismatched action without deleting files', t => {
|
|
91
|
+
var dir = makeTree(t);
|
|
92
|
+
var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
93
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
94
|
+
xy: 1, action: 'event_delete', plugin: { id: 'plugin_one' }
|
|
95
|
+
}, ['--down_cmd', markerCommand('completed.txt')]);
|
|
96
|
+
|
|
97
|
+
assert.equal(result.status, 1);
|
|
98
|
+
assert.match(result.stderr, /does not match --syshook plugin_delete/);
|
|
99
|
+
assert.equal(fs.existsSync(file), true);
|
|
100
|
+
assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('syshook deletes a matching Plugin without changing another Plugin', t => {
|
|
104
|
+
var dir = makeTree(t);
|
|
105
|
+
var target = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
106
|
+
var other = writeSource(dir, 'plugins/Other.json', 'plugin', { id: 'plugin_two', title: 'Other' });
|
|
107
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
108
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(result.status, 0, result.stderr);
|
|
112
|
+
assert.equal(fs.existsSync(target), false);
|
|
113
|
+
assert.equal(fs.existsSync(other), true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Each remaining standard sync type uses the same exact type-and-ID lookup.
|
|
117
|
+
// Include another Event with the target ID to catch an accidental ID-only
|
|
118
|
+
// match, especially for the API Key action's different spelling.
|
|
119
|
+
for (const [action, type] of [
|
|
120
|
+
['alert_delete', 'alert'],
|
|
121
|
+
['apikey_delete', 'api_key'],
|
|
122
|
+
['category_delete', 'category'],
|
|
123
|
+
['channel_delete', 'channel'],
|
|
124
|
+
['group_delete', 'group'],
|
|
125
|
+
['monitor_delete', 'monitor'],
|
|
126
|
+
['tag_delete', 'tag'],
|
|
127
|
+
['web_hook_delete', 'web_hook']
|
|
128
|
+
]) {
|
|
129
|
+
test('syshook deletes one ' + type + ' for ' + action, t => {
|
|
130
|
+
var dir = makeTree(t);
|
|
131
|
+
var target = writeSource(dir, type + '/Target.json', type, { id: 'shared_id' });
|
|
132
|
+
var other = writeSource(dir, type + '/Other.json', type, { id: 'other_id' });
|
|
133
|
+
var event = writeSource(dir, 'events/Shared.json', 'event', { id: 'shared_id' });
|
|
134
|
+
var result = runHook(dir, action, {
|
|
135
|
+
xy: 1, action, [type]: { id: 'shared_id' }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.equal(result.status, 0, result.stderr);
|
|
139
|
+
assert.equal(fs.existsSync(target), false);
|
|
140
|
+
assert.equal(fs.existsSync(other), true);
|
|
141
|
+
assert.equal(fs.existsSync(event), true);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test('syshook rejects bucket deletion and preserves its source', t => {
|
|
146
|
+
var dir = makeTree(t);
|
|
147
|
+
var bucket = writeSource(dir, 'buckets/Shared.json', 'bucket', { id: 'bucket_one' });
|
|
148
|
+
var result = runHook(dir, 'bucket_delete', {
|
|
149
|
+
xy: 1, action: 'bucket_delete', bucket: { id: 'bucket_one' }
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
assert.equal(result.status, 1);
|
|
153
|
+
assert.match(result.stderr, /Invalid --syshook action/);
|
|
154
|
+
assert.equal(fs.existsSync(bucket), true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('syshook checks neighboring files before deleting the source', t => {
|
|
158
|
+
var dir = makeTree(t);
|
|
159
|
+
var target = writeSource(dir, 'events/Ops/Nightly.json', 'event', { id: 'evt_one', title: 'Nightly' });
|
|
160
|
+
var unrelated = Path.join(dir, 'events/Ops/Nightly-other.txt');
|
|
161
|
+
fs.writeFileSync(unrelated, 'keep me\n');
|
|
162
|
+
var result = runHook(dir, 'event_delete', {
|
|
163
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_one' }
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.equal(result.status, 1);
|
|
167
|
+
assert.match(result.stderr, /Neighbor file does not match a string property/);
|
|
168
|
+
assert.equal(fs.existsSync(target), true);
|
|
169
|
+
assert.equal(fs.existsSync(unrelated), true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('syshook stops on malformed JSON before deleting a matching source', t => {
|
|
173
|
+
var dir = makeTree(t);
|
|
174
|
+
var target = writeSource(dir, 'events/Ops/Nightly.json', 'event', { id: 'evt_one', title: 'Nightly' });
|
|
175
|
+
fs.writeFileSync(Path.join(dir, 'events/Ops/Broken.json'), '{bad json');
|
|
176
|
+
var result = runHook(dir, 'event_delete', {
|
|
177
|
+
xy: 1, action: 'event_delete', event: { id: 'evt_one' }
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
assert.equal(result.status, 1);
|
|
181
|
+
assert.match(result.stderr, /Failed to parse JSON file/);
|
|
182
|
+
assert.equal(fs.existsSync(target), true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('syshook refuses duplicate XYPDF sources before changing either file', t => {
|
|
186
|
+
var dir = makeTree(t);
|
|
187
|
+
var data = { id: 'plugin_one', title: 'Shared', script: '(External)' };
|
|
188
|
+
var first = writeSource(dir, 'plugins/First.json', 'plugin', data);
|
|
189
|
+
var second = writeSource(dir, 'plugins/Second.json', 'plugin', data);
|
|
190
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
191
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
assert.equal(result.status, 1);
|
|
195
|
+
assert.match(result.stderr, /Duplicate XYPDF sources/);
|
|
196
|
+
assert.equal(fs.existsSync(first), true);
|
|
197
|
+
assert.equal(fs.existsSync(second), true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('syshook dry run and absent source never run down_cmd', t => {
|
|
201
|
+
var dir = makeTree(t);
|
|
202
|
+
var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
203
|
+
var command = markerCommand('completed.txt');
|
|
204
|
+
var payload = { xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' } };
|
|
205
|
+
var preview = runHook(dir, 'plugin_delete', payload, ['--dry', '--down_cmd', command]);
|
|
206
|
+
|
|
207
|
+
assert.equal(preview.status, 0, preview.stderr);
|
|
208
|
+
assert.match(preview.stdout, /Would delete:/);
|
|
209
|
+
assert.equal(fs.existsSync(file), true);
|
|
210
|
+
assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
|
|
211
|
+
|
|
212
|
+
var absent = runHook(dir, 'plugin_delete', {
|
|
213
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'other' }
|
|
214
|
+
}, ['--down_cmd', command]);
|
|
215
|
+
assert.equal(absent.status, 0, absent.stderr);
|
|
216
|
+
assert.equal(fs.existsSync(file), true);
|
|
217
|
+
assert.equal(fs.existsSync(Path.join(dir, 'completed.txt')), false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('syshook reports a failed down_cmd after deleting the matched source', t => {
|
|
221
|
+
var dir = makeTree(t);
|
|
222
|
+
var file = writeSource(dir, 'plugins/Shared.json', 'plugin', { id: 'plugin_one', title: 'Shared' });
|
|
223
|
+
var command = `${JSON.stringify(process.execPath)} -e "process.exit(2)"`;
|
|
224
|
+
var result = runHook(dir, 'plugin_delete', {
|
|
225
|
+
xy: 1, action: 'plugin_delete', plugin: { id: 'plugin_one' }
|
|
226
|
+
}, ['--down_cmd', command]);
|
|
227
|
+
|
|
228
|
+
assert.equal(result.status, 1);
|
|
229
|
+
assert.match(result.stderr, /Command failed/);
|
|
230
|
+
assert.equal(fs.existsSync(file), false);
|
|
231
|
+
});
|
package/test/sync-unit.test.js
CHANGED
|
@@ -227,6 +227,73 @@ test('sync errors exit nonzero even in quiet mode or a dry run', t => {
|
|
|
227
227
|
}
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
test('sync ignore-missing skips only absent remote IDs and continues updates', t => {
|
|
231
|
+
const remote = { id: 'present', title: 'Present', notes: 'Remote' };
|
|
232
|
+
const local = { ...remote, notes: 'Local' };
|
|
233
|
+
const missing = { id: 'gone', title: 'Gone', notes: 'Keep this file' };
|
|
234
|
+
const result = runSync(t, {
|
|
235
|
+
categories: [remote],
|
|
236
|
+
existingSources: [
|
|
237
|
+
{ path: 'Gone.json', type: 'category', data: missing },
|
|
238
|
+
{ path: 'Present.json', type: 'category', data: local }
|
|
239
|
+
],
|
|
240
|
+
args: { up: 'categories', down: 'categories', delete: false, ignore_missing: true }
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
assert.equal(result.status, 0);
|
|
244
|
+
assert.deepEqual(result.report.warnings, []);
|
|
245
|
+
assert.equal(result.report.calls.includes('update_category'), true);
|
|
246
|
+
assert.equal(fs.existsSync(Path.join(result.dir, 'Gone.json')), true);
|
|
247
|
+
assert.equal(JSON.parse(fs.readFileSync(Path.join(result.dir, 'Gone.json'), 'utf8')).items[0].data.notes, 'Keep this file');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('sync ignore-missing does not suppress malformed source warnings', t => {
|
|
251
|
+
const result = runSync(t, {
|
|
252
|
+
source: { id: 'missing', title: 'Missing' },
|
|
253
|
+
emptyItems: true,
|
|
254
|
+
args: { up: 'categories', ignore_missing: true }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
assert.equal(result.status, 1);
|
|
258
|
+
assert.match(result.report.warnings[0], /empty, malformed or missing items array/);
|
|
259
|
+
assert.deepEqual(result.report.calls, []);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('sync ignore-missing still validates neighbors of missing sources', t => {
|
|
263
|
+
const result = runSync(t, {
|
|
264
|
+
source: { id: 'missing', title: 'Missing' },
|
|
265
|
+
invalidNeighbor: true,
|
|
266
|
+
args: { up: 'categories', ignore_missing: true }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
assert.equal(result.status, 1);
|
|
270
|
+
assert.match(result.report.warnings[0], /Neighbor file does not match/);
|
|
271
|
+
assert.deepEqual(result.report.calls, []);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('sync ignore-missing still rejects duplicate missing sources', t => {
|
|
275
|
+
const result = runSync(t, {
|
|
276
|
+
source: { id: 'missing', title: 'Missing' },
|
|
277
|
+
duplicateSource: true,
|
|
278
|
+
args: { up: 'categories', ignore_missing: true }
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
assert.equal(result.status, 1);
|
|
282
|
+
assert.match(result.report.warnings[0], /Duplicate source item found/);
|
|
283
|
+
assert.deepEqual(result.report.calls, []);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('sync ignore-missing can be disabled over a saved setting', t => {
|
|
287
|
+
const result = runSync(t, {
|
|
288
|
+
source: { id: 'missing', title: 'Missing' },
|
|
289
|
+
config: { up: 'categories', ignore_missing: true },
|
|
290
|
+
args: { ignore_missing: false }
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
assert.equal(result.status, 1);
|
|
294
|
+
assert.match(result.report.warnings[0], /Cannot find category in xyOps/);
|
|
295
|
+
});
|
|
296
|
+
|
|
230
297
|
test('sync sends existing Plugin properties through the sync API path', t => {
|
|
231
298
|
const remote = { id: 'legacy', title: 'Legacy', type: 'event', command: 'node', cwd: '/opt/legacy', custom_field: { keep: true }, notes: 'Remote' };
|
|
232
299
|
const local = { ...remote, notes: 'Local' };
|
|
@@ -313,6 +380,39 @@ test('sync setup new exports only remote definitions missing from the local tree
|
|
|
313
380
|
assert.equal(fs.existsSync(Path.join(result.dir, 'custom', 'layout', 'Renamed-Existing.json')), true);
|
|
314
381
|
});
|
|
315
382
|
|
|
383
|
+
test('sync setup new ignore-missing leaves stale files and exports new definitions', t => {
|
|
384
|
+
const missing = { id: 'gone', title: 'Gone' };
|
|
385
|
+
const added = { id: 'added', title: 'Added Plugin', command: 'node' };
|
|
386
|
+
const result = runSync(t, {
|
|
387
|
+
setup: ['plugins'],
|
|
388
|
+
plugins: [added],
|
|
389
|
+
existingSources: [{ path: 'old/Gone.json', type: 'plugin', data: missing }],
|
|
390
|
+
args: { new: true, ignore_missing: true }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
assert.equal(result.status, 0);
|
|
394
|
+
assert.deepEqual(result.report.warnings, []);
|
|
395
|
+
assert.equal(fs.existsSync(Path.join(result.dir, 'old', 'Gone.json')), true);
|
|
396
|
+
assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), true);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('sync setup new ignore-missing still rejects duplicate missing sources', t => {
|
|
400
|
+
const missing = { id: 'gone', title: 'Gone' };
|
|
401
|
+
const result = runSync(t, {
|
|
402
|
+
setup: ['plugins'],
|
|
403
|
+
plugins: [{ id: 'added', title: 'Added Plugin', command: 'node' }],
|
|
404
|
+
existingSources: [
|
|
405
|
+
{ path: 'old/Gone.json', type: 'plugin', data: missing },
|
|
406
|
+
{ path: 'other/Gone.json', type: 'plugin', data: missing }
|
|
407
|
+
],
|
|
408
|
+
args: { new: true, ignore_missing: true }
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
assert.equal(result.status, 1);
|
|
412
|
+
assert.match(result.stderr, /Duplicate source item found/);
|
|
413
|
+
assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), false);
|
|
414
|
+
});
|
|
415
|
+
|
|
316
416
|
test('sync setup runs down_cmd after writing files from the setup directory', t => {
|
|
317
417
|
const marker = 'setup-complete.txt';
|
|
318
418
|
const result = runSync(t, {
|
package/test/toast.test.js
CHANGED
|
@@ -7,14 +7,14 @@ cli.global();
|
|
|
7
7
|
|
|
8
8
|
test('toast wraps within the terminal width without ellipsis', () => {
|
|
9
9
|
const originalWidth = cli.width;
|
|
10
|
-
const originalWrite = process.
|
|
10
|
+
const originalWrite = process.stderr.write;
|
|
11
11
|
const originalColor = cli.chalk.enabled;
|
|
12
12
|
let output = '';
|
|
13
13
|
|
|
14
14
|
try {
|
|
15
15
|
cli.width = function() { return 40; };
|
|
16
16
|
cli.chalk.enabled = false;
|
|
17
|
-
process.
|
|
17
|
+
process.stderr.write = function(chunk) {
|
|
18
18
|
output += chunk;
|
|
19
19
|
return true;
|
|
20
20
|
};
|
|
@@ -27,7 +27,7 @@ test('toast wraps within the terminal width without ellipsis', () => {
|
|
|
27
27
|
finally {
|
|
28
28
|
cli.width = originalWidth;
|
|
29
29
|
cli.chalk.enabled = originalColor;
|
|
30
|
-
process.
|
|
30
|
+
process.stderr.write = originalWrite;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const box_lines = output.split('\n').filter( line => line.match(/[┌│└]/) );
|