@pixlcore/xycli 1.0.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 +543 -0
- package/docs/help.md +2587 -0
- package/docs/screenshots.md +219 -0
- package/docs/sync.md +920 -0
- package/docs/tutorial.md +204 -0
- package/index.js +444 -0
- package/lib/alerts.js +735 -0
- package/lib/apikey.js +425 -0
- package/lib/buckets.js +607 -0
- package/lib/categories.js +323 -0
- package/lib/channels.js +290 -0
- package/lib/config.js +86 -0
- package/lib/dashboard.js +237 -0
- package/lib/doc.js +131 -0
- package/lib/events.js +1314 -0
- package/lib/groups.js +1038 -0
- package/lib/jobs.js +1254 -0
- package/lib/log.js +125 -0
- package/lib/marketplace.js +386 -0
- package/lib/monitors.js +412 -0
- package/lib/plugins.js +491 -0
- package/lib/secrets.js +441 -0
- package/lib/servers.js +1502 -0
- package/lib/snapshots.js +498 -0
- package/lib/sync.js +963 -0
- package/lib/system.js +612 -0
- package/lib/tags.js +250 -0
- package/lib/tickets.js +681 -0
- package/lib/transfer.js +447 -0
- package/lib/utils.js +2745 -0
- package/lib/webhook.js +414 -0
- package/logo.webp +0 -0
- package/package.json +38 -0
- package/test/README.md +97 -0
- package/test/api-validation.test.js +170 -0
- package/test/categories.test.js +246 -0
- package/test/channels.test.js +209 -0
- package/test/config.test.js +96 -0
- package/test/doc.test.js +84 -0
- package/test/groups.test.js +211 -0
- package/test/help.test.js +26 -0
- package/test/helpers/common.js +127 -0
- package/test/helpers/pagination-preload.cjs +18 -0
- package/test/log.test.js +103 -0
- package/test/markdown.test.js +61 -0
- package/test/marketplace.test.js +229 -0
- package/test/monitors.test.js +227 -0
- package/test/pagination.test.js +138 -0
- package/test/plugins.test.js +351 -0
- package/test/secrets.test.js +271 -0
- package/test/servers.test.js +303 -0
- package/test/snapshots.test.js +167 -0
- package/test/sync-unit.test.js +446 -0
- package/test/sync.test.js +542 -0
- package/test/system.test.js +94 -0
- package/test/tags.test.js +230 -0
- package/test/tickets.test.js +261 -0
- package/test/toast.test.js +73 -0
- package/test/transfer-apikey.test.js +96 -0
- package/test/transfer-workflow.test.js +122 -0
- package/test/transfer.test.js +314 -0
- package/test/webhook.test.js +332 -0
- package/test.js +49 -0
package/lib/sync.js
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
// Sync Layer
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const Path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const cp = require('child_process');
|
|
7
|
+
const Diff = require('diff');
|
|
8
|
+
const cli = require('pixl-cli');
|
|
9
|
+
const Tools = cli.Tools;
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
|
|
13
|
+
async cmd_sync() {
|
|
14
|
+
// Serialize every sync operation for this xyOps instance on this host.
|
|
15
|
+
// Keep the lock through completion commands and notifications, and always
|
|
16
|
+
// release it after the command settles. The process exit handler covers
|
|
17
|
+
// fatal errors and signals that call process.exit() before finally runs.
|
|
18
|
+
this.acquireSyncLock();
|
|
19
|
+
try {
|
|
20
|
+
return await this.runSyncCommand();
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
this.releaseSyncLock();
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async runSyncCommand() {
|
|
28
|
+
// sync a filesystem of XYPDF files to xyOps resources
|
|
29
|
+
// xy sync --up all --dry
|
|
30
|
+
// xy sync --up "events,plugins" --delete plugins
|
|
31
|
+
await this.getMultiple();
|
|
32
|
+
|
|
33
|
+
// optionally setup local filesystem for sync
|
|
34
|
+
if (this.args.other && (this.args.other[0] === 'setup')) {
|
|
35
|
+
this.args.other.shift();
|
|
36
|
+
this.args.setup = this.args.other;
|
|
37
|
+
}
|
|
38
|
+
if (this.args.setup) return await this.setupSync();
|
|
39
|
+
|
|
40
|
+
println( "\n " + cli.emoji('🔄') + ' ' + this.color('theme').bold("XYOPS SYNC ENGINE v1") );
|
|
41
|
+
|
|
42
|
+
var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
|
|
43
|
+
|
|
44
|
+
// allow sync config in config files and/or cli args
|
|
45
|
+
var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
46
|
+
if (!sconfig.up && !sconfig.down) {
|
|
47
|
+
this.die("Please enable sync up and/or down modes. Type 'xy help sync' for details.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// massage up, down, and delete sync lists
|
|
51
|
+
if ((sconfig.up === true) || (sconfig.up === "all")) sconfig.up = [ ...supported_lists ];
|
|
52
|
+
if (sconfig.up && (typeof(sconfig.up) == 'string')) sconfig.up = sconfig.up.split(/\,/);
|
|
53
|
+
if (sconfig.up && !Array.isArray(sconfig.up)) this.die("Invalid sync.up property (must be CSV string, array or true)");
|
|
54
|
+
if (sconfig.up && !Tools.includesAll(supported_lists, sconfig.up)) this.die("Invalid sync.up list: " + sconfig.up.join(', '));
|
|
55
|
+
if (sconfig.up && !sconfig.up.length) sconfig.up = false;
|
|
56
|
+
|
|
57
|
+
if ((sconfig.down === true) || (sconfig.down === "all")) sconfig.down = [ ...supported_lists ];
|
|
58
|
+
if (sconfig.down && (typeof(sconfig.down) == 'string')) sconfig.down = sconfig.down.split(/\,/);
|
|
59
|
+
if (sconfig.down && !Array.isArray(sconfig.down)) this.die("Invalid sync.down property (must be CSV string, array or true)");
|
|
60
|
+
if (sconfig.down && !Tools.includesAll(supported_lists, sconfig.down)) this.die("Invalid sync.down list: " + sconfig.down.join(', '));
|
|
61
|
+
if (sconfig.down && !sconfig.down.length) sconfig.down = false;
|
|
62
|
+
|
|
63
|
+
if ((sconfig.delete === true) || (sconfig.delete === "all")) sconfig.delete = [ ...supported_lists ];
|
|
64
|
+
if (sconfig.delete && (typeof(sconfig.delete) == 'string')) sconfig.delete = sconfig.delete.split(/\,/);
|
|
65
|
+
if (sconfig.delete && !Array.isArray(sconfig.delete)) this.die("Invalid sync.delete property (must be CSV string, array or true)");
|
|
66
|
+
if (sconfig.delete && !Tools.includesAll(supported_lists, sconfig.delete)) this.die("Invalid sync.delete list: " + sconfig.delete.join(', '));
|
|
67
|
+
if (sconfig.delete && !sconfig.delete.length) sconfig.delete = false;
|
|
68
|
+
|
|
69
|
+
// Deletion requires up-only sync, including directions inherited from config.
|
|
70
|
+
// Validate before scanning or applying any changes, even for a dry run.
|
|
71
|
+
if (sconfig.delete && (!sconfig.up || sconfig.down)) {
|
|
72
|
+
this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.warnings = [];
|
|
76
|
+
this.errors = [];
|
|
77
|
+
|
|
78
|
+
// scan all base directories, default to cwd
|
|
79
|
+
var files = [];
|
|
80
|
+
if (!this.args.other || !this.args.other.length) {
|
|
81
|
+
this.args.other = sconfig.base_dirs || ['.'];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
println( "\n " + green.bold("Scanning base directories...") );
|
|
85
|
+
|
|
86
|
+
this.args.other.forEach( dir => {
|
|
87
|
+
dir = Path.normalize(dir).replace(/[\\/]+$/, '');
|
|
88
|
+
if (!fs.existsSync(dir)) {
|
|
89
|
+
this.logSyncError("Sync base dir does not exist: " + dir);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
println( " " + cli.emoji('🔎') + " Scanning directory: " + dir + "/" );
|
|
93
|
+
files = files.concat( Tools.findFilesSync(dir, { stats: true }) );
|
|
94
|
+
} );
|
|
95
|
+
|
|
96
|
+
// exit now if any dir errors occurred
|
|
97
|
+
if (this.errors.length) return await this.completeSync();
|
|
98
|
+
|
|
99
|
+
println( " " + Tools.commify(files.length) + " files found." );
|
|
100
|
+
|
|
101
|
+
// load all xypdf files
|
|
102
|
+
var items = [];
|
|
103
|
+
|
|
104
|
+
files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
|
|
105
|
+
// file: { path, size, mtime }
|
|
106
|
+
var dir = Path.dirname(file.path);
|
|
107
|
+
var filename = Path.basename(file.path);
|
|
108
|
+
var stem = filename.replace(/\.\w+$/, '');
|
|
109
|
+
|
|
110
|
+
// try to parse JSON
|
|
111
|
+
var xypdf = null;
|
|
112
|
+
try {
|
|
113
|
+
xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') );
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
this.logSyncWarning("Failed to parse JSON file: " + file.path + ": " + err);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// make sure we have a real xypdf file here
|
|
121
|
+
// skip silently if file is clearly not xypdf
|
|
122
|
+
if (!xypdf || !Tools.isaHash(xypdf) || !xypdf.type || (xypdf.type !== 'xypdf')) return;
|
|
123
|
+
|
|
124
|
+
if (!xypdf.version || (xypdf.version !== '1.0')) {
|
|
125
|
+
this.logSyncWarning("File is not a compatible version of XYPDF: " + file.path);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!xypdf.items || !Array.isArray(xypdf.items) || !xypdf.items.length) {
|
|
129
|
+
this.logSyncWarning("XYPDF file has an empty, malformed or missing items array: " + file.path);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// xyops minimum version
|
|
134
|
+
if (xypdf.xyops !== undefined && this.compareVersions(xypdf.xyops, this.xyopsVersion) > 0) {
|
|
135
|
+
this.logSyncWarning("Item requires xyOps v" + xypdf.xyops + " or higher: " + file.path);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// file must have exactly one item
|
|
140
|
+
if (xypdf.items.length > 1) {
|
|
141
|
+
this.logSyncWarning("File with multiple items cannot be a sync source: " + file.path);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
var item = xypdf.items.shift();
|
|
146
|
+
|
|
147
|
+
// basic validation
|
|
148
|
+
if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
|
|
149
|
+
this.logSyncWarning("Malformed item type in file: " + file.path);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (item.type.match(/^(bucket|secret|user|role)$/)) {
|
|
153
|
+
this.logSyncWarning("Unsupported item type: " + item.type + " in file: " + file.path);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!item.data || !Tools.isaHash(item.data)) {
|
|
157
|
+
this.logSyncWarning("Malformed item data in file: " + file.path);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!item.data.id || (typeof(item.data.id) != 'string')) {
|
|
161
|
+
this.logSyncWarning("Malformed or missing item ID in file: " + file.path);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
var type_def = this.config.ui.data_types[item.type];
|
|
166
|
+
if (!type_def) {
|
|
167
|
+
this.logSyncWarning("Cannot locate internal type definition: " + item.type);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
var list = this[ type_def.list ];
|
|
172
|
+
if (!list) {
|
|
173
|
+
this.logSyncWarning("Cannot locate internal list: " + type_def.list);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
var xy_item = Tools.findObject(list, { id: item.data.id });
|
|
178
|
+
if (!xy_item) {
|
|
179
|
+
this.logSyncWarning("Cannot find " + item.type + " in xyOps: " + item.data.title + " (" + item.data.id + "), will be skipped from sync.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Tools.findObjectDeep(items, { 'type': item.type, 'data.id': item.data.id })) {
|
|
184
|
+
this.logSyncWarning("Duplicate source item found: " + file.path + " (skipping)");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// locate any external neighbor prop files
|
|
189
|
+
var neighbor_error = false;
|
|
190
|
+
|
|
191
|
+
files.forEach( neighbor => {
|
|
192
|
+
if (Path.dirname(neighbor.path) != dir) return; // must be in same dir
|
|
193
|
+
|
|
194
|
+
var nfilename = Path.basename(neighbor.path);
|
|
195
|
+
var nstem = nfilename.replace(/\.\w+$/, '');
|
|
196
|
+
if (nstem.length <= stem.length) return; // neighbor stem too short
|
|
197
|
+
if (!nstem.startsWith(stem)) return; // no shared stem
|
|
198
|
+
|
|
199
|
+
var suffix = nstem.substring(stem.length);
|
|
200
|
+
if (!suffix.match(/\-([\w\.\-]+)$/)) return; // not correct format
|
|
201
|
+
var prop_path = RegExp.$1;
|
|
202
|
+
|
|
203
|
+
var content = '';
|
|
204
|
+
try {
|
|
205
|
+
content = fs.readFileSync(neighbor.path, 'utf8');
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
this.logSyncError("Failed to load neighbor property file: " + neighbor.path + ": " + err);
|
|
209
|
+
neighbor_error = true;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A neighboring JSON file may be another freely-named sync source whose
|
|
214
|
+
// stem happens to begin with this source's stem. XYPDF files always stand
|
|
215
|
+
// on their own and must never be interpreted as external property content.
|
|
216
|
+
if (neighbor.path.match(/\.json$/i)) {
|
|
217
|
+
try {
|
|
218
|
+
var neighbor_json = Tools.parseJSON(content);
|
|
219
|
+
if (neighbor_json && Tools.isaHash(neighbor_json) && (neighbor_json.type === 'xypdf')) return;
|
|
220
|
+
}
|
|
221
|
+
catch (err) {;} // leave non-JSON content for normal neighbor handling
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// External files may only replace string properties that already exist in
|
|
225
|
+
// the definition. Tools.setPath() creates missing keys, so validate first.
|
|
226
|
+
if (typeof(Tools.getPath(item.data, prop_path)) != 'string') {
|
|
227
|
+
this.logSyncWarning("Neighbor file does not match an existing string property: " + neighbor.path + " (" + prop_path + ")");
|
|
228
|
+
neighbor_error = true;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!Tools.setPath(item.data, prop_path, content)) {
|
|
232
|
+
this.logSyncWarning("Invalid neighbor property path: " + neighbor.path + " (" + prop_path + ")");
|
|
233
|
+
neighbor_error = true;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// compute effective mtime of item including all included neighbors
|
|
238
|
+
if (neighbor.mtime > file.mtime) file.mtime = neighbor.mtime;
|
|
239
|
+
|
|
240
|
+
// include neighbor metadata for downsync
|
|
241
|
+
if (!item.neighbors) item.neighbors = {};
|
|
242
|
+
item.neighbors[ prop_path ] = neighbor.path;
|
|
243
|
+
} ); // foreach neighbor
|
|
244
|
+
|
|
245
|
+
// do not include item if neighbor loading or validation failed
|
|
246
|
+
if (neighbor_error) return;
|
|
247
|
+
|
|
248
|
+
// include effective mtime in item for two-way sync
|
|
249
|
+
item.mtime = file.mtime;
|
|
250
|
+
|
|
251
|
+
// include list name for convenience below
|
|
252
|
+
item.list = type_def.list;
|
|
253
|
+
|
|
254
|
+
// include file for downsync
|
|
255
|
+
item.file = file.path;
|
|
256
|
+
|
|
257
|
+
// add to sync list
|
|
258
|
+
items.push(item);
|
|
259
|
+
} ); // foreach json file
|
|
260
|
+
|
|
261
|
+
// exit before sync starts if any warnings or errors occurred
|
|
262
|
+
if (this.errors.length || this.warnings.length) return await this.completeSync();
|
|
263
|
+
|
|
264
|
+
println( "\n " + this.color('theme').bold("Starting sync...") );
|
|
265
|
+
|
|
266
|
+
// build state object of all sync flags (for sync up only)
|
|
267
|
+
// the xyops UI uses these flags to display edit warnings
|
|
268
|
+
var state = {};
|
|
269
|
+
|
|
270
|
+
// iterate over all items
|
|
271
|
+
for (const item of items) {
|
|
272
|
+
var list = this[ item.list ];
|
|
273
|
+
var xy_item = Tools.findObject( list, { id: item.data.id } );
|
|
274
|
+
|
|
275
|
+
var up_enabled = sconfig.up && sconfig.up.includes(item.list);
|
|
276
|
+
var down_enabled = sconfig.down && sconfig.down.includes(item.list);
|
|
277
|
+
if (!up_enabled && !down_enabled) continue; // no sync direction enabled for this item
|
|
278
|
+
|
|
279
|
+
// add item id to state for sync up solo mode
|
|
280
|
+
if (up_enabled && !down_enabled) state[ item.type + '-' + item.data.id ] = true;
|
|
281
|
+
|
|
282
|
+
var changes = this.diffSyncItems(xy_item, item.data);
|
|
283
|
+
if (!changes.some( change => change.added || change.removed )) continue; // identical
|
|
284
|
+
|
|
285
|
+
// render diff
|
|
286
|
+
var diff = "```diff\n";
|
|
287
|
+
changes.forEach( change => {
|
|
288
|
+
var prefix = change.added ? '+' : change.removed ? '-' : ' ';
|
|
289
|
+
var lines = change.value.replace(/\n$/, '').split(/\n/);
|
|
290
|
+
|
|
291
|
+
if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
|
|
292
|
+
// reduce context lines to 1 above and 1 below, unless in verbose mode
|
|
293
|
+
var top = lines.shift();
|
|
294
|
+
var bottom = lines.pop();
|
|
295
|
+
lines = [ top, "...", bottom ];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
lines.forEach( line => {
|
|
299
|
+
diff += prefix + line.trimEnd() + "\n";
|
|
300
|
+
} );
|
|
301
|
+
} );
|
|
302
|
+
diff += "```\n";
|
|
303
|
+
|
|
304
|
+
// figure out which direction to go
|
|
305
|
+
var direction = false;
|
|
306
|
+
var icon = '';
|
|
307
|
+
|
|
308
|
+
if (up_enabled && down_enabled) {
|
|
309
|
+
// both directions enabled: use mod date to see which wins
|
|
310
|
+
if (xy_item.modified > item.mtime) { icon = '⬇️'; direction = 'down'; }
|
|
311
|
+
else { icon = '⬆️'; direction = 'up'; }
|
|
312
|
+
}
|
|
313
|
+
else if (up_enabled) {
|
|
314
|
+
// up only
|
|
315
|
+
icon = '⬆️';
|
|
316
|
+
direction = 'up';
|
|
317
|
+
}
|
|
318
|
+
else if (down_enabled) {
|
|
319
|
+
// down only
|
|
320
|
+
icon = '⬇️';
|
|
321
|
+
direction = 'down';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
println( "\n " + cli.emoji(icon) + ' ' + green.bold("Updating " + item.type) + ": " + cyan.bold(item.data.title) + gray(" (" + item.data.id + ")") );
|
|
325
|
+
println( "\n" + this.markdown(diff) );
|
|
326
|
+
|
|
327
|
+
if (direction == 'up') {
|
|
328
|
+
// sync change up to xyops
|
|
329
|
+
try {
|
|
330
|
+
await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true });
|
|
331
|
+
|
|
332
|
+
// add flag to run optional command at completion
|
|
333
|
+
this.upSynced = true;
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
this.logSyncError('' + err);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (direction == 'down') {
|
|
340
|
+
// sync change down to filesystem
|
|
341
|
+
try {
|
|
342
|
+
this.downSyncItem(xy_item, item);
|
|
343
|
+
|
|
344
|
+
// add flag to run optional command at completion
|
|
345
|
+
this.downSynced = true;
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
this.logSyncError('' + err);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} // foreach item
|
|
352
|
+
|
|
353
|
+
// update state db
|
|
354
|
+
await this.syncState(state);
|
|
355
|
+
|
|
356
|
+
// now handle deletes
|
|
357
|
+
if (!sconfig.delete) return await this.completeSync();
|
|
358
|
+
|
|
359
|
+
// (deletes always happen upward, i.e. in xyops)
|
|
360
|
+
var list_data_types = [];
|
|
361
|
+
for (var id in this.config.ui.data_types) {
|
|
362
|
+
var type_def = this.config.ui.data_types[id];
|
|
363
|
+
list_data_types.push({ ...type_def, id: id });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const list_name of sconfig.delete) {
|
|
367
|
+
var list = this[list_name];
|
|
368
|
+
if (!list) continue; // sanity
|
|
369
|
+
|
|
370
|
+
for (const xy_item of list) {
|
|
371
|
+
// Stock and Marketplace objects are never deletion candidates. Check the
|
|
372
|
+
// remote object's markers so missing local exports cannot remove them.
|
|
373
|
+
if (Object.hasOwn(xy_item, 'stock') || Object.hasOwn(xy_item, 'marketplace')) continue;
|
|
374
|
+
|
|
375
|
+
var type_def = Tools.findObject( list_data_types, { list: list_name } );
|
|
376
|
+
var item = Tools.findObjectDeep( items, { type: type_def.id, 'data.id': xy_item.id } );
|
|
377
|
+
if (item) continue; // still exists
|
|
378
|
+
|
|
379
|
+
println( "\n " + cli.emoji('🗑️') + ' ' + red.bold("Deleting " + type_def.id) + ": " + red.bold(xy_item.title) + gray(" (" + xy_item.id + ")") );
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true });
|
|
383
|
+
|
|
384
|
+
// add flag to run optional command at completion
|
|
385
|
+
this.upSynced = true;
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
this.logSyncError('' + err);
|
|
389
|
+
}
|
|
390
|
+
}; // foreach item
|
|
391
|
+
}; // foreach delete
|
|
392
|
+
|
|
393
|
+
await this.completeSync();
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
getSyncLockFile() {
|
|
397
|
+
// Allow a fixed path for service installations. Otherwise, serialize all
|
|
398
|
+
// local syncs targeting the exact same configured xyOps base URL.
|
|
399
|
+
var sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
|
|
400
|
+
if (sconfig.lock_file) return Path.resolve('' + sconfig.lock_file);
|
|
401
|
+
|
|
402
|
+
var digest = Tools.digestHex( this.config.base_url, 'sha256', 16);
|
|
403
|
+
return Path.join( os.tmpdir(), 'xyops-cli-sync-' + digest + '.pid' );
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
acquireSyncLock() {
|
|
407
|
+
// Atomically create a PID lock. If a prior process was killed without
|
|
408
|
+
// cleanup, replace its stale lock and retry acquisition.
|
|
409
|
+
var lock_file = this.getSyncLockFile();
|
|
410
|
+
var token = Tools.generateUniqueID(32);
|
|
411
|
+
var payload = {
|
|
412
|
+
pid: process.pid,
|
|
413
|
+
started: (new Date()).toISOString(),
|
|
414
|
+
base_url: this.config.base_url || '',
|
|
415
|
+
token: token
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
for (var attempt = 0; attempt < 3; attempt++) {
|
|
419
|
+
var fd = null;
|
|
420
|
+
try {
|
|
421
|
+
// The file contains no credentials. Keep it readable so another local
|
|
422
|
+
// account can inspect the PID when a shared lock path is configured.
|
|
423
|
+
fd = fs.openSync(lock_file, 'wx', 0o644);
|
|
424
|
+
fs.writeFileSync(fd, JSON.stringify(payload, null, "\t") + "\n");
|
|
425
|
+
fs.closeSync(fd);
|
|
426
|
+
fd = null;
|
|
427
|
+
|
|
428
|
+
this.syncLock = { file: lock_file, token: token };
|
|
429
|
+
this.syncLockExitHandler = () => { this.releaseSyncLock(); };
|
|
430
|
+
process.once('exit', this.syncLockExitHandler);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
if (fd !== null) {
|
|
435
|
+
try { fs.closeSync(fd); } catch (close_err) {;}
|
|
436
|
+
try { fs.unlinkSync(lock_file); } catch (unlink_err) {;}
|
|
437
|
+
}
|
|
438
|
+
if (err.code !== 'EEXIST') {
|
|
439
|
+
this.die("Failed to create sync lock file: " + lock_file + ": " + err.message);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
var raw = '';
|
|
444
|
+
try {
|
|
445
|
+
raw = fs.readFileSync(lock_file, 'utf8').trim();
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
// The owner may have finished between our open and read attempts.
|
|
449
|
+
if (err.code === 'ENOENT') continue;
|
|
450
|
+
this.die("Failed to read existing sync lock file: " + lock_file + ": " + err.message);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
var old_lock = null;
|
|
454
|
+
try { old_lock = raw.match(/^\d+$/) ? { pid: parseInt(raw) } : JSON.parse(raw); }
|
|
455
|
+
catch (err) { this.die("Invalid sync lock file: " + lock_file + ". Verify no sync is running before removing it."); }
|
|
456
|
+
|
|
457
|
+
var old_pid = old_lock && Number(old_lock.pid);
|
|
458
|
+
if (!Number.isInteger(old_pid) || (old_pid < 1)) {
|
|
459
|
+
this.die("Invalid sync lock PID in: " + lock_file + ". Verify no sync is running before removing it.");
|
|
460
|
+
}
|
|
461
|
+
var alive = false;
|
|
462
|
+
try {
|
|
463
|
+
process.kill(old_pid, 0);
|
|
464
|
+
alive = true;
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
// EPERM means the process exists but belongs to another account.
|
|
468
|
+
if (err.code === 'EPERM') alive = true;
|
|
469
|
+
else if (err.code !== 'ESRCH') this.die("Failed to inspect sync lock PID " + old_pid + ": " + err.message);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (alive) {
|
|
473
|
+
this.die("Another sync process is already running (PID " + old_pid + "). Lock file: " + lock_file);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Serialize stale-file recovery too. Without this second atomic claim,
|
|
477
|
+
// two starters could both inspect the old PID and one could unlink the
|
|
478
|
+
// fresh lock created by the other.
|
|
479
|
+
var recovery_file = lock_file + '.recover';
|
|
480
|
+
var recovery_fd = null;
|
|
481
|
+
try {
|
|
482
|
+
recovery_fd = fs.openSync(recovery_file, 'wx', 0o644);
|
|
483
|
+
fs.writeFileSync(recovery_fd, '' + process.pid + "\n");
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
if (recovery_fd !== null) {
|
|
487
|
+
try { fs.closeSync(recovery_fd); } catch (close_err) {;}
|
|
488
|
+
try { fs.unlinkSync(recovery_file); } catch (unlink_err) {;}
|
|
489
|
+
}
|
|
490
|
+
if (err.code === 'EEXIST') {
|
|
491
|
+
this.die("Another process is recovering a stale sync lock. Please retry. Lock file: " + lock_file);
|
|
492
|
+
}
|
|
493
|
+
this.die("Failed to claim stale sync lock file: " + lock_file + ": " + err.message);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
var recovery_error = null;
|
|
497
|
+
var retry = false;
|
|
498
|
+
try {
|
|
499
|
+
// Re-read after winning recovery. If another process already replaced
|
|
500
|
+
// the file, leave its new lock untouched and retry normally.
|
|
501
|
+
var current_raw = fs.readFileSync(lock_file, 'utf8').trim();
|
|
502
|
+
if (current_raw !== raw) retry = true;
|
|
503
|
+
else fs.unlinkSync(lock_file);
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
if (err.code === 'ENOENT') retry = true;
|
|
507
|
+
else recovery_error = err;
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
if (recovery_fd !== null) try { fs.closeSync(recovery_fd); } catch (close_err) {;}
|
|
511
|
+
try { fs.unlinkSync(recovery_file); } catch (unlink_err) {;}
|
|
512
|
+
}
|
|
513
|
+
if (recovery_error) {
|
|
514
|
+
this.die("Failed to remove stale sync lock file: " + lock_file + ": " + recovery_error.message);
|
|
515
|
+
}
|
|
516
|
+
if (retry) continue;
|
|
517
|
+
} // attempt
|
|
518
|
+
|
|
519
|
+
this.die("Failed to acquire sync lock file after multiple attempts: " + lock_file);
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
releaseSyncLock() {
|
|
523
|
+
// Only the process that created the current lock may remove it. This
|
|
524
|
+
// prevents late cleanup from deleting a newer process's replacement lock.
|
|
525
|
+
if (!this.syncLock) return;
|
|
526
|
+
var lock = this.syncLock;
|
|
527
|
+
this.syncLock = null;
|
|
528
|
+
|
|
529
|
+
if (this.syncLockExitHandler) {
|
|
530
|
+
process.removeListener('exit', this.syncLockExitHandler);
|
|
531
|
+
this.syncLockExitHandler = null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
var current = JSON.parse(fs.readFileSync(lock.file, 'utf8'));
|
|
536
|
+
if (current.token === lock.token) fs.unlinkSync(lock.file);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
if (err.code !== 'ENOENT') warnln("Failed to remove sync lock file: " + lock.file + ": " + err.message);
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
async syncState(new_state) {
|
|
544
|
+
// sync state only if it differs
|
|
545
|
+
var old_state = this.state.sync || {};
|
|
546
|
+
|
|
547
|
+
if (Tools.stableStringify(new_state) !== Tools.stableStringify(old_state)) {
|
|
548
|
+
try {
|
|
549
|
+
await this.callStandardAPI( 'update_global_state', { sync: new_state }, { text: "Updating state...", throw: true } );
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
this.logSyncError('' + err);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
async completeSync() {
|
|
558
|
+
// send email if we have warnings or errors
|
|
559
|
+
var sconfig = this.sconfig;
|
|
560
|
+
|
|
561
|
+
println( "\n " + this.color('theme').bold("Sync complete.") );
|
|
562
|
+
|
|
563
|
+
if (this.dry) return;
|
|
564
|
+
|
|
565
|
+
// optional shell commands if we synced anything
|
|
566
|
+
if (this.upSynced && sconfig.up_cmd) {
|
|
567
|
+
this.syncFinishCmd(sconfig.up_cmd);
|
|
568
|
+
}
|
|
569
|
+
if (this.downSynced && sconfig.down_cmd) {
|
|
570
|
+
this.syncFinishCmd(sconfig.down_cmd);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!this.warnings.length && !this.errors.length) return;
|
|
574
|
+
|
|
575
|
+
if (sconfig.error_email) {
|
|
576
|
+
// send email on warning / error
|
|
577
|
+
var md = '';
|
|
578
|
+
md += "The sync operation generated the following output:\n";
|
|
579
|
+
|
|
580
|
+
if (this.errors.length) {
|
|
581
|
+
md += "\n### Errors\n\n";
|
|
582
|
+
this.errors.forEach( error => { md += "- " + error + "\n"; } );
|
|
583
|
+
}
|
|
584
|
+
if (this.warnings.length) {
|
|
585
|
+
md += "\n### Warnings\n\n";
|
|
586
|
+
this.warnings.forEach( warning => { md += "- " + warning + "\n"; } );
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
md += "\n### Other Information\n\n";
|
|
590
|
+
md += "- **Date/Time:** " + (new Date()).toString() + "\n";
|
|
591
|
+
md += "- **Sync Server:** " + os.hostname() + "\n";
|
|
592
|
+
md += "- **Sync Platform:** " + process.platform + "\n";
|
|
593
|
+
md += "- **Node.js Version:** " + process.version + "\n";
|
|
594
|
+
md += "- **xyCLI Version:** " + this.version + "\n";
|
|
595
|
+
if (process.platform != 'win32') md += "- **Sync User/Group:** " + process.getuid() + "/" + process.getgid() + "\n";
|
|
596
|
+
// md += "- **Sync Command:** `" + process.argv.join(' ') + "`\n";
|
|
597
|
+
|
|
598
|
+
md += "\n### Sync Command\n";
|
|
599
|
+
md += "\n```\n" + process.argv.join(' ') + "\n```\n";
|
|
600
|
+
|
|
601
|
+
md += "\n### Sync Configuration\n";
|
|
602
|
+
md += "\n```\n" + JSON.stringify(sconfig, null, "\t") + "\n```\n";
|
|
603
|
+
|
|
604
|
+
var req = {
|
|
605
|
+
to: sconfig.error_email,
|
|
606
|
+
subject: 'xyOps CLI Sync Results',
|
|
607
|
+
title: 'Sync Results',
|
|
608
|
+
body: md
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
await this.callStandardAPI('sendEmail', req, { text: "Sending email..." });
|
|
612
|
+
} // email
|
|
613
|
+
|
|
614
|
+
if (sconfig.error_event) {
|
|
615
|
+
// run event on warning / error
|
|
616
|
+
var req = {
|
|
617
|
+
id: sconfig.error_event,
|
|
618
|
+
input: {
|
|
619
|
+
data: {
|
|
620
|
+
errors: this.errors,
|
|
621
|
+
warnings: this.warnings
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
await this.callStandardAPI('runEvent', req, { text: "Running event..." });
|
|
627
|
+
} // run_event
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
syncFinishCmd(cmd) {
|
|
631
|
+
// spawn command and log output
|
|
632
|
+
var sconfig = this.sconfig;
|
|
633
|
+
var cwd = this.args.other[0]; // use first base dir as cwd for shell commands
|
|
634
|
+
|
|
635
|
+
println( "\n " + green.bold("Running completion command: ") + cyan.bold(cmd) );
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const output = cp.execSync(cmd, {
|
|
639
|
+
cwd: cwd,
|
|
640
|
+
timeout: (sconfig.cmd_timeout || 30) * 1000,
|
|
641
|
+
encoding: 'utf8',
|
|
642
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
if (output.match(/\S/)) {
|
|
646
|
+
println( "\n" + this.markdown("```\n" + output.trim() + "\n```\n") );
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
this.logSyncError('Command failed: ' + cmd + ": " + err.message);
|
|
651
|
+
if (err.stdout) console.error('STDOUT:', err.stdout);
|
|
652
|
+
if (err.stderr) console.error('STDERR:', err.stderr);
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
downSyncItem(xy_item, item) {
|
|
657
|
+
// write changed item back to disk
|
|
658
|
+
var data = Tools.copyHash(xy_item, true);
|
|
659
|
+
|
|
660
|
+
// process and write out neighbor files
|
|
661
|
+
if (item.neighbors) {
|
|
662
|
+
for (var path in item.neighbors) {
|
|
663
|
+
var file = item.neighbors[path];
|
|
664
|
+
var value = Tools.getPath( data, path ) ?? "";
|
|
665
|
+
|
|
666
|
+
if (!Tools.setPath( data, path, "(External)" )) {
|
|
667
|
+
throw "External neighbor file path not found: " + file + ": " + path;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!this.dry) {
|
|
671
|
+
try { Tools.writeFileAtomicSync(file, value); }
|
|
672
|
+
catch (err) {
|
|
673
|
+
throw "Failed to write local file: " + file + ": " + err;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} // neighbors
|
|
678
|
+
|
|
679
|
+
var xypdf = this.getItemExportData(item.type, data);
|
|
680
|
+
|
|
681
|
+
if (this.dry || this.verbose) {
|
|
682
|
+
println( "\n " + this.color('theme').bold("WRITING FILE:") );
|
|
683
|
+
this.jsonOutput(xypdf);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (!this.dry) {
|
|
687
|
+
try { Tools.writeFileAtomicSync( item.file, JSON.stringify(xypdf, null, "\t") + "\n" ); }
|
|
688
|
+
catch (err) {
|
|
689
|
+
throw "Failed to write local file: " + item.file + ": " + err;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
diffSyncItems(a, b) {
|
|
695
|
+
// diff items sans metadata
|
|
696
|
+
var remove_keys = { created: 1, modified: 1, revision: 1, sort_order: 1, username: 1 };
|
|
697
|
+
|
|
698
|
+
return Diff.diffLines(
|
|
699
|
+
Tools.stablePrettyStringify( Tools.copyHashRemoveKeys(a, remove_keys) ),
|
|
700
|
+
Tools.stablePrettyStringify( Tools.copyHashRemoveKeys(b, remove_keys) )
|
|
701
|
+
);
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
logSyncWarning(msg) {
|
|
705
|
+
this.warnings.push(msg);
|
|
706
|
+
|
|
707
|
+
// Scan warnings prevent sync from applying changes, so report failure.
|
|
708
|
+
// Let notifications finish before exiting, including in quiet or dry mode.
|
|
709
|
+
process.exitCode = 1;
|
|
710
|
+
|
|
711
|
+
warnln( "\n " + cli.emoji('⚠️') + " " + yellow.bold("WARNING: ") + " " + msg );
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
logSyncError(msg) {
|
|
715
|
+
this.errors.push(msg);
|
|
716
|
+
|
|
717
|
+
// Report failure to scripts without interrupting completion or notifications.
|
|
718
|
+
// This also covers dry-run errors and failures in completion commands.
|
|
719
|
+
process.exitCode = 1;
|
|
720
|
+
|
|
721
|
+
warnln( "\n " + cli.emoji('🛑') + " " + red.bold("ERROR: ") + " " + msg );
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
async setupSync() {
|
|
725
|
+
// export select resources to local XYPDF files in suggested layout
|
|
726
|
+
// xy sync --setup all --file_props "script,params.script"
|
|
727
|
+
// xy sync --setup all --marketplace --stock --force
|
|
728
|
+
var data_types = this.config.ui.data_types;
|
|
729
|
+
var list_data_types = Object.keys(data_types).map( key => {
|
|
730
|
+
return { ...data_types[key], id: key };
|
|
731
|
+
} );
|
|
732
|
+
|
|
733
|
+
if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
|
|
734
|
+
this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (typeof(this.args.setup) == 'string') this.args.setup = this.args.setup.split(/\,/);
|
|
738
|
+
if (!Array.isArray(this.args.setup)) this.dieUsage('sync setup');
|
|
739
|
+
|
|
740
|
+
if (Tools.includesAny(this.args.setup, ['buckets', 'secrets', 'users', 'roles'])) {
|
|
741
|
+
this.die("Sorry, the sync engine cannot process buckets, secrets, users, or roles.");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!Tools.includesAll(this.config.ui.list_list.map( list => list.id ), this.args.setup)) {
|
|
745
|
+
this.die("One or more specified item types are unknown: " + this.args.setup.join(', ') );
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!this.args.setup.length) this.die("No item types specified to set up.");
|
|
749
|
+
|
|
750
|
+
if (this.args.file_props) {
|
|
751
|
+
if (typeof(this.args.file_props) == 'string') this.args.file_props = this.args.file_props.split(/\,/);
|
|
752
|
+
if (!Array.isArray(this.args.file_props)) this.dieUsage('sync setup');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
this.printBoxList({
|
|
756
|
+
title: 'Sync Setup',
|
|
757
|
+
rows: [
|
|
758
|
+
['Item Types', this.args.setup.join(', ')],
|
|
759
|
+
['File Props', this.args.file_props ? this.args.file_props.join(', ') : '(None)'],
|
|
760
|
+
this.dry ? ['Dry Run', 'No changes will be made.'] : null
|
|
761
|
+
]
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
println( "\n " + this.color('theme').bold("WRITING FILES:") );
|
|
765
|
+
|
|
766
|
+
// split up workflows into their own folder
|
|
767
|
+
if (this.args.setup.includes('events')) {
|
|
768
|
+
this.workflows = this.events.filter( event => event.type == 'workflow' );
|
|
769
|
+
this.events = this.events.filter( event => event.type != 'workflow' );
|
|
770
|
+
this.args.setup.push('workflows');
|
|
771
|
+
list_data_types.push( { id: "event", "list": "workflows" } );
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
this.args.setup.forEach( (list_name) => {
|
|
775
|
+
var list = this[list_name];
|
|
776
|
+
if (!list || !list.length) return;
|
|
777
|
+
|
|
778
|
+
var type_def = Tools.findObject( list_data_types, { list: list_name } );
|
|
779
|
+
if (!type_def) return; // sanity
|
|
780
|
+
|
|
781
|
+
if (!this.dry) Tools.mkdirp.sync( list_name );
|
|
782
|
+
println( "\n " + cli.emoji('📂') + " " + bold.cyan(list_name + '/') );
|
|
783
|
+
|
|
784
|
+
list.forEach( (item) => {
|
|
785
|
+
// exclude marketplace and stock items
|
|
786
|
+
if (item.marketplace && !this.args.marketplace) return;
|
|
787
|
+
if (item.stock && !this.args.stock) return;
|
|
788
|
+
|
|
789
|
+
// extract extras first
|
|
790
|
+
var extras = [];
|
|
791
|
+
(this.args.file_props || []).forEach( (path) => {
|
|
792
|
+
var extra = Tools.getPath(item, path);
|
|
793
|
+
if ((typeof(extra) == 'string') && extra.match(/\S/)) {
|
|
794
|
+
extras.push({ path, content: extra });
|
|
795
|
+
|
|
796
|
+
// we do not need to check the return value of setPath here,
|
|
797
|
+
// because this path is guaranteed to exist at this point
|
|
798
|
+
Tools.setPath( item, path, "(External)" );
|
|
799
|
+
}
|
|
800
|
+
} );
|
|
801
|
+
|
|
802
|
+
var data = this.getItemExportData(type_def.id, item);
|
|
803
|
+
var title = item.title.replace(/\W+/g, '-').replace(/\-+$/, '').replace(/^\-+/, '');
|
|
804
|
+
var filename = title + '.json';
|
|
805
|
+
var file = Path.join(list_name, filename);
|
|
806
|
+
|
|
807
|
+
if (!this.dry) {
|
|
808
|
+
if (!this.args.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
|
|
809
|
+
Tools.writeFileAtomicSync( file, JSON.stringify(data, null, "\t") + "\n" );
|
|
810
|
+
}
|
|
811
|
+
println( " " + cli.emoji('📄') + " " + bold.green(filename) );
|
|
812
|
+
|
|
813
|
+
// now handle extras
|
|
814
|
+
extras.forEach( (extra) => {
|
|
815
|
+
var command = item.command;
|
|
816
|
+
if (!command && extra.content.match(/^\s*\#\!(.+?)\n/)) command = RegExp.$1;
|
|
817
|
+
|
|
818
|
+
var ext = 'txt';
|
|
819
|
+
if (command) ext = this.getExtFromBinary(command);
|
|
820
|
+
else if (extra.content.trim().match(/^\{[\S\s]*\}$/) || extra.content.trim().match(/^\[[\S\s]*\]$/)) ext = 'json';
|
|
821
|
+
|
|
822
|
+
var extra_filename = title + '-' + extra.path + '.' + ext;
|
|
823
|
+
var extra_file = Path.join(list_name, extra_filename);
|
|
824
|
+
|
|
825
|
+
if (!this.dry) {
|
|
826
|
+
if (!this.args.force && fs.existsSync(extra_file)) this.die("File exists, add '--force' to overwrite all: " + extra_file);
|
|
827
|
+
Tools.writeFileAtomicSync( extra_file, extra.content );
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
println( " " + cli.emoji('📜') + " " + bold.yellow(extra_filename) );
|
|
831
|
+
} );
|
|
832
|
+
} ); // foreach item
|
|
833
|
+
} ); // foreach list
|
|
834
|
+
|
|
835
|
+
println( "\n " + this.color('theme').bold("Setup complete!") );
|
|
836
|
+
},
|
|
837
|
+
|
|
838
|
+
getItemExportData(type, item) {
|
|
839
|
+
// generate XYPDF for serialization to JSON for export
|
|
840
|
+
var trimmed_item = { ...item };
|
|
841
|
+
|
|
842
|
+
delete trimmed_item.created;
|
|
843
|
+
delete trimmed_item.modified;
|
|
844
|
+
delete trimmed_item.revision;
|
|
845
|
+
delete trimmed_item.sort_order;
|
|
846
|
+
delete trimmed_item.username;
|
|
847
|
+
|
|
848
|
+
var data = {
|
|
849
|
+
type: 'xypdf',
|
|
850
|
+
description: "xyOps Portable Data Object",
|
|
851
|
+
version: "1.0",
|
|
852
|
+
xyops: this.xyopsVersion,
|
|
853
|
+
items: [{
|
|
854
|
+
type: type,
|
|
855
|
+
data: trimmed_item
|
|
856
|
+
}]
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
return data;
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
getExtFromBinary(bin) {
|
|
863
|
+
// sniff file ext from binary path, e.g. `/bin/sh`
|
|
864
|
+
if ((typeof(bin) != 'string') || !bin.trim()) return 'txt';
|
|
865
|
+
|
|
866
|
+
// normalize Windows paths, optional arguments and common shebang-style
|
|
867
|
+
// env wrappers before matching the interpreter name
|
|
868
|
+
var cmdline = bin.trim().replace(/\\/g, '/');
|
|
869
|
+
var cmd = cmdline;
|
|
870
|
+
if (cmd.match(/^(?:.*\/)?env(?:\.exe)?\s+(?:\-S\s+)?(\S+)/i)) cmd = RegExp.$1;
|
|
871
|
+
else if (cmd.match(/^"([^"]+)"/)) cmd = RegExp.$1;
|
|
872
|
+
else if (cmd.match(/^'([^']+)'/)) cmd = RegExp.$1;
|
|
873
|
+
else cmd = cmd.replace(/\s+.+$/, '');
|
|
874
|
+
cmd = Path.basename(cmd);
|
|
875
|
+
|
|
876
|
+
// allow Windows executables and versioned names such as python3.12
|
|
877
|
+
cmd = cmd.replace(/\.exe$/i, '').replace(/\d+(?:\.\d+)*$/, '').toLowerCase();
|
|
878
|
+
var ext = 'txt';
|
|
879
|
+
|
|
880
|
+
switch (cmd) {
|
|
881
|
+
case 'sh':
|
|
882
|
+
case 'csh':
|
|
883
|
+
case 'ksh':
|
|
884
|
+
case 'tcsh':
|
|
885
|
+
case 'fish':
|
|
886
|
+
case 'zsh':
|
|
887
|
+
case 'bash':
|
|
888
|
+
ext = 'sh';
|
|
889
|
+
break;
|
|
890
|
+
|
|
891
|
+
case 'node':
|
|
892
|
+
case 'nodejs':
|
|
893
|
+
case 'deno':
|
|
894
|
+
case 'bun':
|
|
895
|
+
ext = 'js';
|
|
896
|
+
break;
|
|
897
|
+
|
|
898
|
+
case 'ts-node':
|
|
899
|
+
case 'ts-node-esm':
|
|
900
|
+
case 'tsx':
|
|
901
|
+
ext = 'ts';
|
|
902
|
+
break;
|
|
903
|
+
|
|
904
|
+
case 'pwsh':
|
|
905
|
+
case 'powershell':
|
|
906
|
+
ext = 'ps1';
|
|
907
|
+
break;
|
|
908
|
+
|
|
909
|
+
case 'cmd':
|
|
910
|
+
ext = 'bat';
|
|
911
|
+
break;
|
|
912
|
+
|
|
913
|
+
case 'py':
|
|
914
|
+
case 'python':
|
|
915
|
+
case 'pythonw':
|
|
916
|
+
case 'pypy':
|
|
917
|
+
ext = 'py';
|
|
918
|
+
break;
|
|
919
|
+
|
|
920
|
+
case 'perl':
|
|
921
|
+
ext = 'pl';
|
|
922
|
+
break;
|
|
923
|
+
|
|
924
|
+
case 'ruby':
|
|
925
|
+
ext = 'rb';
|
|
926
|
+
break;
|
|
927
|
+
|
|
928
|
+
case 'php':
|
|
929
|
+
ext = 'php';
|
|
930
|
+
break;
|
|
931
|
+
|
|
932
|
+
case 'lua':
|
|
933
|
+
case 'luajit':
|
|
934
|
+
ext = 'lua';
|
|
935
|
+
break;
|
|
936
|
+
|
|
937
|
+
case 'r':
|
|
938
|
+
case 'rscript':
|
|
939
|
+
ext = 'R';
|
|
940
|
+
break;
|
|
941
|
+
|
|
942
|
+
case 'tclsh':
|
|
943
|
+
case 'wish':
|
|
944
|
+
ext = 'tcl';
|
|
945
|
+
break;
|
|
946
|
+
|
|
947
|
+
case 'julia':
|
|
948
|
+
ext = 'jl';
|
|
949
|
+
break;
|
|
950
|
+
|
|
951
|
+
case 'groovy':
|
|
952
|
+
ext = 'groovy';
|
|
953
|
+
break;
|
|
954
|
+
|
|
955
|
+
case 'elixir':
|
|
956
|
+
ext = 'exs';
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return ext;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
};
|