@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/buckets.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// Storage Bucket Layer
|
|
2
|
+
//
|
|
3
|
+
// Bucket definitions, JSON data and files deliberately have separate mutation
|
|
4
|
+
// paths in xyOps. Keep that separation visible in the CLI so writing data or
|
|
5
|
+
// uploading files does not advance the bucket's metadata revision.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const Path = require('path');
|
|
9
|
+
const cli = require('pixl-cli');
|
|
10
|
+
const Tools = cli.Tools;
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
|
|
14
|
+
async cmd_buckets() {
|
|
15
|
+
// Collection alias for listing and filtering bucket definitions.
|
|
16
|
+
if (this.args.other[0] == 'list') this.args.other.shift();
|
|
17
|
+
await this.cmd_get_buckets();
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async cmd_bucket() {
|
|
21
|
+
// Singular router, e.g. `xy bucket create` or `xy bucket BUCKET_ID`.
|
|
22
|
+
var cmd = this.args.other.shift();
|
|
23
|
+
if (!cmd) return this.dieUsage('bucket');
|
|
24
|
+
|
|
25
|
+
switch (cmd) {
|
|
26
|
+
case 'list': await this.cmd_get_buckets(); break;
|
|
27
|
+
case 'get': await this.cmd_get_bucket(); break;
|
|
28
|
+
case 'write': await this.cmd_write_bucket_data(); break;
|
|
29
|
+
case 'upload': await this.cmd_upload_bucket_files(); break;
|
|
30
|
+
case 'download': await this.cmd_download_bucket_file(); break;
|
|
31
|
+
case 'empty': await this.cmd_empty_bucket(); break;
|
|
32
|
+
case 'file': await this.cmd_bucket_file(); break;
|
|
33
|
+
|
|
34
|
+
case 'create': await this.cmd_create_bucket(); break;
|
|
35
|
+
case 'update': await this.cmd_update_bucket(); break;
|
|
36
|
+
case 'delete': await this.cmd_delete_bucket(); break;
|
|
37
|
+
|
|
38
|
+
default:
|
|
39
|
+
// A bare ID or fuzzy title opens bucket details.
|
|
40
|
+
this.args.other.unshift(cmd);
|
|
41
|
+
await this.cmd_get_bucket();
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async cmd_bucket_file() {
|
|
47
|
+
// Keep file operations readable without introducing compound flag names.
|
|
48
|
+
var cmd = this.args.other.shift();
|
|
49
|
+
if (!cmd) return this.dieUsage('bucket file');
|
|
50
|
+
|
|
51
|
+
switch (cmd) {
|
|
52
|
+
case 'get':
|
|
53
|
+
case 'download': await this.cmd_download_bucket_file(); break;
|
|
54
|
+
case 'delete': await this.cmd_delete_bucket_file(); break;
|
|
55
|
+
default: this.die("Unknown bucket file command: " + cmd);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async cmd_get_buckets() {
|
|
60
|
+
// List bucket definitions from getMultiple. Data and file lists are only
|
|
61
|
+
// loaded when the user asks for one specific bucket.
|
|
62
|
+
this.prepSearchArgs();
|
|
63
|
+
await this.getMultiple();
|
|
64
|
+
var buckets = this.buckets.slice(0);
|
|
65
|
+
var is_filtered = false;
|
|
66
|
+
|
|
67
|
+
if (this.args.other.length) {
|
|
68
|
+
var search = this.args.other.join(' ');
|
|
69
|
+
buckets = this.findObjectsFuzzy(buckets, {
|
|
70
|
+
id: search,
|
|
71
|
+
title: search,
|
|
72
|
+
notes: search,
|
|
73
|
+
username: search
|
|
74
|
+
}, 1);
|
|
75
|
+
is_filtered = true;
|
|
76
|
+
}
|
|
77
|
+
delete this.args.other;
|
|
78
|
+
|
|
79
|
+
if ('enabled' in this.args) {
|
|
80
|
+
var enabled = this.parseBucketBoolean(this.args.enabled, 'enabled');
|
|
81
|
+
buckets = buckets.filter( bucket => !!bucket.enabled === enabled );
|
|
82
|
+
delete this.args.enabled;
|
|
83
|
+
is_filtered = true;
|
|
84
|
+
}
|
|
85
|
+
if ('disabled' in this.args) {
|
|
86
|
+
var disabled = this.parseBucketBoolean(this.args.disabled, 'disabled');
|
|
87
|
+
buckets = buckets.filter( bucket => !bucket.enabled === disabled );
|
|
88
|
+
delete this.args.disabled;
|
|
89
|
+
is_filtered = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Tools.numKeys(this.args)) {
|
|
93
|
+
// Preserve generic fuzzy filters for new scalar metadata fields.
|
|
94
|
+
buckets = this.findObjectsFuzzy(buckets, this.args);
|
|
95
|
+
is_filtered = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
buckets.sort( function(a, b) {
|
|
99
|
+
return String(a.title || '').toLowerCase().localeCompare(String(b.title || '').toLowerCase());
|
|
100
|
+
} );
|
|
101
|
+
|
|
102
|
+
if (this.format.match(/json/)) return this.jsonOutput(buckets);
|
|
103
|
+
|
|
104
|
+
var total = buckets.length;
|
|
105
|
+
var rows = buckets.slice(this.offset, this.offset + this.limit);
|
|
106
|
+
this.printPaginatedBoxTable({
|
|
107
|
+
title: is_filtered ? 'Filtered Storage Buckets' : 'All Storage Buckets',
|
|
108
|
+
header: ['Bucket ID', 'Title', 'Status', 'Author', 'Modified', 'Revision'],
|
|
109
|
+
rows: rows,
|
|
110
|
+
list: { length: total },
|
|
111
|
+
offset: this.offset,
|
|
112
|
+
limit: this.limit
|
|
113
|
+
}, bucket => {
|
|
114
|
+
return [
|
|
115
|
+
this.color('theme').bold(bucket.id),
|
|
116
|
+
bold(bucket.title),
|
|
117
|
+
this.getNiceEnabled(bucket.enabled),
|
|
118
|
+
bucket.username || gray('(Unknown)'),
|
|
119
|
+
this.getRelativeDateTime(bucket.modified, true),
|
|
120
|
+
bucket.revision || 1
|
|
121
|
+
];
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
this.printSuggestedCommands({
|
|
125
|
+
"View bucket details": "xy bucket BUCKET_ID_OR_TITLE",
|
|
126
|
+
"Create a bucket": "xy bucket create --title \"My Bucket\"",
|
|
127
|
+
"Write bucket data": "xy bucket write BUCKET_ID --data @data.json",
|
|
128
|
+
"Upload bucket files": "xy bucket upload BUCKET_ID --file FILE"
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async cmd_get_bucket() {
|
|
133
|
+
// Resolve an exact ID first, then allow a fuzzy title for read-only views.
|
|
134
|
+
await this.getMultiple();
|
|
135
|
+
if (!this.buckets.length) return this.die("No storage buckets found.");
|
|
136
|
+
|
|
137
|
+
var selector = '';
|
|
138
|
+
if (this.args.other.length) selector = this.args.other.join(' ');
|
|
139
|
+
else if (this.args.id) selector = this.args.id;
|
|
140
|
+
else if (this.args.title) selector = this.args.title;
|
|
141
|
+
else return this.dieUsage('bucket get');
|
|
142
|
+
|
|
143
|
+
var bucket = Tools.findObject(this.buckets, { id: selector }) ||
|
|
144
|
+
this.findObjectFuzzy(this.buckets, { title: selector });
|
|
145
|
+
if (!bucket) return this.die("Could not find bucket based on your criteria: " + selector);
|
|
146
|
+
|
|
147
|
+
var result = await this.fetchBucket(bucket.id);
|
|
148
|
+
if (this.format.match(/json/)) {
|
|
149
|
+
return this.jsonOutput({
|
|
150
|
+
bucket: result.bucket,
|
|
151
|
+
data: result.data,
|
|
152
|
+
files: result.files
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.printBoxList({
|
|
157
|
+
title: 'Bucket Summary',
|
|
158
|
+
rows: [
|
|
159
|
+
[ 'Bucket ID', gray(result.bucket.id) ],
|
|
160
|
+
[ 'Title', this.color('theme').bold(result.bucket.title) ],
|
|
161
|
+
[ 'Status', this.getNiceEnabled(result.bucket.enabled) ],
|
|
162
|
+
[ 'Icon', result.bucket.icon || gray('(None)') ],
|
|
163
|
+
[ 'Author', result.bucket.username || gray('(Unknown)') ],
|
|
164
|
+
[ 'Created', this.getNiceDateTime(result.bucket.created, true, true) ],
|
|
165
|
+
[ 'Modified', this.getNiceDateTime(result.bucket.modified, true, true) ],
|
|
166
|
+
[ 'Revision', result.bucket.revision || 1 ],
|
|
167
|
+
[ 'Data Keys', Tools.commify(Tools.numKeys(result.data || {})) ],
|
|
168
|
+
[ 'Files', Tools.commify((result.files || []).length) ]
|
|
169
|
+
]
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// User notes may contain multiple lines, so keep them out of the summary box.
|
|
173
|
+
if (result.bucket.notes) {
|
|
174
|
+
this.printUserNotes('BUCKET NOTES', result.bucket.notes);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
print( "\n " + this.color('theme').bold('BUCKET DATA') );
|
|
178
|
+
this.jsonOutput(result.data || {});
|
|
179
|
+
this.printBucketFiles(result.files || []);
|
|
180
|
+
|
|
181
|
+
this.printSuggestedCommands({
|
|
182
|
+
"Update bucket metadata": `xy bucket update ${bucket.id} [--KEY VALUE, ...]`,
|
|
183
|
+
"Export bucket metadata": `xy bucket ${bucket.id} --export bucket.json`,
|
|
184
|
+
"Write bucket data": `xy bucket write ${bucket.id} --data @data.json`,
|
|
185
|
+
"Upload files": `xy bucket upload ${bucket.id} --file FILE`,
|
|
186
|
+
"Download a file": result.files.length ? `xy bucket file download ${bucket.id} ${result.files[0].filename}` : '',
|
|
187
|
+
"Delete a file": result.files.length ? `xy bucket file delete ${bucket.id} ${result.files[0].filename}` : '',
|
|
188
|
+
"Empty bucket contents": `xy bucket empty ${bucket.id} --data --files`,
|
|
189
|
+
"Delete bucket": `xy bucket delete ${bucket.id}`
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async cmd_create_bucket() {
|
|
194
|
+
// Initial data belongs to revision 1, so create_bucket can safely accept it.
|
|
195
|
+
// Files still use the dedicated multipart upload after creation succeeds.
|
|
196
|
+
await this.getMultiple();
|
|
197
|
+
var files = this.consumeBucketFileArgs();
|
|
198
|
+
var params = {
|
|
199
|
+
enabled: true,
|
|
200
|
+
icon: '',
|
|
201
|
+
notes: '',
|
|
202
|
+
data: {}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
this.mergeDotArgs(params, this.args);
|
|
206
|
+
delete params.other;
|
|
207
|
+
this.assertBucketFields(params, ['id', 'title', 'enabled', 'icon', 'notes', 'data']);
|
|
208
|
+
|
|
209
|
+
if (!params.title || !String(params.title).trim()) return this.dieUsage('bucket create');
|
|
210
|
+
params.title = String(params.title).trim();
|
|
211
|
+
params.enabled = this.parseBucketBoolean(params.enabled, 'enabled');
|
|
212
|
+
this.requireBucketDataObject(params.data);
|
|
213
|
+
this.validateBucketUploadFiles(files);
|
|
214
|
+
|
|
215
|
+
var result = await this.callStandardAPI('createBucket', params, {
|
|
216
|
+
text: 'Creating storage bucket...'
|
|
217
|
+
});
|
|
218
|
+
if (this.dry) {
|
|
219
|
+
if (files.length) {
|
|
220
|
+
this.jsonOutput({ files: files });
|
|
221
|
+
this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without sending request.");
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
var uploaded_files = [];
|
|
227
|
+
if (files.length) {
|
|
228
|
+
var upload = await this.callStandardAPI('uploadBucketFiles', { id: result.bucket.id }, {
|
|
229
|
+
text: 'Uploading bucket files...',
|
|
230
|
+
files: files
|
|
231
|
+
});
|
|
232
|
+
uploaded_files = upload.files || [];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (this.format.match(/json/)) {
|
|
236
|
+
return this.jsonOutput({
|
|
237
|
+
bucket: result.bucket,
|
|
238
|
+
data: params.data,
|
|
239
|
+
files: uploaded_files
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.toast('✅', 'green', "Successfully created storage bucket: #" + result.bucket.id);
|
|
244
|
+
this.printSuggestedCommands({
|
|
245
|
+
"View bucket details": `xy bucket ${result.bucket.id}`,
|
|
246
|
+
"Update bucket metadata": `xy bucket update ${result.bucket.id} [--KEY VALUE, ...]`,
|
|
247
|
+
"Write bucket data": `xy bucket write ${result.bucket.id} --data @data.json`,
|
|
248
|
+
"Upload files": `xy bucket upload ${result.bucket.id} --file FILE`
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async cmd_update_bucket() {
|
|
253
|
+
// This command is intentionally metadata-only. Dedicated content APIs avoid
|
|
254
|
+
// changing revision and modified timestamps for data and file operations.
|
|
255
|
+
await this.getMultiple();
|
|
256
|
+
var id = this.consumeBucketID();
|
|
257
|
+
var bucket = this.requireBucketID(id);
|
|
258
|
+
|
|
259
|
+
delete this.args.id;
|
|
260
|
+
delete this.args.other;
|
|
261
|
+
|
|
262
|
+
this.printMutationSummary({
|
|
263
|
+
title: 'Update Storage Bucket',
|
|
264
|
+
rows: [
|
|
265
|
+
[ 'Bucket ID', gray(bucket.id) ],
|
|
266
|
+
[ 'Title', this.color('theme').bold(bucket.title) ]
|
|
267
|
+
]
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (!Tools.numKeys(this.args)) return this.die("No updates specified for storage bucket.");
|
|
271
|
+
this.printUpdateData(this.args);
|
|
272
|
+
|
|
273
|
+
this.assertBucketFields(this.args, ['title', 'enabled', 'icon', 'notes']);
|
|
274
|
+
|
|
275
|
+
var params = { id: id };
|
|
276
|
+
Tools.mergeHashInto(params, this.args);
|
|
277
|
+
if ('title' in params) {
|
|
278
|
+
if (!params.title || !String(params.title).trim()) return this.die("Bucket title cannot be empty.");
|
|
279
|
+
params.title = String(params.title).trim();
|
|
280
|
+
}
|
|
281
|
+
if ('enabled' in params) params.enabled = this.parseBucketBoolean(params.enabled, 'enabled');
|
|
282
|
+
|
|
283
|
+
await this.callStandardAPI('updateBucket', params, {
|
|
284
|
+
text: 'Updating bucket metadata...'
|
|
285
|
+
});
|
|
286
|
+
if (this.dry) return;
|
|
287
|
+
this.toast('✅', 'green', "Successfully updated storage bucket: #" + id);
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async cmd_write_bucket_data() {
|
|
291
|
+
// Shallow-merge arbitrary JSON through write_bucket_data. Fetch the merged
|
|
292
|
+
// object so JSON mode and the human response both show the resulting state.
|
|
293
|
+
await this.getMultiple();
|
|
294
|
+
var id = this.consumeBucketID();
|
|
295
|
+
this.requireBucketID(id);
|
|
296
|
+
delete this.args.id;
|
|
297
|
+
delete this.args.other;
|
|
298
|
+
|
|
299
|
+
var input = {};
|
|
300
|
+
this.mergeDotArgs(input, this.args);
|
|
301
|
+
this.assertBucketFields(input, ['data']);
|
|
302
|
+
if (!Object.prototype.hasOwnProperty.call(input, 'data')) return this.dieUsage('bucket write');
|
|
303
|
+
this.requireBucketDataObject(input.data);
|
|
304
|
+
|
|
305
|
+
var result = await this.callStandardAPI('writeBucketData', {
|
|
306
|
+
id: id,
|
|
307
|
+
data: input.data,
|
|
308
|
+
fetch: true
|
|
309
|
+
}, {
|
|
310
|
+
text: 'Writing bucket data...'
|
|
311
|
+
});
|
|
312
|
+
if (this.dry) return;
|
|
313
|
+
if (this.format.match(/json/)) return this.jsonOutput(result.data || {});
|
|
314
|
+
|
|
315
|
+
this.toast('✅', 'green', "Successfully wrote data to storage bucket: #" + id);
|
|
316
|
+
println( "\n " + this.color('theme').bold('MERGED BUCKET DATA') );
|
|
317
|
+
this.jsonOutput(result.data || {});
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
async cmd_upload_bucket_files() {
|
|
321
|
+
// Uploading uses the dedicated multipart API and leaves metadata untouched.
|
|
322
|
+
await this.getMultiple();
|
|
323
|
+
var id = this.consumeBucketID();
|
|
324
|
+
this.requireBucketID(id);
|
|
325
|
+
delete this.args.id;
|
|
326
|
+
delete this.args.other;
|
|
327
|
+
|
|
328
|
+
var files = this.consumeBucketFileArgs();
|
|
329
|
+
if (Tools.numKeys(this.args)) this.assertBucketFields(this.args, []);
|
|
330
|
+
if (!files.length) return this.dieUsage('bucket upload');
|
|
331
|
+
this.validateBucketUploadFiles(files);
|
|
332
|
+
|
|
333
|
+
var result = await this.callStandardAPI('uploadBucketFiles', { id: id }, {
|
|
334
|
+
text: 'Uploading bucket files...',
|
|
335
|
+
files: files
|
|
336
|
+
});
|
|
337
|
+
if (this.dry) return;
|
|
338
|
+
if (this.format.match(/json/)) return this.jsonOutput(result.files || []);
|
|
339
|
+
|
|
340
|
+
this.toast('✅', 'green', "Successfully uploaded " + Tools.commify(files.length) + " file(s) to storage bucket: #" + id);
|
|
341
|
+
this.printBucketFiles(result.files || []);
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async cmd_delete_bucket_file() {
|
|
345
|
+
// Resolve a file against the authoritative bucket file list, then delete by
|
|
346
|
+
// storage path so similarly named files can never select the wrong object.
|
|
347
|
+
await this.getMultiple();
|
|
348
|
+
var id = this.consumeBucketID(true);
|
|
349
|
+
this.requireBucketID(id);
|
|
350
|
+
var selector = this.args.other.shift() || this.args.filename || this.args.file;
|
|
351
|
+
if (!selector) return this.dieUsage('bucket file delete');
|
|
352
|
+
if (this.args.other.length) return this.die("Unexpected argument after bucket filename: " + this.args.other[0]);
|
|
353
|
+
|
|
354
|
+
delete this.args.id;
|
|
355
|
+
delete this.args.other;
|
|
356
|
+
delete this.args.filename;
|
|
357
|
+
delete this.args.file;
|
|
358
|
+
if (!this.args.confirm) return this.die("Please confirm the file delete by adding '--confirm'");
|
|
359
|
+
delete this.args.confirm;
|
|
360
|
+
if (Tools.numKeys(this.args)) this.assertBucketFields(this.args, []);
|
|
361
|
+
|
|
362
|
+
var result = await this.fetchBucket(id);
|
|
363
|
+
var file = this.findBucketFile(result.files, selector);
|
|
364
|
+
if (!file) return this.die("Could not find bucket file from exact filename or File ID: " + selector);
|
|
365
|
+
|
|
366
|
+
await this.callStandardAPI('deleteBucketFile', {
|
|
367
|
+
id: id,
|
|
368
|
+
path: file.path
|
|
369
|
+
}, {
|
|
370
|
+
text: 'Deleting bucket file...'
|
|
371
|
+
});
|
|
372
|
+
if (this.dry) return;
|
|
373
|
+
this.toast('✅', 'green', "Successfully deleted bucket file: " + file.filename);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
async cmd_empty_bucket() {
|
|
377
|
+
// Empty data, files, or both without deleting the bucket definition.
|
|
378
|
+
await this.getMultiple();
|
|
379
|
+
var id = this.consumeBucketID();
|
|
380
|
+
this.requireBucketID(id);
|
|
381
|
+
delete this.args.id;
|
|
382
|
+
delete this.args.other;
|
|
383
|
+
|
|
384
|
+
var empty_data = ('data' in this.args) ? this.parseBucketBoolean(this.args.data, 'data') : false;
|
|
385
|
+
var empty_files = ('files' in this.args) ? this.parseBucketBoolean(this.args.files, 'files') : false;
|
|
386
|
+
var empty_all = ('all' in this.args) ? this.parseBucketBoolean(this.args.all, 'all') : false;
|
|
387
|
+
delete this.args.data;
|
|
388
|
+
delete this.args.files;
|
|
389
|
+
delete this.args.all;
|
|
390
|
+
if (empty_all) empty_data = empty_files = true;
|
|
391
|
+
if (!empty_data && !empty_files) return this.dieUsage('bucket empty');
|
|
392
|
+
if (!this.args.confirm) return this.die("Please confirm emptying the bucket by adding '--confirm'");
|
|
393
|
+
delete this.args.confirm;
|
|
394
|
+
if (Tools.numKeys(this.args)) this.assertBucketFields(this.args, []);
|
|
395
|
+
|
|
396
|
+
await this.callStandardAPI('emptyBucket', {
|
|
397
|
+
id: id,
|
|
398
|
+
data: empty_data,
|
|
399
|
+
files: empty_files
|
|
400
|
+
}, {
|
|
401
|
+
text: 'Emptying storage bucket...'
|
|
402
|
+
});
|
|
403
|
+
if (this.dry) return;
|
|
404
|
+
|
|
405
|
+
var contents = empty_data && empty_files ? 'data and files' : (empty_data ? 'data' : 'files');
|
|
406
|
+
this.toast('✅', 'green', "Successfully emptied bucket " + contents + ": #" + id);
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
async cmd_delete_bucket() {
|
|
410
|
+
// Bucket deletion removes the definition and all stored contents permanently.
|
|
411
|
+
await this.getMultiple();
|
|
412
|
+
var id = this.consumeBucketID();
|
|
413
|
+
var bucket = this.requireBucketID(id);
|
|
414
|
+
delete this.args.id;
|
|
415
|
+
delete this.args.other;
|
|
416
|
+
this.printMutationSummary({
|
|
417
|
+
title: 'Delete Storage Bucket',
|
|
418
|
+
rows: [
|
|
419
|
+
[ 'Bucket ID', gray(bucket.id) ],
|
|
420
|
+
[ 'Title', this.color('theme').bold(bucket.title) ]
|
|
421
|
+
]
|
|
422
|
+
});
|
|
423
|
+
if (this.args.confirm !== true) {
|
|
424
|
+
this.toast('⚠️', 'orange', "Please confirm the storage bucket delete by adding '--confirm'.");
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
delete this.args.confirm;
|
|
428
|
+
if (Tools.numKeys(this.args)) this.assertBucketFields(this.args, []);
|
|
429
|
+
|
|
430
|
+
await this.callStandardAPI('deleteBucket', { id: id }, {
|
|
431
|
+
text: 'Deleting storage bucket...'
|
|
432
|
+
});
|
|
433
|
+
if (this.dry) return;
|
|
434
|
+
this.toast('✅', 'green', "Successfully deleted storage bucket and all contents: #" + id);
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
async cmd_download_bucket_file() {
|
|
438
|
+
// Bucket files are served from their storage path, not a named JSON API.
|
|
439
|
+
// Refuse to overwrite local files so a typo cannot destroy user data.
|
|
440
|
+
await this.getMultiple();
|
|
441
|
+
var id = this.consumeBucketID(true);
|
|
442
|
+
this.requireBucketID(id);
|
|
443
|
+
var selector = this.args.other.shift() || this.args.filename || this.args.file;
|
|
444
|
+
if (!selector) return this.dieUsage('bucket file download');
|
|
445
|
+
var output = this.args.other.shift() || this.args.output || this.args.download;
|
|
446
|
+
if (this.args.other.length) return this.die("Unexpected argument after output filename: " + this.args.other[0]);
|
|
447
|
+
|
|
448
|
+
delete this.args.id;
|
|
449
|
+
delete this.args.other;
|
|
450
|
+
delete this.args.filename;
|
|
451
|
+
delete this.args.file;
|
|
452
|
+
delete this.args.output;
|
|
453
|
+
delete this.args.download;
|
|
454
|
+
if (Tools.numKeys(this.args)) this.assertBucketFields(this.args, []);
|
|
455
|
+
|
|
456
|
+
var result = await this.fetchBucket(id);
|
|
457
|
+
var file = this.findBucketFile(result.files, selector);
|
|
458
|
+
if (!file) return this.die("Could not find bucket file from exact filename or File ID: " + selector);
|
|
459
|
+
if (!output || (output === true)) output = file.filename;
|
|
460
|
+
|
|
461
|
+
var dest_file = Path.resolve(String(output));
|
|
462
|
+
if (fs.existsSync(dest_file)) return this.die("Output file already exists: " + dest_file);
|
|
463
|
+
var request = {
|
|
464
|
+
// The file API prepends its own `files/` storage prefix.
|
|
465
|
+
path: file.path.replace(/^files\//, ''),
|
|
466
|
+
download: file.filename
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
if (this.dry || this.verbose) {
|
|
470
|
+
println( "\n " + this.color('theme').bold('FILE DOWNLOAD:') );
|
|
471
|
+
this.jsonOutput({
|
|
472
|
+
bucket: id,
|
|
473
|
+
file: file.filename,
|
|
474
|
+
request: request,
|
|
475
|
+
output: dest_file
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (this.dry) {
|
|
479
|
+
this.toast('⚠️', 'orange', bold("DRY RUN: ") + "Exiting without sending request.");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
println( "\n " + this.color('theme').bold('BUCKET FILE: ' + file.filename) );
|
|
484
|
+
println( " " + gray("Downloading to file: ") + dest_file );
|
|
485
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Downloading bucket file...') });
|
|
486
|
+
try {
|
|
487
|
+
var { err } = await this.api.file(request, { download: dest_file });
|
|
488
|
+
if (err) throw err;
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
cli.progress.end();
|
|
492
|
+
// The destination did not exist before this command, so any partial file
|
|
493
|
+
// here belongs to this failed download and is safe to remove.
|
|
494
|
+
if (fs.existsSync(dest_file)) fs.unlinkSync(dest_file);
|
|
495
|
+
return this.die(err);
|
|
496
|
+
}
|
|
497
|
+
cli.progress.end();
|
|
498
|
+
this.toast('✅', 'green', "Successfully downloaded bucket file: " + file.filename);
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
async fetchBucket(id) {
|
|
502
|
+
// Fetch complete bucket contents and normalize older empty responses.
|
|
503
|
+
cli.progress.start({ amount: 1, pct: false, text: gray('→ Loading storage bucket...') });
|
|
504
|
+
var { err, data } = await this.api.getBucket({ id: id });
|
|
505
|
+
cli.progress.end();
|
|
506
|
+
if (err) this.die(err);
|
|
507
|
+
data.data = data.data || {};
|
|
508
|
+
data.files = data.files || [];
|
|
509
|
+
return data;
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
consumeBucketID(allow_extra) {
|
|
513
|
+
// Consume an internal bucket ID from the positional slot or --id.
|
|
514
|
+
var id = this.args.id;
|
|
515
|
+
if (this.args.other && this.args.other.length) id = this.args.other.shift();
|
|
516
|
+
if (!id) this.die("Missing required Bucket ID argument.");
|
|
517
|
+
if (!allow_extra && this.args.other && this.args.other.length) {
|
|
518
|
+
this.die("Unexpected argument after Bucket ID: " + this.args.other[0]);
|
|
519
|
+
}
|
|
520
|
+
return String(id);
|
|
521
|
+
},
|
|
522
|
+
|
|
523
|
+
requireBucketID(id) {
|
|
524
|
+
// Mutating commands always require an exact internal ID.
|
|
525
|
+
var bucket = Tools.findObject(this.buckets, { id: id });
|
|
526
|
+
if (!bucket) this.die("Could not find bucket from exact ID: " + id);
|
|
527
|
+
return bucket;
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
consumeBucketFileArgs() {
|
|
531
|
+
// Accept repeated --file, --files with an array, or either spelling alone.
|
|
532
|
+
var files = [];
|
|
533
|
+
if ('file' in this.args) files = files.concat(Tools.alwaysArray(this.args.file));
|
|
534
|
+
if ('files' in this.args) files = files.concat(Tools.alwaysArray(this.args.files));
|
|
535
|
+
delete this.args.file;
|
|
536
|
+
delete this.args.files;
|
|
537
|
+
return files.map( file => String(file) ).filter( file => !!file );
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
validateBucketUploadFiles(files) {
|
|
541
|
+
// Fail before making a request if any local upload path is unusable.
|
|
542
|
+
files.forEach( file => {
|
|
543
|
+
if (!fs.existsSync(file)) this.die("Upload file not found: " + file);
|
|
544
|
+
var stats = fs.statSync(file);
|
|
545
|
+
if (!stats.isFile()) this.die("Upload path is not a file: " + file);
|
|
546
|
+
});
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
findBucketFile(files, selector) {
|
|
550
|
+
// Filenames are unique after server normalization; File IDs are also safe.
|
|
551
|
+
selector = String(selector);
|
|
552
|
+
return Tools.findObject(files || [], { filename: selector }) ||
|
|
553
|
+
Tools.findObject(files || [], { id: selector });
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
requireBucketDataObject(data) {
|
|
557
|
+
// Bucket data is always a JSON hash. Arrays and scalars would violate the
|
|
558
|
+
// shallow-merge contract of write_bucket_data.
|
|
559
|
+
if (!Tools.isaHash(data)) this.die("Bucket data must be a JSON object.");
|
|
560
|
+
return data;
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
parseBucketBoolean(value, name) {
|
|
564
|
+
// Accept both normal command-line booleans and a few friendly spellings.
|
|
565
|
+
if ((value === true) || (value === 1) || (value === '1')) return true;
|
|
566
|
+
if ((value === false) || (value === 0) || (value === '0')) return false;
|
|
567
|
+
if (String(value).match(/^(yes|on)$/i)) return true;
|
|
568
|
+
if (String(value).match(/^(no|off)$/i)) return false;
|
|
569
|
+
this.die("Invalid boolean value for '" + name + "': " + value);
|
|
570
|
+
},
|
|
571
|
+
|
|
572
|
+
assertBucketFields(object, allowed) {
|
|
573
|
+
// Catch typos instead of silently storing accidental metadata properties.
|
|
574
|
+
var unknown = Object.keys(object).filter( key => !allowed.includes(key) );
|
|
575
|
+
if (unknown.length) this.die("Unsupported bucket option: --" + unknown[0]);
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
getNiceBucketFileSource(file) {
|
|
579
|
+
// Files may originate from a user upload, job, or server-side action.
|
|
580
|
+
if (file.username) return file.username;
|
|
581
|
+
if (file.job) return 'Job #' + file.job;
|
|
582
|
+
if (file.server) return this.getNiceServer(file.server);
|
|
583
|
+
return gray('(Unknown)');
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
printBucketFiles(files) {
|
|
587
|
+
// File IDs and normalized filenames are both displayed and accepted by file
|
|
588
|
+
// commands. Keep the ID column free of decorative prefixes for copying.
|
|
589
|
+
files = files.slice(0).sort( function(a, b) {
|
|
590
|
+
return String(a.filename || '').localeCompare(String(b.filename || ''));
|
|
591
|
+
} );
|
|
592
|
+
this.printBoxTable({
|
|
593
|
+
title: 'Bucket Files',
|
|
594
|
+
header: ['File ID', 'Filename', 'Size', 'Source', 'Modified'],
|
|
595
|
+
rows: files.map( file => {
|
|
596
|
+
return [
|
|
597
|
+
this.color('theme').bold(file.id),
|
|
598
|
+
cli.emoji('📄') + ' ' + bold(file.filename),
|
|
599
|
+
Tools.getTextFromBytes(file.size || 0),
|
|
600
|
+
this.getNiceBucketFileSource(file),
|
|
601
|
+
this.getRelativeDateTime(file.date, true)
|
|
602
|
+
];
|
|
603
|
+
})
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
}; // module.exports
|