@getxflow/cli 0.9.1 → 0.10.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/dist/bin.js CHANGED
@@ -24,6 +24,7 @@ const org_1 = require("./commands/org");
24
24
  const schedules_1 = require("./commands/schedules");
25
25
  const skills_1 = require("./commands/skills");
26
26
  const sources_1 = require("./commands/sources");
27
+ const storage_1 = require("./commands/storage");
27
28
  const deploy_1 = require("./commands/deploy");
28
29
  const update_1 = require("./commands/update");
29
30
  async function run(args) {
@@ -164,6 +165,20 @@ async function run(args) {
164
165
  return;
165
166
  }
166
167
  throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, schema, query and migrate');
168
+ case 'storage':
169
+ if (second === 'push') {
170
+ await (0, storage_1.storagePush)(rest);
171
+ return;
172
+ }
173
+ if (second === 'rm' || second === 'remove') {
174
+ await (0, storage_1.storageRemove)(rest);
175
+ return;
176
+ }
177
+ if (second === undefined || second === 'ls' || second === 'list') {
178
+ await (0, storage_1.storageList)(rest);
179
+ return;
180
+ }
181
+ throw new errors_1.CliError(`Unknown command: storage ${second}`, 'Available: ls, push and rm');
167
182
  case 'status':
168
183
  await (0, sources_1.status)();
169
184
  return;
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.contentTypeFor = contentTypeFor;
4
+ exports.planBatches = planBatches;
5
+ exports.caseCollisions = caseCollisions;
6
+ exports.storageList = storageList;
7
+ exports.storagePush = storagePush;
8
+ exports.storageRemove = storageRemove;
9
+ const node_fs_1 = require("node:fs");
10
+ const node_path_1 = require("node:path");
11
+ const node_stream_1 = require("node:stream");
12
+ const api_1 = require("../api");
13
+ const args_1 = require("../args");
14
+ const config_1 = require("../config");
15
+ const errors_1 = require("../errors");
16
+ const session_1 = require("../session");
17
+ const ui_1 = require("../ui");
18
+ /**
19
+ * File storage of the project: the place for heavy static assets of the
20
+ * application.
21
+ *
22
+ * Sources are capped at 10 MB and are rebuilt on every deploy, so photos, video
23
+ * and PDFs do not belong there. Files here live beside the versions instead:
24
+ * they survive a build, they are metered against the plan of the organization,
25
+ * and their addresses do not change when the application is rebuilt.
26
+ */
27
+ /** Files per batch, and bytes per batch: whichever comes first closes it. */
28
+ const BATCH_FILES = 10;
29
+ const BATCH_BYTES = 20 * 1024 * 1024;
30
+ /** Uploads running at the same time. */
31
+ const PARALLEL_UPLOADS = 4;
32
+ /** Below this a file is sent as one buffer; above it, streamed off disk. */
33
+ const STREAM_OVER_BYTES = 16 * 1024 * 1024;
34
+ const UPLOAD_TIMEOUT_MS = 10 * 60_000;
35
+ const CONTENT_TYPES = {
36
+ '.png': 'image/png',
37
+ '.jpg': 'image/jpeg',
38
+ '.jpeg': 'image/jpeg',
39
+ '.gif': 'image/gif',
40
+ '.webp': 'image/webp',
41
+ '.avif': 'image/avif',
42
+ '.svg': 'image/svg+xml',
43
+ '.ico': 'image/x-icon',
44
+ '.mp4': 'video/mp4',
45
+ '.webm': 'video/webm',
46
+ '.mov': 'video/quicktime',
47
+ '.mp3': 'audio/mpeg',
48
+ '.wav': 'audio/wav',
49
+ '.ogg': 'audio/ogg',
50
+ '.pdf': 'application/pdf',
51
+ '.json': 'application/json',
52
+ '.csv': 'text/csv',
53
+ '.txt': 'text/plain',
54
+ '.md': 'text/markdown',
55
+ '.html': 'text/html',
56
+ '.css': 'text/css',
57
+ '.js': 'text/javascript',
58
+ '.woff': 'font/woff',
59
+ '.woff2': 'font/woff2',
60
+ '.ttf': 'font/ttf',
61
+ '.otf': 'font/otf',
62
+ '.zip': 'application/zip',
63
+ };
64
+ /** Unknown extensions go up as bytes: the browser then trusts the file name. */
65
+ function contentTypeFor(name) {
66
+ return CONTENT_TYPES[(0, node_path_1.extname)(name).toLowerCase()] ?? 'application/octet-stream';
67
+ }
68
+ /**
69
+ * Files of a directory, recursively.
70
+ *
71
+ * A walker of its own instead of the one the sources use: that one reads every
72
+ * file into memory to hash it and rewrites CRLF into LF for anything without a
73
+ * NUL byte early on, which would quietly damage SVG, CSV and subtitles on the
74
+ * way to the bucket. It also obeys .xflowignore, and a media folder is exactly
75
+ * what one is asked to put there so it stays out of the 10 MB archive.
76
+ *
77
+ * Dot entries and symlinks are skipped: pointing push at a working copy should
78
+ * not upload .git, and a link is not ours to follow.
79
+ */
80
+ function collectMedia(root, prefix = '') {
81
+ const found = [];
82
+ for (const entry of (0, node_fs_1.readdirSync)(root, { withFileTypes: true })) {
83
+ if (entry.name.startsWith('.') || entry.isSymbolicLink())
84
+ continue;
85
+ if (entry.name === 'Thumbs.db')
86
+ continue;
87
+ const absolute = (0, node_path_1.join)(root, entry.name);
88
+ const remote = prefix ? `${prefix}/${entry.name}` : entry.name;
89
+ if (entry.isDirectory()) {
90
+ found.push(...collectMedia(absolute, remote));
91
+ continue;
92
+ }
93
+ if (!entry.isFile())
94
+ continue;
95
+ found.push({ absolute, remote, size: (0, node_fs_1.statSync)(absolute).size });
96
+ }
97
+ return found;
98
+ }
99
+ /**
100
+ * Split the upload into batches.
101
+ *
102
+ * The window where an object is in the bucket but not yet recorded lasts from
103
+ * the PUT to the confirm, and everything in that window is invisible to the
104
+ * project while still taking up the quota. Ten files or twenty megabytes keep
105
+ * the window that size no matter whether these are icons or video: a file
106
+ * heavier than the limit goes up alone.
107
+ */
108
+ function planBatches(files, maxFiles = BATCH_FILES, maxBytes = BATCH_BYTES) {
109
+ const batches = [];
110
+ let batch = [];
111
+ let bytes = 0;
112
+ for (const file of files) {
113
+ if (batch.length > 0 && (batch.length >= maxFiles || bytes + file.size > maxBytes)) {
114
+ batches.push(batch);
115
+ batch = [];
116
+ bytes = 0;
117
+ }
118
+ batch.push(file);
119
+ bytes += file.size;
120
+ }
121
+ if (batch.length > 0)
122
+ batches.push(batch);
123
+ return batches;
124
+ }
125
+ /**
126
+ * Paths that differ only in case.
127
+ *
128
+ * The bucket tells them apart and Windows does not, so a folder that looks like
129
+ * one file locally becomes two files on the platform, and the application asks
130
+ * for whichever the code spells.
131
+ */
132
+ function caseCollisions(files) {
133
+ const seen = new Map();
134
+ for (const file of files) {
135
+ const key = file.remote.toLowerCase();
136
+ seen.set(key, [...(seen.get(key) ?? []), file.remote]);
137
+ }
138
+ return [...seen.values()].filter((group) => group.length > 1);
139
+ }
140
+ async function fetchPage(client, projectId, params) {
141
+ const query = new URLSearchParams(params).toString();
142
+ return (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/storage?${query}`);
143
+ }
144
+ /** Everything under a folder, following the cursor to the end. */
145
+ async function fetchAll(client, projectId, folder) {
146
+ const files = [];
147
+ let cursor = null;
148
+ do {
149
+ const params = { limit: '500' };
150
+ if (folder)
151
+ params.folder = folder;
152
+ if (cursor)
153
+ params.cursor = cursor;
154
+ const page = await fetchPage(client, projectId, params);
155
+ files.push(...page.files);
156
+ cursor = page.next_cursor;
157
+ } while (cursor);
158
+ return files;
159
+ }
160
+ async function storageList(args) {
161
+ const { config } = (0, config_1.requireProject)();
162
+ const client = (0, session_1.connect)(config);
163
+ const folder = args.words[1] ?? '';
164
+ const files = await fetchAll(client, config.projectId, folder);
165
+ if ((0, args_1.flagBool)(args, 'json')) {
166
+ (0, ui_1.out)(JSON.stringify({ files }, null, 2));
167
+ return;
168
+ }
169
+ if (files.length === 0) {
170
+ (0, ui_1.note)(folder ? `No files under ${folder}` : 'The project has no files in storage');
171
+ (0, ui_1.note)((0, ui_1.dim)(' To upload a folder: xflow storage push ./media --to media'));
172
+ return;
173
+ }
174
+ (0, ui_1.table)(files.map((file) => [file.path, (0, ui_1.formatBytes)(file.size), file.url]));
175
+ (0, ui_1.note)((0, ui_1.dim)(` ${files.length} file(s), ${(0, ui_1.formatBytes)(files.reduce((sum, f) => sum + f.size, 0))}`));
176
+ }
177
+ /** PUT the bytes straight into the bucket: they never pass through the platform. */
178
+ async function putObject(file, ticket) {
179
+ const contentType = contentTypeFor(file.remote);
180
+ // Small files go as one buffer, big ones off the disk: 200 MB in memory is a
181
+ // price nothing here pays for.
182
+ const body = file.size > STREAM_OVER_BYTES
183
+ ? node_stream_1.Readable.toWeb((0, node_fs_1.createReadStream)(file.absolute))
184
+ : (0, node_fs_1.readFileSync)(file.absolute);
185
+ // `duplex` is required by Node to send a stream as a request body and is not
186
+ // in the type of fetch, hence the cast.
187
+ const init = {
188
+ method: 'PUT',
189
+ body,
190
+ headers: { 'Content-Type': contentType, 'Content-Length': String(file.size) },
191
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
192
+ duplex: 'half',
193
+ };
194
+ const response = await fetch(ticket.upload_url, init);
195
+ if (!response.ok) {
196
+ throw new errors_1.CliError(`${file.remote}: the bucket rejected the upload (${response.status})`, 'Run the command again: what was uploaded before is kept');
197
+ }
198
+ }
199
+ /** Run tasks a few at a time, keeping the order of the results. */
200
+ async function inParallel(items, limit, task) {
201
+ const results = new Array(items.length);
202
+ let next = 0;
203
+ async function worker() {
204
+ while (next < items.length) {
205
+ const index = next++;
206
+ results[index] = await task(items[index]);
207
+ }
208
+ }
209
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
210
+ return results;
211
+ }
212
+ async function storagePush(args) {
213
+ const { config } = (0, config_1.requireProject)();
214
+ const client = (0, session_1.connect)(config);
215
+ const source = args.words[1];
216
+ if (!source) {
217
+ throw new errors_1.CliError('A folder or a file is required', 'For example: xflow storage push ./media --to media');
218
+ }
219
+ const absolute = (0, node_path_1.resolve)(source);
220
+ let stat;
221
+ try {
222
+ stat = (0, node_fs_1.statSync)(absolute);
223
+ }
224
+ catch {
225
+ throw new errors_1.CliError(`No such file or folder: ${source}`);
226
+ }
227
+ const target = ((0, args_1.flagString)(args, 'to') ?? '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
228
+ const local = stat.isDirectory()
229
+ ? collectMedia(absolute)
230
+ : [{ absolute, remote: (0, node_path_1.basename)(absolute), size: stat.size }];
231
+ if (local.length === 0)
232
+ throw new errors_1.CliError(`Nothing to upload in ${source}`);
233
+ const files = local
234
+ .map((file) => ({ ...file, remote: target ? `${target}/${file.remote}` : file.remote }))
235
+ .sort((a, b) => a.remote.localeCompare(b.remote));
236
+ for (const group of caseCollisions(files)) {
237
+ (0, ui_1.warn)(`Names that differ only in case: ${group.join(', ')}`);
238
+ (0, ui_1.note)((0, ui_1.dim)(' The platform keeps them as separate files'));
239
+ }
240
+ const replace = (0, args_1.flagBool)(args, 'replace');
241
+ const asJson = (0, args_1.flagBool)(args, 'json');
242
+ // What is already there decides what to send: the same run repeated has to be
243
+ // cheap and quiet, not a pile of conflicts.
244
+ const known = new Map((await fetchAll(client, config.projectId, target)).map((f) => [f.path, f]));
245
+ const outcome = { uploaded: [], skipped: [], failed: [] };
246
+ const pending = [];
247
+ for (const file of files) {
248
+ const remote = known.get(file.remote);
249
+ if (!remote) {
250
+ pending.push(file);
251
+ continue;
252
+ }
253
+ if (remote.size === file.size) {
254
+ outcome.skipped.push({ path: file.remote, reason: 'already there' });
255
+ continue;
256
+ }
257
+ if (!replace) {
258
+ outcome.skipped.push({ path: file.remote, reason: 'differs, kept as is' });
259
+ continue;
260
+ }
261
+ pending.push(file);
262
+ }
263
+ if (pending.length === 0) {
264
+ report(outcome, asJson);
265
+ return;
266
+ }
267
+ const batches = planBatches(pending);
268
+ if (!asJson) {
269
+ const bytes = pending.reduce((sum, file) => sum + file.size, 0);
270
+ (0, ui_1.step)(`Uploading ${pending.length} file(s), ${(0, ui_1.formatBytes)(bytes)}`);
271
+ }
272
+ for (const batch of batches) {
273
+ const { files: tickets } = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage/upload-url`, {
274
+ method: 'POST',
275
+ body: {
276
+ files: batch.map((file) => ({
277
+ path: file.remote,
278
+ content_type: contentTypeFor(file.remote),
279
+ size: file.size,
280
+ })),
281
+ overwrite: replace,
282
+ },
283
+ });
284
+ const byPath = new Map(tickets.map((ticket) => [ticket.path, ticket]));
285
+ const done = [];
286
+ const results = await inParallel(batch, PARALLEL_UPLOADS, async (file) => {
287
+ const ticket = byPath.get(file.remote);
288
+ if (!ticket)
289
+ return { file, error: 'the platform did not return an upload address' };
290
+ try {
291
+ await putObject(file, ticket);
292
+ return { file, ticket };
293
+ }
294
+ catch (e) {
295
+ return { file, error: e instanceof Error ? e.message : String(e) };
296
+ }
297
+ });
298
+ for (const result of results) {
299
+ if ('ticket' in result)
300
+ done.push({ file: result.file, ticket: result.ticket });
301
+ else
302
+ outcome.failed.push({ path: result.file.remote, reason: result.error });
303
+ }
304
+ if (done.length === 0)
305
+ continue;
306
+ // Confirm right after the batch, not at the end: an object that is in the
307
+ // bucket without a record is invisible to the project and still takes up
308
+ // the quota.
309
+ const confirmed = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage/confirm`, {
310
+ method: 'POST',
311
+ body: { files: done.map(({ ticket }) => ({ s3_key: ticket.s3_key })) },
312
+ });
313
+ outcome.uploaded.push(...confirmed.files.map((file) => ({ path: file.path, url: file.url, size: file.size })));
314
+ for (const failure of confirmed.failed) {
315
+ const owner = done.find(({ ticket }) => ticket.s3_key === failure.s3_key);
316
+ outcome.failed.push({ path: owner?.file.remote ?? failure.s3_key, reason: failure.error });
317
+ }
318
+ }
319
+ report(outcome, asJson);
320
+ }
321
+ function report(outcome, asJson) {
322
+ if (asJson) {
323
+ (0, ui_1.out)(JSON.stringify(outcome, null, 2));
324
+ return;
325
+ }
326
+ if (outcome.uploaded.length > 0) {
327
+ (0, ui_1.table)(outcome.uploaded.map((file) => [file.path, (0, ui_1.formatBytes)(file.size), file.url]));
328
+ }
329
+ const parts = [`uploaded ${outcome.uploaded.length}`];
330
+ if (outcome.skipped.length > 0)
331
+ parts.push(`skipped ${outcome.skipped.length}`);
332
+ if (outcome.failed.length > 0)
333
+ parts.push(`failed ${outcome.failed.length}`);
334
+ if (outcome.failed.length > 0) {
335
+ for (const file of outcome.failed)
336
+ (0, ui_1.warn)(`${file.path}: ${file.reason}`);
337
+ throw new errors_1.CliError(parts.join(', '), 'Run the command again: what went up is kept and skipped');
338
+ }
339
+ (0, ui_1.ok)(parts.join(', '));
340
+ if (outcome.uploaded.length > 0) {
341
+ (0, ui_1.note)((0, ui_1.dim)(' Addresses are permanent: replacing a file keeps its address'));
342
+ (0, ui_1.note)((0, ui_1.dim)(' The whole list in one piece: xflow storage ls --json'));
343
+ }
344
+ }
345
+ async function storageRemove(args) {
346
+ const { config } = (0, config_1.requireProject)();
347
+ const client = (0, session_1.connect)(config);
348
+ const folder = (0, args_1.flagString)(args, 'folder');
349
+ const url = args.words[1];
350
+ if ((!url && !folder) || (url && folder)) {
351
+ throw new errors_1.CliError('Either the address of a file or --folder is required', 'For example: xflow storage rm https://app.getxflow.com/api/storage/files/…/view, or xflow storage rm --folder photos --yes');
352
+ }
353
+ const query = new URLSearchParams(url ? { url } : { folder: folder });
354
+ if ((0, args_1.flagBool)(args, 'yes'))
355
+ query.set('confirm', 'true');
356
+ let result;
357
+ try {
358
+ result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/storage?${query.toString()}`, { method: 'DELETE' });
359
+ }
360
+ catch (e) {
361
+ // The platform counts what a folder holds and refuses until the answer is
362
+ // seen. Said in the words of this command, not of the API.
363
+ if (e instanceof api_1.ApiError && e.code === 'confirm_required') {
364
+ throw new errors_1.CliError(e.message, `To delete them anyway: xflow storage rm --folder ${folder} --yes`);
365
+ }
366
+ throw e;
367
+ }
368
+ (0, ui_1.ok)(`${(0, ui_1.bold)(String(result.deleted))} file(s) deleted`);
369
+ (0, ui_1.note)((0, ui_1.dim)(' There is no undo and no copy on the platform side'));
370
+ }