@pixlcore/xycli 1.0.8 → 1.1.0
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 +6 -3
- package/docs/help.md +21 -7
- package/docs/sync.md +85 -21
- package/docs/two-way-delete-tutorial.md +259 -0
- package/docs/two-way-git-tutorial.md +13 -7
- package/index.js +22 -1
- package/lib/apikey.js +1 -1
- package/lib/marketplace.js +1 -1
- package/lib/sync-buckets.js +35 -1
- package/lib/sync-syshook.js +207 -0
- package/lib/sync.js +161 -26
- package/lib/transfer.js +3 -3
- package/lib/utils.js +6 -2
- package/package.json +2 -2
- package/test/README.md +3 -3
- package/test/help.test.js +56 -0
- package/test/sync-buckets-unit.test.js +31 -0
- package/test/sync-buckets.test.js +36 -0
- package/test/sync-syshook-unit.test.js +231 -0
- package/test/sync-unit.test.js +215 -7
- package/test/sync.test.js +42 -0
- package/test/tickets.test.js +4 -1
- package/test/toast.test.js +3 -3
|
@@ -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
|
}
|
|
@@ -71,10 +76,10 @@ module.exports = {
|
|
|
71
76
|
if (sconfig.delete && !Tools.includesAll(supported_lists, sconfig.delete)) this.die("Invalid sync.delete list: " + sconfig.delete.join(', '));
|
|
72
77
|
if (sconfig.delete && !sconfig.delete.length) sconfig.delete = false;
|
|
73
78
|
|
|
74
|
-
// Deletion
|
|
75
|
-
//
|
|
76
|
-
if (sconfig.delete && (
|
|
77
|
-
this.die("Delete mode requires
|
|
79
|
+
// Deletion follows the sole enabled direction. Validate settings inherited
|
|
80
|
+
// from config as well as command-line args before scanning or writing.
|
|
81
|
+
if (sconfig.delete && (!!sconfig.up === !!sconfig.down)) {
|
|
82
|
+
this.die("Delete mode requires exactly one sync direction: --up or --down.");
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
// Bucket content needs the replacement API and data timestamps introduced
|
|
@@ -137,6 +142,8 @@ module.exports = {
|
|
|
137
142
|
|
|
138
143
|
// load all xypdf files
|
|
139
144
|
var items = [];
|
|
145
|
+
var local_delete_items = [];
|
|
146
|
+
var seen_sources = new Map();
|
|
140
147
|
|
|
141
148
|
files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
|
|
142
149
|
// file: { path, size, mtime }
|
|
@@ -211,16 +218,14 @@ module.exports = {
|
|
|
211
218
|
return;
|
|
212
219
|
}
|
|
213
220
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (Tools.findObjectDeep(items, { 'type': item.type, 'data.id': item.data.id })) {
|
|
221
|
+
// Check duplicate identities even when their remote definition is
|
|
222
|
+
// missing, so --ignore_missing only relaxes the missing-ID check.
|
|
223
|
+
var source_key = item.type + '\0' + item.data.id;
|
|
224
|
+
if (seen_sources.has(source_key)) {
|
|
221
225
|
this.logSyncWarning("Duplicate source item found: " + file.path + " (skipping)");
|
|
222
226
|
return;
|
|
223
227
|
}
|
|
228
|
+
seen_sources.set(source_key, file.path);
|
|
224
229
|
|
|
225
230
|
// locate any external neighbor prop files
|
|
226
231
|
var neighbor_error = false;
|
|
@@ -282,19 +287,38 @@ module.exports = {
|
|
|
282
287
|
// include neighbor metadata for downsync
|
|
283
288
|
if (!item.neighbors) item.neighbors = {};
|
|
284
289
|
item.neighbors[ prop_path ] = neighbor.path;
|
|
290
|
+
if (!item.delete_neighbors) item.delete_neighbors = [];
|
|
291
|
+
item.delete_neighbors.push(neighbor.path);
|
|
285
292
|
} ); // foreach neighbor
|
|
286
293
|
|
|
287
294
|
// do not include item if neighbor loading or validation failed
|
|
288
295
|
if (neighbor_error) return;
|
|
289
296
|
|
|
290
|
-
// include effective mtime in item for two-way sync
|
|
291
|
-
item.mtime = file.mtime;
|
|
292
|
-
|
|
293
|
-
// include list name for convenience below
|
|
294
297
|
item.list = type_def.list;
|
|
295
|
-
|
|
296
|
-
// include file for downsync
|
|
297
298
|
item.file = file.path;
|
|
299
|
+
var xy_item = Tools.findObject(list, { id: item.data.id });
|
|
300
|
+
if (!xy_item) {
|
|
301
|
+
// In a down-only delete run, xyOps owns this definition type.
|
|
302
|
+
// Queue its local files for the final destructive pass, after every
|
|
303
|
+
// source and neighbor has passed the normal scan validation.
|
|
304
|
+
if (sconfig.delete && sconfig.down && sconfig.delete.includes(item.list)) {
|
|
305
|
+
local_delete_items.push(item);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// An opted-in Git sync can briefly retain a source after its xyOps
|
|
310
|
+
// definition is deleted. Skip only that source and keep scanning.
|
|
311
|
+
if (ignore_missing) {
|
|
312
|
+
println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// include effective mtime in item for two-way sync
|
|
321
|
+
item.mtime = file.mtime;
|
|
298
322
|
|
|
299
323
|
// add to sync list
|
|
300
324
|
items.push(item);
|
|
@@ -396,7 +420,12 @@ module.exports = {
|
|
|
396
420
|
// now handle deletes
|
|
397
421
|
if (!sconfig.delete) return await this.completeSync();
|
|
398
422
|
|
|
399
|
-
|
|
423
|
+
if (sconfig.down) {
|
|
424
|
+
this.deleteSyncLocalItems(local_delete_items);
|
|
425
|
+
return await this.completeSync();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Up-only deletion removes remote definitions absent from the local tree.
|
|
400
429
|
var list_data_types = [];
|
|
401
430
|
for (var id in this.config.ui.data_types) {
|
|
402
431
|
var type_def = this.config.ui.data_types[id];
|
|
@@ -433,13 +462,105 @@ module.exports = {
|
|
|
433
462
|
await this.completeSync();
|
|
434
463
|
},
|
|
435
464
|
|
|
465
|
+
deleteSyncLocalItems(items) {
|
|
466
|
+
// Validate every selected local deletion before removing any files. The
|
|
467
|
+
// normal source scan has already checked XYPDF identity and neighboring
|
|
468
|
+
// property paths; recheck the source and file types in case a checkout or
|
|
469
|
+
// another process changed the tree while this sync was running.
|
|
470
|
+
var plans = [];
|
|
471
|
+
for (const item of items) {
|
|
472
|
+
// Match the protection used by upward deletion for bundled or
|
|
473
|
+
// Marketplace definitions. A local source may retain these markers
|
|
474
|
+
// even after its remote record has disappeared.
|
|
475
|
+
if (Object.hasOwn(item.data, 'stock') || Object.hasOwn(item.data, 'marketplace')) continue;
|
|
476
|
+
|
|
477
|
+
var bucket_plan = null;
|
|
478
|
+
try {
|
|
479
|
+
if (item.type === 'bucket') bucket_plan = this.getSyncBucketLocalDeletePlan(item);
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
this.logSyncError("Local delete preflight failed: " + err.message);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
var targets = [...new Set(item.delete_neighbors || []), ...(bucket_plan ? bucket_plan.files : []), item.file];
|
|
486
|
+
var dirs = bucket_plan ? bucket_plan.dirs : [];
|
|
487
|
+
try {
|
|
488
|
+
for (const file of targets) {
|
|
489
|
+
var stat = fs.lstatSync(file);
|
|
490
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Local deletion target is not a regular file: " + file);
|
|
491
|
+
}
|
|
492
|
+
for (const dir of dirs) {
|
|
493
|
+
var stat = fs.lstatSync(dir);
|
|
494
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Local deletion target is not a regular directory: " + dir);
|
|
495
|
+
}
|
|
496
|
+
var source = Tools.parseJSON(fs.readFileSync(item.file, 'utf8'));
|
|
497
|
+
var source_item = source && Array.isArray(source.items) && (source.items.length === 1) ? source.items[0] : null;
|
|
498
|
+
if (!source || (source.type !== 'xypdf') || (source.version !== '1.0') || !source_item ||
|
|
499
|
+
(source_item.type !== item.type) || !source_item.data || (source_item.data.id !== item.data.id)) {
|
|
500
|
+
throw new Error("Local XYPDF source changed during sync: " + item.file);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
this.logSyncError("Local delete preflight failed: " + err.message);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
plans.push({ item, targets, dirs });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const plan of plans) {
|
|
511
|
+
println( "\n " + cli.emoji('🗑️') + ' ' + red.bold(this.dry ? "Would delete local " : "Deleting local ") +
|
|
512
|
+
red.bold(plan.item.type) + ": " + red.bold(plan.item.data.title || plan.item.data.id) +
|
|
513
|
+
gray(" (" + plan.item.data.id + ")") );
|
|
514
|
+
// Keep the XYPDF JSON source last so a partial filesystem failure
|
|
515
|
+
// leaves the identity available for a later retry.
|
|
516
|
+
for (const file of plan.targets.slice(0, -1)) {
|
|
517
|
+
println(" " + file);
|
|
518
|
+
if (this.dry) continue;
|
|
519
|
+
try {
|
|
520
|
+
fs.unlinkSync(file);
|
|
521
|
+
this.downSynced = true;
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
this.logSyncError("Failed to delete local file: " + file + ": " + err.message);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const dir of plan.dirs) {
|
|
529
|
+
println(" " + dir + Path.sep);
|
|
530
|
+
if (this.dry) continue;
|
|
531
|
+
try {
|
|
532
|
+
fs.rmdirSync(dir);
|
|
533
|
+
this.downSynced = true;
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
this.logSyncError("Failed to delete local directory: " + dir + ": " + err.message);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
var source_file = plan.targets[plan.targets.length - 1];
|
|
541
|
+
println(" " + source_file);
|
|
542
|
+
if (this.dry) continue;
|
|
543
|
+
try {
|
|
544
|
+
fs.unlinkSync(source_file);
|
|
545
|
+
this.downSynced = true;
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
this.logSyncError("Failed to delete local file: " + source_file + ": " + err.message);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
|
|
436
554
|
getSyncLockFile() {
|
|
437
555
|
// Allow a fixed path for service installations. Otherwise, serialize all
|
|
438
|
-
// local syncs targeting the exact same configured xyOps base URL.
|
|
556
|
+
// local syncs targeting the exact same configured xyOps base URL. Local
|
|
557
|
+
// System Hook deletions need no server config, so share a fixed lock key
|
|
558
|
+
// when no base URL is available.
|
|
439
559
|
var sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
440
560
|
if (sconfig.lock_file) return Path.resolve('' + sconfig.lock_file);
|
|
441
561
|
|
|
442
|
-
var
|
|
562
|
+
var base_url = this.config.base_url || (('syshook' in this.args) ? 'syshook-local' : '');
|
|
563
|
+
var digest = Tools.digestHex( base_url, 'sha256', 16);
|
|
443
564
|
return Path.join( os.tmpdir(), 'xyops-cli-sync-' + digest + '.pid' );
|
|
444
565
|
},
|
|
445
566
|
|
|
@@ -824,8 +945,9 @@ module.exports = {
|
|
|
824
945
|
}
|
|
825
946
|
if (new_only && this.args.force) this.die("Sync setup --new cannot be combined with --force.");
|
|
826
947
|
|
|
948
|
+
var ignore_missing = !!this.args.ignore_missing;
|
|
827
949
|
var default_ext = this.normalizeSyncDefaultExt(this.args.default_ext);
|
|
828
|
-
var existing_keys = new_only ? this.scanSyncSourceIndex(['.']) : {};
|
|
950
|
+
var existing_keys = new_only ? this.scanSyncSourceIndex(['.'], { ignore_missing }) : {};
|
|
829
951
|
|
|
830
952
|
this.printBoxList({
|
|
831
953
|
title: 'Sync Setup',
|
|
@@ -889,7 +1011,7 @@ module.exports = {
|
|
|
889
1011
|
return ext;
|
|
890
1012
|
},
|
|
891
1013
|
|
|
892
|
-
scanSyncSourceIndex(base_dirs) {
|
|
1014
|
+
scanSyncSourceIndex(base_dirs, opts = {}) {
|
|
893
1015
|
// Build an identity index from an existing sync tree. Filenames and folders
|
|
894
1016
|
// are deliberately ignored, because sync sources may be freely reorganized.
|
|
895
1017
|
var files = [];
|
|
@@ -900,6 +1022,7 @@ module.exports = {
|
|
|
900
1022
|
});
|
|
901
1023
|
|
|
902
1024
|
var index = {};
|
|
1025
|
+
var seen_sources = new Map();
|
|
903
1026
|
var bucket_extra_dirs = [];
|
|
904
1027
|
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
905
1028
|
try {
|
|
@@ -941,14 +1064,26 @@ module.exports = {
|
|
|
941
1064
|
if (!type_def) this.die("Cannot locate internal type definition: " + item.type);
|
|
942
1065
|
var list = this[type_def.list];
|
|
943
1066
|
if (!list) this.die("Cannot locate internal list: " + type_def.list);
|
|
1067
|
+
|
|
1068
|
+
// Keep duplicate detection independent of remote existence, including
|
|
1069
|
+
// for sources intentionally skipped with --ignore_missing.
|
|
1070
|
+
var key = item.type + '-' + item.data.id;
|
|
1071
|
+
if (seen_sources.has(key)) {
|
|
1072
|
+
this.die("Duplicate source item found: " + file.path + " (already found in " + seen_sources.get(key) + ")");
|
|
1073
|
+
}
|
|
1074
|
+
seen_sources.set(key, file.path);
|
|
1075
|
+
|
|
944
1076
|
if (!Tools.findObject(list, { id: item.data.id })) {
|
|
1077
|
+
// New-only setup needs the same grace period as ordinary sync.
|
|
1078
|
+
// A missing local source does not represent a new remote item.
|
|
1079
|
+
if (opts.ignore_missing) {
|
|
1080
|
+
println(" Skipping local " + item.type + " missing from xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
945
1084
|
this.die("Cannot find " + item.type + " in xyOps: " + (item.data.title || item.data.id) + " (" + item.data.id + ")");
|
|
946
1085
|
}
|
|
947
1086
|
|
|
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
1087
|
index[key] = { file: file.path, item: item };
|
|
953
1088
|
});
|
|
954
1089
|
|
package/lib/transfer.js
CHANGED
|
@@ -86,7 +86,7 @@ module.exports = {
|
|
|
86
86
|
if (this.format.match(/json/)) return this.jsonOutput({ code: 0, file: filename, count: items.length, warnings: warnings });
|
|
87
87
|
|
|
88
88
|
this.toast('✅', 'green', "Exported " + items.length + " item(s) to: " + filename);
|
|
89
|
-
warnings.forEach( warning =>
|
|
89
|
+
warnings.forEach( warning => warnln(yellow(warning)) );
|
|
90
90
|
},
|
|
91
91
|
|
|
92
92
|
parseExportDependencies(value) {
|
|
@@ -316,7 +316,7 @@ module.exports = {
|
|
|
316
316
|
|
|
317
317
|
if (this.format.match(/json/)) return this.jsonOutput(report);
|
|
318
318
|
|
|
319
|
-
warnings.forEach( warning =>
|
|
319
|
+
warnings.forEach( warning => warnln(yellow(warning)) );
|
|
320
320
|
|
|
321
321
|
this.printBoxTable({
|
|
322
322
|
title: 'Import Results',
|
|
@@ -324,7 +324,7 @@ module.exports = {
|
|
|
324
324
|
rows: results.map( item => [item.type, item.id || '(New)', item.title, item.status] )
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
if (error)
|
|
327
|
+
if (error) warnln("\n " + red('Import stopped: ' + error) + '\n Earlier successful changes remain in place.');
|
|
328
328
|
else this.printSuggestedCommands({
|
|
329
329
|
"List events": "xy events",
|
|
330
330
|
"List categories": "xy categories",
|
package/lib/utils.js
CHANGED
|
@@ -1011,7 +1011,7 @@ module.exports = {
|
|
|
1011
1011
|
// load client config from xyops and cache for a while
|
|
1012
1012
|
var { err, data } = await this.cacheAPI('config', { format: 'json' }, { cache_ttl: 3600 });
|
|
1013
1013
|
if (err) {
|
|
1014
|
-
|
|
1014
|
+
warnln( "\n" + bold.red("Failed to contact xyOps at: ") + this.config.base_url );
|
|
1015
1015
|
this.die(err);
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
@@ -1265,7 +1265,11 @@ module.exports = {
|
|
|
1265
1265
|
// cli.box performs word wrapping at this content width and never ellipsizes.
|
|
1266
1266
|
var available_width = Math.max( 1, (cli.width() || 80) - 7 );
|
|
1267
1267
|
var max_width = Math.min( available_width, cli.stringWidth(final_text) );
|
|
1268
|
-
|
|
1268
|
+
var message = "\n" + cli.box(final_text, { width: max_width, hspace: 2, vspace: 1, indent: 1, styles: [this.color(color)] });
|
|
1269
|
+
|
|
1270
|
+
// Keep warning and error toasts visible when routine output is quiet.
|
|
1271
|
+
if (['⚠️', '🛑', '❌', '☢️'].includes(emoji)) warnln(message);
|
|
1272
|
+
else println(message);
|
|
1269
1273
|
},
|
|
1270
1274
|
|
|
1271
1275
|
printMutationSummary(opts) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixlcore/xycli",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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/README.md
CHANGED
|
@@ -66,10 +66,10 @@ node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.te
|
|
|
66
66
|
| `secrets` | Vault metadata, assignments, redacted previews, full field replacement, confirmed decryption, deletion, validation, help |
|
|
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
|
-
| `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and
|
|
70
|
-
| `sync-buckets` | Disposable Bucket setup, JSON replacement, file upload and download,
|
|
69
|
+
| `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, up-only deletion, and down-only Event and Plugin file deletion |
|
|
70
|
+
| `sync-buckets` | Disposable Bucket setup, JSON replacement, file upload and download, timestamp alignment, and down-only local file and whole-Bucket deletion on xyOps 1.1.2 or later |
|
|
71
71
|
| `sync-buckets-unit` | Offline Bucket version gating, content setup, upload and download, timestamp alignment, and pre-delete safety |
|
|
72
|
-
| `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery,
|
|
72
|
+
| `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, one-way 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 |
|
|
73
73
|
| `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
|
|
74
74
|
| `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
|
|
75
75
|
| `toast` | Warning-box word wrapping and terminal-width limits |
|
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
|
+
});
|