@pixlcore/xycli 1.0.1 → 1.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # xyCLI Changelog
2
+
3
+ ## Version v1.0.0
4
+
5
+ > September 20, 2026
6
+
7
+ - Initial release!
package/README.md CHANGED
@@ -26,6 +26,7 @@ Browse the [screenshot gallery](docs/screenshots.md) for more views, or jump to
26
26
  - [Configuration](#configuration)
27
27
  - [Global CLI switches](#global-cli-switches)
28
28
  - [Tutorial: your first Event, job, and Web Hook](docs/tutorial.md)
29
+ - [Tutorial: two-way sync with Git](docs/two-way-git-tutorial.md)
29
30
  - [Explore and manage xyOps](#explore-and-manage-xyops)
30
31
  - [Transfer data between systems](#transfer-data-between-systems)
31
32
  - [Sync your automation with local files](#sync-your-automation-with-local-files)
@@ -483,7 +484,7 @@ cd xyops-automation
483
484
  xy sync setup events plugins categories --file_props script,params.script
484
485
  ```
485
486
 
486
- Setup creates one file per definition and can extract scripts into adjacent source files. You choose how to organize the tree afterward; sync scans subdirectories and matches definitions by resource type and exact ID.
487
+ Setup creates one file per definition and can extract scripts into adjacent source files. Events and workflows are automatically grouped into subfolders named after their Categories. You can reorganize the tree afterward; sync scans subdirectories and matches definitions by resource type and exact ID.
487
488
 
488
489
  Preview a push or pull, then apply the direction you want:
489
490
 
@@ -499,6 +500,8 @@ Sync supports Alerts, API Keys, Categories, Channels, Events and workflows, Grou
499
500
 
500
501
  Delete mode is for up-sync only: it removes eligible xyOps objects missing from your local inventory. Objects with a `stock` or `marketplace` property are always protected from deletion and need no local files. Keep a complete inventory of the other objects you intend to retain for each selected type, and review a dry run before applying it. Sync applies changes without a confirmation step unless you use `--dry`.
501
502
 
503
+ Follow the [Two-Way Git Sync Tutorial](docs/two-way-git-tutorial.md) to connect one persistent checkout to a shared repository, commit downloaded changes, discover new Events, and schedule the complete workflow with cron or xySat.
504
+
502
505
  Read the dedicated [Sync Guide](docs/sync.md) for setup, file layouts, every option, saved defaults, two-way sync, deletion, notifications, and automation with cron, Git hooks, and GitHub Actions.
503
506
 
504
507
  - **Command reference:** [Sync commands](docs/help.md#sync) and [Sync setup](docs/help.md#sync-setup).
@@ -540,4 +543,4 @@ xy api runEvent --id EVENT_ID --params.example VALUE --dry
540
543
  xy api updateEvent --json @./request.json --dry
541
544
  ```
542
545
 
543
- The examples with `--dry` display the request without calling the API. Many resource views also accept `--format json` to display their data as JSON. See the [xyOps API Reference](https://docs.xyops.io/api), [API command](docs/help.md#api), and [documentation command](docs/help.md#doc) for more.
546
+ The examples with `--dry` display the request without calling the API. Many resource views also accept `--format json` to display their data as JSON. See the [xyOps API Reference](https://docs.xyops.io/#Docs/api), [API command](docs/help.md#api), and [documentation command](docs/help.md#doc) for more.
package/docs/help.md CHANGED
@@ -281,6 +281,9 @@ Export existing xyOps definitions into a starter sync tree in the current direct
281
281
  ```sh
282
282
  xy sync setup events plugins categories --dry
283
283
  xy sync setup events plugins categories --file_props script,params.script
284
+ xy sync setup events --file_props params.script --default_ext ps1
285
+ xy sync setup events plugins --new
286
+ xy sync setup events --new --down_cmd "./commit-downloads.sh"
284
287
  xy sync setup all
285
288
  xy sync setup all --stock --marketplace
286
289
  xy sync setup events plugins --force
@@ -289,17 +292,25 @@ xy sync --setup events,plugins --file_props script,params.script
289
292
 
290
293
  Choose one or more resource types separated by spaces, or use `all`. Supported types are the same as for `xy sync`. The `--setup TYPES` form also accepts a comma-separated list.
291
294
 
292
- Setup creates one folder per resource type and one XYPDF JSON file per object, using its title as a filename slug. Selecting `events` exports both Events and workflows, with workflows placed in a separate `workflows` folder. Both are synced with `--up events` or `--down events`.
295
+ Setup creates one folder per resource type and one XYPDF JSON file per object, using its title as a filename slug. Selecting `events` exports both Events and workflows, with workflows placed in a separate `workflows` folder. Events and workflows are grouped into subfolders using their Category title as a slug. Both are synced with `--up events` or `--down events`. Setup stops before writing if an Event or workflow selected for export refers to a missing Category.
293
296
 
294
297
  - `--file_props PATHS` extracts string properties into adjacent files.
298
+ - `--default_ext EXT` sets the fallback neighbor extension instead of `txt`.
299
+ - `--new` writes only remote definitions not already represented locally.
300
+ - `--down_cmd COMMAND` runs after setup writes one or more definitions.
301
+ - `--cmd_timeout SECONDS` changes the command timeout from 30 seconds.
295
302
  - `--stock` includes stock objects, which are omitted by default.
296
303
  - `--marketplace` includes Marketplace objects, which are omitted by default.
297
304
  - `--force` allows generated files to overwrite existing destinations.
298
305
  - `--dry` previews the layout without creating directories or writing files.
299
306
 
300
- For `--file_props`, use comma-separated property names or dot paths, such as `script,params.script`. Only nonempty string values are extracted. The JSON value becomes `(External)`, and sync loads the external file automatically.
307
+ For `--file_props`, use comma-separated property names or dot paths, such as `script,params.script`. Only nonempty string values are extracted. The JSON value becomes `(External)`, and sync loads the external file automatically. Setup chooses extensions from executable commands, shebangs, and JSON content when possible. Use `--default_ext ps1` (or `--default_ext .ps1`) to replace the `.txt` fallback for properties whose type cannot be detected. The override does not replace an extension that setup successfully detects.
301
308
 
302
- Without `--force`, setup stops when an output file already exists. Files written before an error are not rolled back. Ensure object titles produce distinct filename slugs, especially when using `--force`, and back up any local edits before rerunning setup. For a delete-mode inventory, verify that every object eligible for deletion that you intend to keep has a source file. Stock and Marketplace objects are protected from deletion and may be omitted.
309
+ 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.
310
+
311
+ 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.
312
+
313
+ Without `--force`, ordinary setup stops when an output file already exists. Files written before an error are not rolled back. Ensure object titles produce distinct filename slugs, especially when using `--force`, and back up any local edits before rerunning setup. For a delete-mode inventory, verify that every object eligible for deletion that you intend to keep has a source file. Stock and Marketplace objects are protected from deletion and may be omitted.
303
314
 
304
315
  ## api
305
316
 
package/docs/sync.md CHANGED
@@ -113,18 +113,20 @@ xyops-automation/
113
113
  categories/
114
114
  Backups.json
115
115
  events/
116
- Nightly-Backup.json
117
- Nightly-Backup-params.script.sh
116
+ Operations/
117
+ Nightly-Backup.json
118
+ Nightly-Backup-params.script.sh
118
119
  plugins/
119
120
  Custom-Runner.json
120
121
  Custom-Runner-script.js
121
122
  workflows/
122
- Release-Pipeline.json
123
+ Deployments/
124
+ Release-Pipeline.json
123
125
  ```
124
126
 
125
- Setup creates one folder per selected resource type and one JSON file per definition. Event and workflow exports are separated into `events/` and `workflows/`. Both participate in normal sync through the `events` selection.
127
+ Setup creates one folder per selected resource type and one JSON file per definition. Event and workflow exports are separated into `events/` and `workflows/`, then grouped into subfolders using their Category titles. Both participate in normal sync through the `events` selection.
126
128
 
127
- Filenames come from each definition's title: sequences of non-word characters become hyphens, and leading or trailing hyphens are removed. For example, `Nightly Backup` becomes `Nightly-Backup.json`. Different titles can produce the same filename, so review the preview for collisions.
129
+ Folder and filenames come from Category and definition titles: sequences of non-word characters become hyphens, and leading or trailing hyphens are removed. For example, the Event `Nightly Backup` in the `Operations` Category becomes `events/Operations/Nightly-Backup.json`. Different titles can produce the same path, so review the preview for collisions. Setup stops before writing anything if an Event or workflow selected for export refers to a Category that is not present in xyOps.
128
130
 
129
131
  ### 4. Review and version the tree
130
132
 
@@ -159,6 +161,17 @@ xy sync setup events plugins --force
159
161
 
160
162
  `--force` allows generated JSON and external files to be overwritten. It does not clean up old filenames, solve filename collisions, or preserve your local edits. Export into a fresh directory when adding newly created objects, then move their reviewed files into the existing tree.
161
163
 
164
+ To add only definitions that are new to an existing sync tree, run setup from the tree's root with `--new`:
165
+
166
+ ```sh
167
+ xy sync setup events plugins categories --new --file_props script,params.script --dry
168
+ xy sync setup events plugins categories --new --file_props script,params.script
169
+ ```
170
+
171
+ 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`.
172
+
173
+ 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.
174
+
162
175
  Alternatively, you can export single objects at a time:
163
176
 
164
177
  ```sh
@@ -232,6 +245,8 @@ This extracts the selected content, if found, and moves it to a neighboring file
232
245
 
233
246
  The general naming pattern is **`BASENAME-PROPERTY.EXT`**, in the same directory as `BASENAME.json`. Dot paths such as `params.script` identify nested properties. Setup chooses an extension from the executable command or a script's shebang when possible, falling back to JSON-looking content or `.txt`. Normal sync accepts your chosen extension.
234
247
 
248
+ Use `--default_ext EXT` to replace the `.txt` fallback when setup cannot detect a type. For example, `--default_ext ps1` and `--default_ext .ps1` both produce `.ps1` neighbors. Detected interpreter and JSON extensions still take precedence. The fallback applies to every undetected property selected by that setup command, so group properties with the same expected file type or run setup separately when needed.
249
+
235
250
  External properties are detected automatically when sync runs; you do not need to repeat `--file_props`. The neighbor's text replaces that property in the local definition before comparison and before an upload. A pull writes the latest value into the existing neighbor file.
236
251
 
237
252
  You can use this for other string properties too, such as `body` or `notes`. Keep only one neighbor file per unique property path. A neighbor must resolve to a valid property path, and multiple matching neighbors are not treated as alternative versions.
@@ -393,12 +408,16 @@ At least one direction must be enabled. The command is a single run, not a backg
393
408
  | `xy sync setup TYPES...` | Export the selected types to the current directory. Separate type names with spaces, or use `all`. |
394
409
  | `--setup TYPES` | Alternative setup syntax, accepting comma-separated type names. For example, `xy sync --setup events,plugins`. |
395
410
  | `--file_props PATHS` | Extract string properties into adjacent neighbor files. Accepts comma-separated dot paths or a JSON array. |
411
+ | `--default_ext EXT` | Use this extension instead of `txt` when a neighbor's type cannot be detected. A leading dot is optional. |
412
+ | `--new` | Export only remote definitions whose type and ID are not already present in the current local tree. |
413
+ | `--down_cmd COMMAND` | Run a local shell command after setup writes one or more definitions. |
414
+ | `--cmd_timeout SECONDS` | Set the setup completion-command timeout in seconds. The default is `30`. |
396
415
  | `--stock` | Include stock definitions that ships wit xyOps (e.g. "Shell Plugin"), which setup normally omits. |
397
416
  | `--marketplace` | Include Marketplace Plugins, which setup normally omits. |
398
417
  | `--force` | Allow generated files to overwrite existing destinations. |
399
418
  | `--dry` | Preview setup's output without creating folders or files. |
400
419
 
401
- Workflow defaults in the `sync` configuration object are not used by setup. Pass setup choices explicitly on the command line. The common `lock_file` setting still applies so setup cannot overlap another sync operation for the same instance.
420
+ Workflow defaults in the `sync` configuration object are not used by setup. Pass setup choices explicitly on the command line. An explicitly supplied `down_cmd` runs from the current setup directory only after real files are written, so it does not run during a dry run or when `--new` finds nothing. The common `lock_file` setting still applies so setup cannot overlap another sync operation for the same instance.
402
421
 
403
422
  ## Two-way sync and modification times
404
423
 
@@ -501,6 +520,7 @@ xy sync ./ --down events,plugins --down_cmd "./commit-downloads.sh" --cmd_timeou
501
520
 
502
521
  - `up_cmd` runs if at least one object was successfully upsynced (this includes deletes).
503
522
  - `down_cmd` runs if at least one object was successfully downsynced.
523
+ - During setup, an explicitly supplied `down_cmd` runs if at least one definition was written.
504
524
  - If both apply, `up_cmd` runs first, then `down_cmd`.
505
525
  - Neither runs for an identical/no-op run, a scan that stopped before changes, or a dry run.
506
526
  - A completion command can run after partial success even if another item failed. It is not proof that the whole run succeeded.
@@ -898,7 +918,7 @@ Repository concurrency controls these workflow runs. Coordinate all writers to t
898
918
  | A script edit has no effect | Check the matching JSON stem, property suffix, filename extension, and property path. Hidden paths are skipped. |
899
919
  | Two-way sync overwrites an unexpected side | Compare the remote modification time with the newest JSON/property-file mtime. Fresh checkouts, clock skew, or touched files can make local sources appear newer. |
900
920
  | An Event or workflow does not execute | Sync saves definitions; it does not run them. Use `xy run EVENT_ID` separately. |
901
- | `down_cmd` does not run | It requires at least one successful download. A no-op, dry run, scan failure, or deletion alone does not trigger it. |
921
+ | `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. |
902
922
  | 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. |
903
923
  | 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. |
904
924
  | A scheduled run prints nothing | `--quiet` suppresses routine output and collected reports. Remove it to investigate and configure notifications. |
@@ -0,0 +1,262 @@
1
+ # Two-Way Git Sync Tutorial
2
+
3
+ > [!IMPORTANT]
4
+ > Some of the features used in this tutorial require xyCLI v1.0.2 or newer.
5
+
6
+ This tutorial sets up one persistent Git checkout as the bridge between xyOps and a shared repository. Team members can edit and push Event files through Git, while changes made in the xyOps web interface are downloaded, committed, and pushed back to the repository.
7
+
8
+ The finished workflow looks like this:
9
+
10
+ ```text
11
+ Developers <-> Git repository <-> Dedicated sync checkout <-> xyOps
12
+ ```
13
+
14
+ One machine and operating-system account should own the dedicated sync checkout. That machine can be an xyOps worker running xySat, but xySat is not required. Cron, another scheduler, or a manually launched script works too.
15
+
16
+ This tutorial manages Events and workflows. You can expand the commands to other [supported resource types](sync.md#supported-resources-and-requirements) after the Event flow is working.
17
+
18
+ ## Before you begin
19
+
20
+ You will need:
21
+
22
+ - xyCLI installed and connected to xyOps.
23
+ - A dedicated Git repository and a persistent clone on the sync machine.
24
+ - A xyOps API Key that can read and edit Events and update the global sync state.
25
+ - Git credentials that allow the sync account to pull and push without an interactive prompt.
26
+ - A configured Git author name and email for automated commits.
27
+ - Accurate clocks on the sync machine and the xyOps conductor, preferably synchronized through NTP.
28
+ - The `flock` command on the scheduler host if you plan to use cron.
29
+
30
+ Two-way sync is experimental. When the local and remote definitions differ, xyCLI compares the xyOps modification timestamp with the newest modification time among the local JSON file and its external property files. The newer side wins, and equal timestamps favor the local copy.
31
+
32
+ Do not periodically replace the dedicated checkout with a fresh clone. Git checkout operations can change filesystem timestamps, which are part of two-way conflict resolution.
33
+
34
+ ## 1. Prepare the dedicated checkout
35
+
36
+ Clone the repository into a permanent location. Replace the example URL and path with your own:
37
+
38
+ ```sh
39
+ git clone git@github.com:YOUR_ORG/xyops-automation.git /srv/xyops-automation
40
+ cd /srv/xyops-automation
41
+ ```
42
+
43
+ Configure the identity used for commits created by sync:
44
+
45
+ ```sh
46
+ git config user.name "xyOps Sync"
47
+ git config user.email "xyops-sync@example.com"
48
+ ```
49
+
50
+ Run all remaining setup commands as the same operating-system account that will run the scheduled job. Configure xyCLI for that account if you have not already done so:
51
+
52
+ ```sh
53
+ xy config --base_url https://xyops.example.com --api_key YOUR_API_KEY
54
+ xy dashboard
55
+ ```
56
+
57
+ The dashboard command verifies the connection. Keep the API Key and Git credentials out of the repository.
58
+
59
+ ## 2. Export the initial Event tree
60
+
61
+ Preview the initial setup from the repository root:
62
+
63
+ ```sh
64
+ cd /srv/xyops-automation
65
+ xy sync setup events --file_props script,params.script --default_ext ps1 --dry
66
+ ```
67
+
68
+ The `events` selection includes both Events and workflows. Setup places workflows in their own `workflows/` directory, then groups Events and workflows into subfolders named after their Categories.
69
+
70
+ `--file_props script,params.script` extracts common Plugin and Event script properties into neighboring files. If your Plugin uses another property, add its dot path to the list. For example, use `script,params.script,params.command` when an Event stores its PowerShell source in `params.command`.
71
+
72
+ The optional `--default_ext ps1` uses `.ps1` when setup cannot detect a neighbor file's language from an executable command, shebang, or JSON content. Remove it or choose another extension if PowerShell is not the appropriate fallback for your repository.
73
+
74
+ When the preview looks correct, write the files:
75
+
76
+ ```sh
77
+ xy sync setup events --file_props script,params.script --default_ext ps1
78
+ ```
79
+
80
+ Review the generated files before publishing them. Definitions can contain scripts, request headers, and other sensitive configuration.
81
+
82
+ ```sh
83
+ git status --short
84
+ find events workflows -type f -print 2>/dev/null
85
+ ```
86
+
87
+ Commit and push the initial tree:
88
+
89
+ ```sh
90
+ git add --all
91
+ git commit -m "Add xyOps Event definitions"
92
+ git push -u origin HEAD
93
+ ```
94
+
95
+ Setup is only responsible for creating the local source files. Normal sync identifies each definition by its resource type and exact ID inside the JSON, so you may reorganize or rename the generated files afterward.
96
+
97
+ ## 3. Preview two-way sync
98
+
99
+ Run a dry pass before enabling automation:
100
+
101
+ ```sh
102
+ xy sync . --up events --down events --delete false --dry
103
+ ```
104
+
105
+ This considers both directions for every local Event and workflow source:
106
+
107
+ - A newer local file is uploaded to its existing xyOps definition.
108
+ - A newer xyOps definition is downloaded to its existing local file.
109
+ - Identical definitions are left alone.
110
+
111
+ The `--delete false` switch is intentional. Two-way sync does not propagate deletions safely. Delete an item deliberately on both sides, and keep deletion disabled in the scheduled command.
112
+
113
+ Review the displayed diffs carefully. If the wrong side would win, check the clocks and file modification times before continuing.
114
+
115
+ ## 4. Create the scheduled sync script
116
+
117
+ Save the following script as `/usr/local/sbin/xyops-git-sync`. Adjust `PATH`, `REPO_DIR`, `XY_BIN`, the extracted property list, and the fallback extension for your environment.
118
+
119
+ ```sh
120
+ #!/bin/sh
121
+ set -eu
122
+
123
+ # Cron and xySat jobs often receive a minimal environment. Include the
124
+ # directories containing Node.js, xyCLI, Git, and standard system tools.
125
+ PATH="/usr/local/bin:/usr/bin:/bin"
126
+ export PATH
127
+
128
+ REPO_DIR="/srv/xyops-automation"
129
+ XY_BIN="/usr/local/bin/xy"
130
+
131
+ cd "$REPO_DIR"
132
+
133
+ # This should be a dedicated automated checkout. Stop instead of accidentally
134
+ # committing an operator's unfinished work or leftovers from a failed run.
135
+ if [ -n "$(git status --porcelain)" ]; then
136
+ echo "The sync checkout has uncommitted changes. Resolve them before retrying."
137
+ exit 1
138
+ fi
139
+
140
+ # Bring in changes pushed by contributors. The extra push retries a commit
141
+ # that may have succeeded during an earlier run whose network push then failed.
142
+ git pull --quiet --ff-only
143
+ git push --quiet
144
+
145
+ # xyCLI runs this only after it writes local definitions.
146
+ GIT_DOWN_CMD="git add --all && git commit -m 'Sync from xyOps' && git push"
147
+
148
+ # Sync changes to existing local definitions in both directions. If this
149
+ # command fails, set -e stops the script before the setup pass.
150
+ "$XY_BIN" sync . \
151
+ --up events \
152
+ --down events \
153
+ --delete false \
154
+ --down_cmd "$GIT_DOWN_CMD" \
155
+ --cmd_timeout 120
156
+
157
+ # Discover definitions created in xyOps since the initial setup. Existing
158
+ # sources are identified by type and ID, regardless of their paths or names.
159
+ # The setup completion command runs only when new files were actually written.
160
+ "$XY_BIN" sync setup events \
161
+ --new \
162
+ --file_props script,params.script \
163
+ --default_ext ps1 \
164
+ --down_cmd "$GIT_DOWN_CMD" \
165
+ --cmd_timeout 120
166
+ ```
167
+
168
+ Make the script executable:
169
+
170
+ ```sh
171
+ chmod 755 /usr/local/sbin/xyops-git-sync
172
+ ```
173
+
174
+ Run it manually as the eventual scheduler account before installing a schedule:
175
+
176
+ ```sh
177
+ /usr/local/sbin/xyops-git-sync
178
+ ```
179
+
180
+ The script intentionally does not implement its own outer lock. A cron caller will use `flock` to protect the complete pull, normal sync, and new-definition setup sequence. When xyOps schedules the script through xySat, the Event's job concurrency setting provides this protection instead. xyCLI's built-in lock still protects each individual sync command.
181
+
182
+ The two uses of `--down_cmd` are deliberately identical:
183
+
184
+ - Ordinary sync runs it only after at least one existing definition is successfully downloaded.
185
+ - `sync setup --new` runs it only after at least one new local definition is written.
186
+ - A no-op or dry run does not create a commit or push.
187
+
188
+ 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
+
190
+ ## 5. Schedule the script with cron
191
+
192
+ Open the crontab for the sync account:
193
+
194
+ ```sh
195
+ crontab -e
196
+ ```
197
+
198
+ Locate `flock` and adjust the path in the cron entry if necessary:
199
+
200
+ ```sh
201
+ command -v flock
202
+ ```
203
+
204
+ Run the script every minute, with `flock` outside the script:
205
+
206
+ ```cron
207
+ * * * * * /usr/bin/flock --nonblock --conflict-exit-code 0 /tmp/xyops-git-sync.lock /usr/local/sbin/xyops-git-sync >/dev/null
208
+ ```
209
+
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.
211
+
212
+ 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
+
214
+ `--nonblock` makes an overlapping invocation exit immediately instead of waiting. `--conflict-exit-code 0` treats that expected skip as a successful scheduler run, while an executed sync script still returns its own exit status. The operating system releases the advisory lock automatically when `flock` exits, including when the sync script fails. The `/tmp/xyops-git-sync.lock` file may remain afterward, which is normal and does not mean the lock is still held. See the [`flock` manual](https://man7.org/linux/man-pages/man1/flock.1.html) for details.
215
+
216
+ ## 6. Or schedule it through xySat
217
+
218
+ On a machine running xySat, create an Event using the Shell Plugin and target only the dedicated sync worker. Give it a recurring one-minute schedule and use this script body:
219
+
220
+ ```sh
221
+ /usr/local/sbin/xyops-git-sync
222
+ ```
223
+
224
+ Keep the Event's [job concurrency setting](https://docs.xyops.io/#Docs/limits/max-jobs-limit) at `1`, which is the default. xyOps will then prevent a second copy of the Event from running while the first job is still active, so this invocation does not need `flock`.
225
+
226
+ The xySat service account must be able to execute the script, read and write the repository, run Node.js and xyCLI, access that account's xyCLI configuration, and use the Git credentials. Test the Event manually before enabling its schedule.
227
+
228
+ Schedule only one copy of this workflow, and do not run both cron and xySat for the same checkout.
229
+
230
+ ## 7. Understand the daily workflow
231
+
232
+ After setup, contributors work through Git as usual:
233
+
234
+ ```sh
235
+ git pull
236
+ # Edit Event JSON or neighboring script files.
237
+ git add --all
238
+ git commit -m "Update nightly report"
239
+ git push
240
+ ```
241
+
242
+ The next scheduled pass pulls the commit and uploads newer local definitions to xyOps. If someone edits an existing Event in the xyOps web interface, the next pass downloads the newer definition, and `--down_cmd` commits and pushes the changed files.
243
+
244
+ When someone creates a new Event in xyOps, the ordinary sync pass does not know where to create its local source. The following `sync setup events --new` pass discovers it, creates the suggested files in its Category folder, then commits and pushes them through its own `--down_cmd`.
245
+
246
+ `--new` is additive. It does not overwrite existing sources, and it matches existing definitions by type and exact ID rather than filename. This preserves renamed files and custom folder layouts.
247
+
248
+ ## Troubleshooting
249
+
250
+ | Symptom | What to check |
251
+ | --- | --- |
252
+ | `git pull --ff-only` fails | The sync clone and remote branch have diverged, or the working tree changed. Resolve the Git state manually. Do not force-reset the automated checkout without reviewing its commits. |
253
+ | `git push` prompts or fails under cron or xySat | Configure noninteractive SSH or HTTPS credentials for the scheduler account and confirm the branch allows that account to push. |
254
+ | Git reports an unknown author | Set `user.name` and `user.email` in the dedicated clone for the scheduler account. |
255
+ | The wrong side wins a two-way conflict | Check NTP, the xyOps conductor clock, and the modification times of the JSON and neighboring files. A checkout, restore, or file copy can refresh local timestamps. |
256
+ | A new Event is not exported | Confirm `sync setup events --new` runs from the repository root and that the API Key can see the Event. Add the correct property path if its script is stored somewhere other than `params.script`. |
257
+ | A new Git file is not created in xyOps | Ordinary sync updates definitions that already exist on both sides. Create or import the definition into xyOps first, then let sync manage subsequent changes. |
258
+ | A deleted item reappears or causes an error | Two-way deletion is not supported. Remove the local source and the xyOps definition deliberately as one coordinated operation. |
259
+ | A cron run is skipped | Another cron invocation may hold the `flock` lock. The lock file itself may remain when no lock is held, so check for a running sync process rather than treating the file as stale. |
260
+ | A completion command times out | Increase `--cmd_timeout`, investigate Git network latency, and confirm that Git cannot wait for interactive input. |
261
+
262
+ For the complete behavior and option reference, see the [Filesystem Sync Guide](sync.md) and the [`sync` command reference](help.md#sync).
package/lib/sync.js CHANGED
@@ -30,6 +30,11 @@ module.exports = {
30
30
  // xy sync --up "events,plugins" --delete plugins
31
31
  await this.getMultiple();
32
32
 
33
+ // Both ordinary sync and setup completion commands report failures through
34
+ // the shared warning and error collectors.
35
+ this.warnings = [];
36
+ this.errors = [];
37
+
33
38
  // optionally setup local filesystem for sync
34
39
  if (this.args.other && (this.args.other[0] === 'setup')) {
35
40
  this.args.other.shift();
@@ -72,9 +77,6 @@ module.exports = {
72
77
  this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
73
78
  }
74
79
 
75
- this.warnings = [];
76
- this.errors = [];
77
-
78
80
  // scan all base directories, default to cwd
79
81
  var files = [];
80
82
  if (!this.args.other || !this.args.other.length) {
@@ -635,17 +637,19 @@ module.exports = {
635
637
  } // run_event
636
638
  },
637
639
 
638
- syncFinishCmd(cmd) {
640
+ syncFinishCmd(cmd, opts) {
639
641
  // spawn command and log output
640
- var sconfig = this.sconfig;
641
- var cwd = this.args.other[0]; // use first base dir as cwd for shell commands
642
+ opts = opts || {};
643
+ var sconfig = this.sconfig || {};
644
+ var cwd = opts.cwd || this.args.other[0]; // default to first normal sync base dir
645
+ var cmd_timeout = opts.cmd_timeout || sconfig.cmd_timeout || 30;
642
646
 
643
647
  println( "\n " + green.bold("Running completion command: ") + cyan.bold(cmd) );
644
648
 
645
649
  try {
646
650
  const output = cp.execSync(cmd, {
647
651
  cwd: cwd,
648
- timeout: (sconfig.cmd_timeout || 30) * 1000,
652
+ timeout: cmd_timeout * 1000,
649
653
  encoding: 'utf8',
650
654
  stdio: ['ignore', 'pipe', 'pipe']
651
655
  });
@@ -732,12 +736,8 @@ module.exports = {
732
736
  async setupSync() {
733
737
  // export select resources to local XYPDF files in suggested layout
734
738
  // xy sync --setup all --file_props "script,params.script"
739
+ // xy sync --setup events --new
735
740
  // xy sync --setup all --marketplace --stock --force
736
- var data_types = this.config.ui.data_types;
737
- var list_data_types = Object.keys(data_types).map( key => {
738
- return { ...data_types[key], id: key };
739
- } );
740
-
741
741
  if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
742
742
  this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
743
743
  }
@@ -760,87 +760,279 @@ module.exports = {
760
760
  if (!Array.isArray(this.args.file_props)) this.dieUsage('sync setup');
761
761
  }
762
762
 
763
+ // New-only setup is intentionally an additive operation. It must never be
764
+ // combined with the overwrite behavior of --force.
765
+ var new_only = false;
766
+ if (this.args.new !== undefined) {
767
+ if ((this.args.new !== true) && (this.args.new !== false)) {
768
+ this.die("Invalid --new value (use --new or --new false).");
769
+ }
770
+ new_only = this.args.new;
771
+ }
772
+ if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
773
+
774
+ var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
775
+ var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
776
+
763
777
  this.printBoxList({
764
778
  title: 'Sync Setup',
765
779
  rows: [
766
780
  ['Item Types', this.args.setup.join(', ')],
767
781
  ['File Props', this.args.file_props ? this.args.file_props.join(', ') : '(None)'],
782
+ ['Default Ext', '.' + default_ext],
783
+ new_only ? ['New Only', 'Yes'] : null,
768
784
  this.dry ? ['Dry Run', 'No changes will be made.'] : null
769
785
  ]
770
786
  });
771
787
 
772
788
  println( "\n " + this.color('theme').bold("WRITING FILES:") );
773
789
 
774
- // split up workflows into their own folder
775
- if (this.args.setup.includes('events')) {
776
- this.workflows = this.events.filter( event => event.type == 'workflow' );
777
- this.events = this.events.filter( event => event.type != 'workflow' );
778
- this.args.setup.push('workflows');
779
- list_data_types.push( { id: "event", "list": "workflows" } );
790
+ var result = this.writeSyncSetupFiles(this.args.setup, {
791
+ base_dir: '.',
792
+ existing_keys: existing_keys,
793
+ new_only: new_only,
794
+ file_props: this.args.file_props || [],
795
+ default_ext: default_ext,
796
+ stock: !!this.args.stock,
797
+ marketplace: !!this.args.marketplace,
798
+ force: !!this.args.force,
799
+ dry: !!this.dry
800
+ });
801
+
802
+ if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
803
+
804
+ println( "\n " + this.color('theme').bold("Setup complete!") );
805
+
806
+ // Setup writes into the current directory, not a positional base directory.
807
+ // Only run an explicitly supplied down command after real files were written.
808
+ if (!this.dry && result.written && this.args.down_cmd) {
809
+ this.syncFinishCmd(this.args.down_cmd, {
810
+ cwd: '.',
811
+ cmd_timeout: this.args.cmd_timeout
812
+ });
813
+ }
814
+ },
815
+
816
+ normalizeSyncDefaultExt(value) {
817
+ // Allow users to choose the fallback extension for extracted properties whose
818
+ // language cannot be detected. A leading dot is accepted for convenience,
819
+ // but paths and compound values are rejected before any files are written.
820
+ if (value === undefined) return 'txt';
821
+ if (typeof(value) != 'string') {
822
+ this.die("Invalid --default_ext value (use a filename extension such as ps1 or .ps1).");
780
823
  }
781
824
 
782
- this.args.setup.forEach( (list_name) => {
783
- var list = this[list_name];
784
- if (!list || !list.length) return;
825
+ var ext = value.trim().replace(/^\./, '');
826
+ if (!ext.match(/^[A-Za-z0-9][A-Za-z0-9_]*$/)) {
827
+ this.die("Invalid --default_ext value (use a filename extension such as ps1 or .ps1).");
828
+ }
829
+ return ext;
830
+ },
831
+
832
+ scanSyncSourceIndex(base_dirs) {
833
+ // Build an identity index from an existing sync tree. Filenames and folders
834
+ // are deliberately ignored, because sync sources may be freely reorganized.
835
+ var files = [];
836
+ base_dirs.forEach( dir => {
837
+ dir = Path.normalize(dir).replace(/[\\/]+$/, '') || '.';
838
+ if (!fs.existsSync(dir)) this.die("Sync base dir does not exist: " + dir);
839
+ files = files.concat( Tools.findFilesSync(dir, { stats: true }) );
840
+ });
841
+
842
+ var index = {};
843
+ files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
844
+ var xypdf = null;
845
+ try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
846
+ catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
785
847
 
786
- var type_def = Tools.findObject( list_data_types, { list: list_name } );
787
- if (!type_def) return; // sanity
848
+ // Ordinary JSON files are unrelated and may coexist in the tree.
849
+ if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
850
+ if (xypdf.version !== '1.0') this.die("File is not a compatible version of XYPDF: " + file.path);
851
+ if (xypdf.xyops !== undefined && this.compareVersions(xypdf.xyops, this.xyopsVersion) > 0) {
852
+ this.die("Item requires xyOps v" + xypdf.xyops + " or higher: " + file.path);
853
+ }
854
+ if (!Array.isArray(xypdf.items) || (xypdf.items.length !== 1)) {
855
+ this.die("Sync source must contain exactly one item: " + file.path);
856
+ }
788
857
 
789
- if (!this.dry) Tools.mkdirp.sync( list_name );
790
- println( "\n " + cli.emoji('📂') + " " + bold.cyan(list_name + '/') );
858
+ var item = xypdf.items[0];
859
+ if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
860
+ this.die("Malformed item type in file: " + file.path);
861
+ }
862
+ if (item.type.match(/^(bucket|secret|user|role)$/)) {
863
+ this.die("Unsupported item type: " + item.type + " in file: " + file.path);
864
+ }
865
+ if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
866
+ this.die("Malformed or missing item data or ID in file: " + file.path);
867
+ }
791
868
 
792
- list.forEach( (item) => {
793
- // exclude marketplace and stock items
794
- if (item.marketplace && !this.args.marketplace) return;
795
- if (item.stock && !this.args.stock) return;
869
+ var type_def = this.config.ui.data_types[item.type];
870
+ if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
871
+ var list = this[type_def.list];
872
+ if (!list) this.die("Cannot locate internal list: " + type_def.list);
873
+ if (!Tools.findObject(list, { id: item.data.id })) {
874
+ this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
875
+ }
876
+
877
+ var key = item.type + '-' + item.data.id;
878
+ if (index[key]) {
879
+ this.die("Duplicate source item found: " + file.path + " (already found in " + index[key].file + ")");
880
+ }
881
+ index[key] = { file: file.path, item: item };
882
+ });
883
+
884
+ return index;
885
+ },
886
+
887
+ writeSyncSetupFiles(list_names, opts = {}) {
888
+ // Write selected remote definitions using setup's suggested layout. Keeping
889
+ // this operation separate allows a future downsync to append newly discovered
890
+ // remote definitions after it finishes updating existing local sources.
891
+ var data_types = this.config.ui.data_types;
892
+ var list_data_types = Object.keys(data_types).map( key => {
893
+ return { ...data_types[key], id: key };
894
+ });
895
+ var targets = [];
896
+
897
+ list_names.forEach( list_name => {
898
+ if (list_name == 'events') {
899
+ targets.push({ name: 'events', type: 'event', items: this.events.filter( event => event.type != 'workflow' ) });
900
+ targets.push({ name: 'workflows', type: 'event', items: this.events.filter( event => event.type == 'workflow' ) });
901
+ return;
902
+ }
903
+
904
+ var type_def = Tools.findObject( list_data_types, { list: list_name } );
905
+ if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
906
+ });
907
+
908
+ var result = { written: 0, skipped: 0 };
909
+ var existing_keys = opts.existing_keys || {};
910
+ var base_dir = opts.base_dir || '.';
911
+ var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
912
+
913
+ // Build and validate the complete write plan before creating any directories.
914
+ // Events and workflows are grouped under their xyOps Category titles. A
915
+ // missing Category indicates inconsistent remote data, so setup must stop
916
+ // without leaving behind a partially generated tree.
917
+ targets.forEach( target => {
918
+ var items = target.items.filter( item => {
919
+ if (item.marketplace && !opts.marketplace) return false;
920
+ if (item.stock && !opts.stock) return false;
921
+ if (opts.new_only && existing_keys[target.type + '-' + item.id]) {
922
+ result.skipped++;
923
+ return false;
924
+ }
925
+ return true;
926
+ });
927
+
928
+ target.plans = items.map( source_item => {
929
+ var dir = Path.join(base_dir, target.name);
930
+ var display_dir = target.name + '/';
931
+
932
+ if (target.type == 'event') {
933
+ var category = Tools.findObject( this.categories, { id: source_item.category } );
934
+ if (!category) {
935
+ this.die("Cannot find Category for " + (source_item.type == 'workflow' ? 'workflow' : 'Event') + ": " + (source_item.title || source_item.id) + " (" + (source_item.category || 'missing ID') + ")");
936
+ }
937
+
938
+ var category_slug = this.getSyncSetupTitleSlug(category.title, 'Category', category.id);
939
+ dir = Path.join(dir, category_slug);
940
+ display_dir += category_slug + '/';
941
+ }
796
942
 
797
- // extract extras first
943
+ var title = this.getSyncSetupTitleSlug(source_item.title, (source_item.type == 'workflow' ? 'workflow' : target.type), source_item.id);
944
+ return { source_item, title, dir, display_dir };
945
+ });
946
+
947
+ // Remote Event order is not guaranteed to keep Categories together. Group
948
+ // plans explicitly so each printed folder heading owns every file shown
949
+ // beneath it, while preserving the first-seen order of folders and items.
950
+ var groups_by_dir = new Map();
951
+ var plan_groups = [];
952
+ target.plans.forEach( plan => {
953
+ var group = groups_by_dir.get(plan.dir);
954
+ if (!group) {
955
+ group = [];
956
+ groups_by_dir.set(plan.dir, group);
957
+ plan_groups.push(group);
958
+ }
959
+ group.push(plan);
960
+ });
961
+ target.plans = [].concat(...plan_groups);
962
+ });
963
+
964
+ // All Categories are now known to be valid, so it is safe to start writing.
965
+ targets.forEach( target => {
966
+ var active_dir = '';
967
+ target.plans.forEach( plan => {
968
+ var dir = plan.dir;
969
+ if (dir != active_dir) {
970
+ if (!opts.dry) Tools.mkdirp.sync(dir);
971
+ println( "\n " + cli.emoji('📂') + " " + bold.cyan(plan.display_dir) );
972
+ active_dir = dir;
973
+ }
974
+
975
+ // Work on a deep copy so setup never changes the cached remote definition.
976
+ var item = Tools.copyHash(plan.source_item, true);
798
977
  var extras = [];
799
- (this.args.file_props || []).forEach( (path) => {
978
+ (opts.file_props || []).forEach( path => {
800
979
  var extra = Tools.getPath(item, path);
801
980
  if ((typeof(extra) == 'string') && extra.match(/\S/)) {
802
981
  extras.push({ path, content: extra });
803
-
804
- // we do not need to check the return value of setPath here,
805
- // because this path is guaranteed to exist at this point
806
982
  Tools.setPath( item, path, "(External)" );
807
983
  }
808
- } );
984
+ });
809
985
 
810
- var data = this.getItemExportData(type_def.id, item);
811
- var title = item.title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
986
+ var data = this.getItemExportData(target.type, item);
987
+ var title = plan.title;
812
988
  var filename = title + '.json';
813
- var file = Path.join(list_name, filename);
989
+ var file = Path.join(dir, filename);
814
990
 
815
- if (!this.dry) {
816
- if (!this.args.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
991
+ if (!opts.dry) {
992
+ if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
817
993
  Tools.writeFileAtomicSync( file, JSON.stringify(data, null, "\t") + "\n" );
818
994
  }
819
995
  println( " " + cli.emoji('📄') + " " + bold.green(filename) );
996
+ result.written++;
820
997
 
821
- // now handle extras
822
- extras.forEach( (extra) => {
998
+ extras.forEach( extra => {
823
999
  var command = item.command;
824
1000
  if (!command && extra.content.match(/^\s*\#\!(.+?)\n/)) command = RegExp.$1;
825
1001
 
826
- var ext = 'txt';
827
- if (command) ext = this.getExtFromBinary(command);
1002
+ var ext = default_ext;
1003
+ if (command) {
1004
+ var detected_ext = this.getExtFromBinary(command);
1005
+ if (detected_ext != 'txt') ext = detected_ext;
1006
+ }
828
1007
  else if (extra.content.trim().match(/^\{[\S\s]*\}$/) || extra.content.trim().match(/^\[[\S\s]*\]$/)) ext = 'json';
829
1008
 
830
1009
  var extra_filename = title + '-' + extra.path + '.' + ext;
831
- var extra_file = Path.join(list_name, extra_filename);
832
-
833
- if (!this.dry) {
834
- if (!this.args.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
1010
+ var extra_file = Path.join(dir, extra_filename);
1011
+ if (!opts.dry) {
1012
+ if (!opts.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
835
1013
  Tools.writeFileAtomicSync( extra_file, extra.content );
836
1014
  }
837
-
838
1015
  println( " " + cli.emoji('📜') + " " + bold.yellow(extra_filename) );
839
- } );
840
- } ); // foreach item
841
- } ); // foreach list
1016
+ });
1017
+ });
1018
+ });
842
1019
 
843
- println( "\n " + this.color('theme').bold("Setup complete!") );
1020
+ return result;
1021
+ },
1022
+
1023
+ getSyncSetupTitleSlug(title, item_type, item_id) {
1024
+ // Use one filename-safe transformation for definitions and Category folders.
1025
+ // Reject unusable titles explicitly instead of silently producing `.json` or
1026
+ // collapsing an Event back into the top-level resource directory.
1027
+ if (typeof(title) != 'string') {
1028
+ this.die("Cannot create sync setup path for " + item_type + ": Missing title (" + item_id + ")");
1029
+ }
1030
+
1031
+ var slug = title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
1032
+ if (!slug) {
1033
+ this.die("Cannot create sync setup path for " + item_type + ": Title has no usable filename characters (" + item_id + ")");
1034
+ }
1035
+ return slug;
844
1036
  },
845
1037
 
846
1038
  getItemExportData(type, item) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",
@@ -20,7 +20,8 @@
20
20
  "xyops", "cli"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test.js"
23
+ "test": "node test.js",
24
+ "changelog": "node tools/changelog.js"
24
25
  },
25
26
  "dependencies": {
26
27
  "@pixlcore/xyops-sdk": "^1.0.7",
package/test/README.md CHANGED
@@ -67,7 +67,7 @@ node --test test/config.test.js test/sync-unit.test.js
67
67
  | `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
68
68
  | `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
69
69
  | `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
70
- | `sync-unit` | Offline PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
70
+ | `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
71
71
  | `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
72
72
  | `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
73
73
  | `toast` | Warning-box word wrapping and terminal-width limits |
@@ -13,28 +13,31 @@ const script = `
13
13
  const cli = require('pixl-cli');
14
14
  cli.global();
15
15
  const options = JSON.parse(process.argv[1]);
16
- cli.args.quiet = true;
16
+ cli.args.quiet = options.quiet !== false;
17
+ const sync = require('./lib/sync.js');
18
+ if (options.setupCwd) process.chdir(options.setupCwd);
17
19
  const calls = [];
18
20
  const requests = [];
19
21
  const app = {
20
- ...require('./lib/sync.js'),
22
+ ...sync,
21
23
  args: options.args,
22
24
  config: {
23
25
  base_url: 'https://fixture.invalid',
24
26
  sync: options.config || {},
25
27
  ui: {
26
28
  list_list: ['categories', 'events', 'plugins'].map(id => ({ id })),
27
- data_types: { category: { list: 'categories' }, plugin: { list: 'plugins' } }
29
+ data_types: { category: { list: 'categories' }, event: { list: 'events' }, plugin: { list: 'plugins' } }
28
30
  }
29
31
  },
30
32
  state: {},
31
33
  categories: options.categories || [],
32
- events: [],
34
+ events: options.events || [],
33
35
  plugins: options.plugins || [],
34
36
  version: 'test',
35
37
  dry: !!options.args.dry,
36
38
  color() { return cli.chalk.white; },
37
39
  markdown(text) { return text; },
40
+ printBoxList() {},
38
41
  compareVersions() { return 0; },
39
42
  async getMultiple() {},
40
43
  die(message) { throw new Error(message); },
@@ -58,6 +61,7 @@ function runSync(t, options) {
58
61
  const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-sync-unit-'));
59
62
  t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
60
63
  const args = { other: [dir], ...options.args };
64
+ if (options.setup) args.other = ['setup', ...options.setup];
61
65
 
62
66
  if (options.source) {
63
67
  fs.writeFileSync(Path.join(dir, 'Category.json'), JSON.stringify({
@@ -79,9 +83,25 @@ function runSync(t, options) {
79
83
  fs.copyFileSync(Path.join(dir, 'Category.json'), Path.join(dir, 'Duplicate.json'));
80
84
  }
81
85
  }
86
+ if (options.existingSources) {
87
+ options.existingSources.forEach(source => {
88
+ const file = Path.join(dir, source.path);
89
+ fs.mkdirSync(Path.dirname(file), { recursive: true });
90
+ const contents = source.contents !== undefined ? source.contents : JSON.stringify({
91
+ type: 'xypdf',
92
+ version: '1.0',
93
+ items: [{ type: source.type, data: source.data }]
94
+ });
95
+ fs.writeFileSync(file, contents);
96
+ });
97
+ }
82
98
  if (options.missingDir) args.other = [Path.join(dir, 'missing')];
83
99
 
84
- const result = spawnSync(process.execPath, ['-e', script, JSON.stringify({ ...options, args })], {
100
+ const result = spawnSync(process.execPath, ['-e', script, JSON.stringify({
101
+ ...options,
102
+ args,
103
+ setupCwd: options.setup ? dir : undefined
104
+ })], {
85
105
  cwd: Path.join(__dirname, '..'),
86
106
  encoding: 'utf8',
87
107
  timeout: 10000
@@ -89,7 +109,14 @@ function runSync(t, options) {
89
109
  assert.ifError(result.error);
90
110
  assert.equal(result.signal, null);
91
111
  const report = JSON.parse(result.stdout.trim().split('\n').pop());
92
- return { ...result, report };
112
+ return { ...result, report, dir };
113
+ }
114
+
115
+ function getSetupMarkerCommand(filename) {
116
+ // Use the current Node.js binary so the completion-command fixture is
117
+ // portable and does not depend on a separate shell utility being installed.
118
+ const code = `require('node:fs').writeFileSync(${JSON.stringify(filename)}, process.cwd())`;
119
+ return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(code)}`;
93
120
  }
94
121
 
95
122
  test('sync update guard requires confirmation only for the exact managed object', async () => {
@@ -217,6 +244,247 @@ test('sync API errors preserve failure status while still sending both notificat
217
244
  }
218
245
  });
219
246
 
247
+ test('sync setup uses a custom extension only when detection falls back to txt', t => {
248
+ const result = runSync(t, {
249
+ setup: ['plugins'],
250
+ plugins: [
251
+ { id: 'fallback', title: 'Fallback', command: 'custom-runner', script: 'Write-Host "Hello"\n' },
252
+ { id: 'detected', title: 'Detected', command: 'pwsh -File', script: 'Write-Host "Hello"\n' },
253
+ { id: 'json', title: 'JSON Data', command: '', script: '{ "hello": true }\n' }
254
+ ],
255
+ args: { file_props: 'script', default_ext: '.code' }
256
+ });
257
+
258
+ assert.equal(result.status, 0);
259
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Fallback-script.code')), true);
260
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Detected-script.ps1')), true);
261
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'JSON-Data-script.json')), true);
262
+ });
263
+
264
+ test('sync setup rejects unsafe or malformed default extensions before writing', t => {
265
+ for (const defaultExt of [true, '', '.', '../ps1', 'ps/1', 'ps-1', 'tar.gz']) {
266
+ const result = runSync(t, {
267
+ setup: ['plugins'],
268
+ plugins: [{ id: 'fixture', title: 'Fixture', command: 'custom-runner', script: 'Example\n' }],
269
+ args: { file_props: 'script', default_ext: defaultExt }
270
+ });
271
+
272
+ assert.equal(result.status, 1);
273
+ assert.match(result.stderr, /Invalid --default_ext value/);
274
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
275
+ }
276
+ });
277
+
278
+ test('sync setup new exports only remote definitions missing from the local tree', t => {
279
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'console.log("Existing");\n' };
280
+ const added = { id: 'added', title: 'Added Plugin', command: 'custom-runner', script: 'Write-Host "Added"\n' };
281
+ const result = runSync(t, {
282
+ setup: ['plugins'],
283
+ plugins: [existing, added],
284
+ existingSources: [{
285
+ path: 'custom/layout/Renamed-Existing.json',
286
+ type: 'plugin',
287
+ data: existing
288
+ }],
289
+ args: { new: true, file_props: 'script', default_ext: 'ps1' }
290
+ });
291
+
292
+ assert.equal(result.status, 0);
293
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin.json')), true);
294
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Added-Plugin-script.ps1')), true);
295
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins', 'Existing.json')), false);
296
+ assert.equal(fs.existsSync(Path.join(result.dir, 'custom', 'layout', 'Renamed-Existing.json')), true);
297
+ });
298
+
299
+ test('sync setup runs down_cmd after writing files from the setup directory', t => {
300
+ const marker = 'setup-complete.txt';
301
+ const result = runSync(t, {
302
+ setup: ['plugins'],
303
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
304
+ args: {
305
+ file_props: 'script',
306
+ down_cmd: getSetupMarkerCommand(marker)
307
+ }
308
+ });
309
+
310
+ assert.equal(result.status, 0);
311
+ assert.equal(fs.readFileSync(Path.join(result.dir, marker), 'utf8'), fs.realpathSync(result.dir));
312
+ });
313
+
314
+ test('sync setup new runs down_cmd only when it writes new files', t => {
315
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
316
+ const marker = 'setup-new-complete.txt';
317
+ const command = getSetupMarkerCommand(marker);
318
+ const added = runSync(t, {
319
+ setup: ['plugins'],
320
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
321
+ existingSources: [{ path: 'Existing.json', type: 'plugin', data: existing }],
322
+ args: { new: true, down_cmd: command }
323
+ });
324
+
325
+ assert.equal(added.status, 0);
326
+ assert.equal(fs.existsSync(Path.join(added.dir, marker)), true);
327
+
328
+ const unchanged = runSync(t, {
329
+ setup: ['plugins'],
330
+ plugins: [existing],
331
+ existingSources: [{ path: 'Existing.json', type: 'plugin', data: existing }],
332
+ args: { new: true, down_cmd: command }
333
+ });
334
+
335
+ assert.equal(unchanged.status, 0);
336
+ assert.equal(fs.existsSync(Path.join(unchanged.dir, marker)), false);
337
+ });
338
+
339
+ test('sync setup does not run down_cmd during a dry run', t => {
340
+ const marker = 'setup-dry-complete.txt';
341
+ const result = runSync(t, {
342
+ setup: ['plugins'],
343
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
344
+ args: { dry: true, down_cmd: getSetupMarkerCommand(marker) }
345
+ });
346
+
347
+ assert.equal(result.status, 0);
348
+ assert.equal(fs.existsSync(Path.join(result.dir, marker)), false);
349
+ });
350
+
351
+ test('sync setup completion-command errors exit nonzero', t => {
352
+ const result = runSync(t, {
353
+ setup: ['plugins'],
354
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
355
+ args: { down_cmd: `${JSON.stringify(process.execPath)} -e "process.exit(2)"` }
356
+ });
357
+
358
+ assert.equal(result.status, 1);
359
+ assert.match(result.report.errors[0], /Command failed/);
360
+ });
361
+
362
+ test('sync setup new dry run discovers additions without writing them', t => {
363
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
364
+ const result = runSync(t, {
365
+ setup: ['plugins'],
366
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
367
+ existingSources: [{ path: 'Existing-Renamed.json', type: 'plugin', data: existing }],
368
+ args: { new: true, dry: true, file_props: 'script' }
369
+ });
370
+
371
+ assert.equal(result.status, 0);
372
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
373
+ });
374
+
375
+ test('sync setup new preserves existing Events and writes new workflows separately', t => {
376
+ const category = { id: 'operations', title: 'Operations Team' };
377
+ const existing = { id: 'existing-event', title: 'Existing Event', type: 'event', category: category.id };
378
+ const workflow = { id: 'new-workflow', title: 'New Workflow', type: 'workflow', category: category.id };
379
+ const result = runSync(t, {
380
+ setup: ['events'],
381
+ categories: [category],
382
+ events: [existing, workflow],
383
+ existingSources: [{ path: 'custom/Event.json', type: 'event', data: existing }],
384
+ args: { new: true }
385
+ });
386
+
387
+ assert.equal(result.status, 0);
388
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events')), false);
389
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Operations-Team', 'New-Workflow.json')), true);
390
+ });
391
+
392
+ test('sync setup groups Events and workflows into Category folders', t => {
393
+ const operations = { id: 'operations', title: 'Operations Team' };
394
+ const limited = { id: 'limited', title: 'Limited Run' };
395
+ const engineering = { id: 'engineering', title: 'Engineering & QA' };
396
+ const result = runSync(t, {
397
+ setup: ['events'],
398
+ categories: [operations, limited, engineering],
399
+ events: [
400
+ { id: 'nightly', title: 'Nightly Backup', type: 'event', category: operations.id, params: { script: '#!/bin/sh\necho backup\n' } },
401
+ { id: 'limited', title: 'Limited Event', type: 'event', category: limited.id },
402
+ { id: 'release', title: 'Release Pipeline', type: 'workflow', category: engineering.id },
403
+ { id: 'ondemand', title: 'On Demand Backup', type: 'event', category: operations.id }
404
+ ],
405
+ args: { file_props: 'params.script' },
406
+ quiet: false
407
+ });
408
+
409
+ assert.equal(result.status, 0);
410
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'Nightly-Backup.json')), true);
411
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'Nightly-Backup-params.script.sh')), true);
412
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Operations-Team', 'On-Demand-Backup.json')), true);
413
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Limited-Run', 'Limited-Event.json')), true);
414
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Engineering-QA', 'Release-Pipeline.json')), true);
415
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events', 'Nightly-Backup.json')), false);
416
+ assert.equal(fs.existsSync(Path.join(result.dir, 'workflows', 'Release-Pipeline.json')), false);
417
+
418
+ // The source order deliberately returns to Operations after Limited Run.
419
+ // Its two Events must still print beneath one folder heading.
420
+ const operationsHeader = result.stdout.indexOf('events/Operations-Team/');
421
+ const nightlyFile = result.stdout.indexOf('Nightly-Backup.json');
422
+ const onDemandFile = result.stdout.indexOf('On-Demand-Backup.json');
423
+ const limitedHeader = result.stdout.indexOf('events/Limited-Run/');
424
+ const limitedFile = result.stdout.indexOf('Limited-Event.json');
425
+ const workflowHeader = result.stdout.indexOf('workflows/Engineering-QA/');
426
+ assert.ok(operationsHeader < nightlyFile);
427
+ assert.ok(nightlyFile < onDemandFile);
428
+ assert.ok(onDemandFile < limitedHeader);
429
+ assert.ok(limitedHeader < limitedFile);
430
+ assert.ok(limitedFile < workflowHeader);
431
+ assert.equal(result.stdout.indexOf('events/Operations-Team/', operationsHeader + 1), -1);
432
+ });
433
+
434
+ test('sync setup validates all Event Categories before writing any files', t => {
435
+ const result = runSync(t, {
436
+ setup: ['plugins', 'events'],
437
+ plugins: [{ id: 'plugin', title: 'Plugin', script: 'Hello\n' }],
438
+ events: [{ id: 'orphan', title: 'Orphan Event', type: 'event', category: 'missing' }],
439
+ args: {}
440
+ });
441
+
442
+ assert.equal(result.status, 1);
443
+ assert.match(result.stderr, /Cannot find Category for Event: Orphan Event \(missing\)/);
444
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
445
+ assert.equal(fs.existsSync(Path.join(result.dir, 'events')), false);
446
+ });
447
+
448
+ test('sync setup new rejects overwrite mode and malformed values before writing', t => {
449
+ for (const args of [
450
+ { new: true, force: true },
451
+ { new: 'yes' }
452
+ ]) {
453
+ const result = runSync(t, {
454
+ setup: ['plugins'],
455
+ plugins: [{ id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
456
+ args
457
+ });
458
+
459
+ assert.equal(result.status, 1);
460
+ assert.match(result.stderr, /--new/);
461
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
462
+ }
463
+ });
464
+
465
+ test('sync setup new aborts on duplicate or malformed existing sources before writing', t => {
466
+ const existing = { id: 'existing', title: 'Existing', command: 'node', script: 'Existing\n' };
467
+ for (const existingSources of [
468
+ [
469
+ { path: 'one.json', type: 'plugin', data: existing },
470
+ { path: 'nested/two.json', type: 'plugin', data: existing }
471
+ ],
472
+ [{ path: 'broken.json', contents: '{ nope' }],
473
+ [{ path: 'orphan.json', type: 'plugin', data: { id: 'missing', title: 'Missing' } }]
474
+ ]) {
475
+ const result = runSync(t, {
476
+ setup: ['plugins'],
477
+ plugins: [existing, { id: 'added', title: 'Added', command: 'node', script: 'Added\n' }],
478
+ existingSources,
479
+ args: { new: true }
480
+ });
481
+
482
+ assert.equal(result.status, 1);
483
+ assert.match(result.stderr, /Duplicate source item|Failed to parse JSON file|Cannot find plugin in xyOps/);
484
+ assert.equal(fs.existsSync(Path.join(result.dir, 'plugins')), false);
485
+ }
486
+ });
487
+
220
488
  test('sync skips the entire delete pass after an update or state API failure', t => {
221
489
  const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
222
490
  const orphan = { id: 'orphan', title: 'Orphan', notes: 'Delete candidate' };
package/test/sync.test.js CHANGED
@@ -171,8 +171,9 @@ test('sync', async t => {
171
171
  const categoryExport = Path.join(temp, 'categories', slugify(categoryTitle) + '.json');
172
172
  const pluginExport = Path.join(temp, 'plugins', slugify(pluginTitle) + '.json');
173
173
  const pluginScriptExport = Path.join(temp, 'plugins', slugify(pluginTitle) + '-script.js');
174
- const eventExport = Path.join(temp, 'events', slugify(eventTitle) + '.json');
175
- const eventScriptExport = Path.join(temp, 'events', slugify(eventTitle) + '-params.script.sh');
174
+ const eventDir = Path.join(temp, 'events', slugify(categoryTitle));
175
+ const eventExport = Path.join(eventDir, slugify(eventTitle) + '.json');
176
+ const eventScriptExport = Path.join(eventDir, slugify(eventTitle) + '-params.script.sh');
176
177
 
177
178
  await check('setup writes valid XYPDF and external property files', () => {
178
179
  const category = readXYPDF(categoryExport);
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Build xyCLI CHANGELOG.md file
4
+
5
+ var fs = require('fs');
6
+ var Path = require('path');
7
+ var cp = require('child_process');
8
+ var Tools = require('pixl-tools');
9
+
10
+ var debug = (process.argv[2] == 'debug');
11
+
12
+ // make sure we're in the correct dir
13
+ process.chdir( Path.dirname( __dirname ) );
14
+
15
+ // make sure git sandbox is clean
16
+ var porcelain = cp.execSync('git status --porcelain', { encoding: 'utf8' }).trim();
17
+ if (!debug && porcelain.length) {
18
+ console.error("\nERROR: Git sandbox has local changes. Please commit these before updating the changelog.\n");
19
+ process.exit(1);
20
+ }
21
+
22
+ // get list of tags
23
+ var tags = cp.execSync('git tag --list --sort=version:refname', { encoding: 'utf8' }).trim().split(/\n/).reverse();
24
+
25
+ var md = '';
26
+ md += "# xyCLI Changelog\n";
27
+
28
+ var last_tag = tags.pop();
29
+ if (!tags.length) {
30
+ console.error("\nERROR: No tags found after popping first one. Please push another tag before running changelog.\n");
31
+ process.exit(1);
32
+ }
33
+
34
+ tags.forEach( function(tag, idx) {
35
+ var prev_tag = tags[idx + 1] || last_tag;
36
+ md += `\n## Version ${tag}\n\n`;
37
+
38
+ var cmd = `git log ${prev_tag}..${tag} --no-merges --pretty=format:'%h %H %ad %s%n%n%b%n----PX----' --date=short`;
39
+ var output = cp.execSync(cmd, { encoding: 'utf8' }).trim();
40
+ var commits = output.split('----PX----');
41
+ var first = true;
42
+
43
+ // 029a96a 029a96aebd721fe565b1b5c8f2b661564c9017f3 2025-12-30 Version 0.9.2
44
+ commits.forEach( function(chunk) {
45
+ var lines = chunk.trim().split(/\n/);
46
+ var line = lines.shift();
47
+
48
+ var matches = line.trim().match(/^(\w+)\s+(\w+)\s+([\d\-]+)\s+(.+)$/);
49
+ if (!matches) return;
50
+ if (matches[4].match(/\b(CHANGELOG)\b/)) return;
51
+
52
+ var url = 'https://github.com/pixlcore/xycli/commit/' + matches[2];
53
+
54
+ if (first) {
55
+ md += `> ` + Tools.formatDate( matches[3] + ' 00:00:00', '[mmmm] [mday], [yyyy]' ) + "\n\n";
56
+ first = false;
57
+ }
58
+
59
+ md += `- [\`${matches[1]}\`](${url}): ` + matches[4] + "\n";
60
+
61
+ if (matches[4].match(/^Version/)) lines.forEach( function(extra) {
62
+ if (!extra.match(/\S/)) return;
63
+ extra = extra.trim();
64
+ if (!extra.match(/^\-/)) extra = '- ' + extra;
65
+ md += "\t" + extra + "\n";
66
+ } );
67
+ } );
68
+ } );
69
+
70
+ md += `\n## Version ${last_tag}\n\n> September 20, 2026\n\n- Initial release!\n`;
71
+
72
+ if (debug) {
73
+ console.log(md);
74
+ process.exit(0);
75
+ }
76
+
77
+ fs.writeFileSync( 'CHANGELOG.md', md );
78
+
79
+ // make sure log has actually changed
80
+ porcelain = cp.execSync('git status --porcelain', { encoding: 'utf8' }).trim();
81
+ if (!porcelain.length) {
82
+ console.error("\nWarning: Changelog has not changed since last run. Skipping actions.\n");
83
+ process.exit(1);
84
+ }
85
+
86
+ cp.execSync('git add CHANGELOG.md && git commit -m "Update CHANGELOG" && git push', { stdio: 'inherit' } );