@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 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 fatal error messages remain visible on stderr. |
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
- Fatal errors print to stderr and exit with a nonzero status even in quiet mode, so redirecting JSON output to a file leaves those errors visible in your terminal. Quiet mode can still suppress command-specific warnings and failure reports, such as sync messages or import results. Sync warnings and errors still produce a nonzero exit status. Check exit status and any structured results when scripting; omit `--quiet` when you need those detailed diagnostics.
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. Deletions alone do not trigger them. Event notifications receive `input.data.errors` and `input.data.warnings`.
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. Fatal errors remain visible, but collected sync warnings and errors can be suppressed. |
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. Pause the schedule during these steps if you want to avoid a failed pass between the two removals.
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. 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.
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. Fatal CLI errors still print to standard error. Collected sync warnings and errors can be suppressed, so quiet automation should have configured notifications and external monitoring. Remove `--quiet` while investigating a problem.
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 collected reports. Remove it to investigate and configure notifications. |
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 deliberately omit `--quiet`, because that option can suppress collected sync warnings and error reports along with routine output.
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
- 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:
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. 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.
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, 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.
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. A leftover source stops sync until it is removed. |
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
- println( "\n " + red.bold("Disable Error: ") + (state_error.message || state_error) );
244
+ warnln( "\n " + red.bold("Disable Error: ") + (state_error.message || state_error) );
245
245
  process.exitCode = 1;
246
246
  return;
247
247
  }
@@ -235,7 +235,7 @@ module.exports = {
235
235
  ]
236
236
  });
237
237
 
238
- if (warning) println("\n " + yellow(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
- var xy_item = Tools.findObject(list, { id: item.data.id });
215
- if (!xy_item) {
216
- this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
217
- return;
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 digest = Tools.digestHex( this.config.base_url, 'sha256', 16);
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 => println(yellow(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 => println(yellow(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) println("\n " + red('Import stopped: ' + error) + '\n Earlier successful changes remain in place.');
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
- println( "\n" + bold.red("Failed to contact xyOps at: ") + this.config.base_url );
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
- println( "\n" + cli.box(final_text, { width: max_width, hspace: 2, vspace: 1, indent: 1, styles: [this.color(color)] }) );
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.8",
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
+ });
@@ -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, {
@@ -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.stdout.write;
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.stdout.write = function(chunk) {
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.stdout.write = originalWrite;
30
+ process.stderr.write = originalWrite;
31
31
  }
32
32
 
33
33
  const box_lines = output.split('\n').filter( line => line.match(/[┌│└]/) );