@getxflow/cli 0.9.0 → 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/README.md CHANGED
@@ -20,7 +20,7 @@ else's code belongs next to it.
20
20
  | `org` / `org switch` | organizations with a stored key, and which one this folder works with |
21
21
  | `init` / `link` / `templates` | new project, link a folder to an existing one, which templates there are |
22
22
  | `status` / `pull` | state of the sources on the server and fetching them back |
23
- | `deploy` / `publish` / `rollback` / `deployments` | build, publish, roll back, version history |
23
+ | `deploy` / `publish` / `rollback` / `deployments` | build, publish, serve earlier pages, version history |
24
24
  | `db status` / `db migrate` | migrations from `migrations/*.sql` with a gate on destructive ones |
25
25
  | `db schema` / `db query` | tables and columns, reading data in a read-only transaction |
26
26
  | `functions list` | cloud functions of the project from `functions/<name>/index.ts`, shipped by `deploy` |
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;
@@ -18,6 +18,8 @@ const sources_1 = require("./sources");
18
18
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
19
  /** Slightly longer than the platform's own build timeout. */
20
20
  const WAIT_LIMIT_MS = 16 * 60_000;
21
+ /** Said the same way with a terminal and without one. */
22
+ const REMOVAL_IS_FINAL = 'Removal is final: a function created again later gets a different address, schedules included';
21
23
  /** Print every check violation at once. */
22
24
  function reportIssues(e) {
23
25
  const issues = e.issues ?? [];
@@ -81,13 +83,19 @@ async function startBuild(client, projectId, revision, allowRemovals) {
81
83
  catch (e) {
82
84
  if (e instanceof api_1.ApiError && e.issues?.length)
83
85
  reportIssues(e);
84
- const removing = e instanceof api_1.ApiError && e.code === 'confirm_required' ? (e.removing ?? []) : [];
85
- // No terminal (CI, an agent): the flag is the only way to say yes, and the
86
- // platform message already names it.
87
- if (removing.length === 0 || process.stdin.isTTY !== true || process.stderr.isTTY !== true)
86
+ if (!(e instanceof api_1.ApiError) || e.code !== 'confirm_required')
88
87
  throw e;
88
+ const removing = e.removing ?? [];
89
+ if (removing.length === 0)
90
+ throw e;
91
+ // No terminal (CI, an agent): the flag is the only way to say yes. The platform
92
+ // hint names the API field, so translate it into the flag this CLI accepts.
93
+ if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
94
+ throw new errors_1.CliError(e.message, `${REMOVAL_IS_FINAL}. If that is intended, retry with --allow-removals. ` +
95
+ 'If it is not, bring the functions/<name>/index.ts directories back');
96
+ }
89
97
  (0, ui_1.warn)(`Gone from the sources, the build would remove them: ${removing.join(', ')}`);
90
- (0, ui_1.note)((0, ui_1.dim)(' Removal is final: a function created again later gets a different address, schedules included'));
98
+ (0, ui_1.note)((0, ui_1.dim)(` ${REMOVAL_IS_FINAL}`));
91
99
  const answer = await (0, prompt_1.select)('Remove them from the cloud?', [
92
100
  { label: 'No, stop here', hint: 'bring the functions/<name>/index.ts directories back' },
93
101
  { label: 'Yes, remove and build', hint: removing.join(', ') },
@@ -158,7 +166,9 @@ async function deploy(args) {
158
166
  catch {
159
167
  (0, ui_1.warn)('Could not refresh the function addresses in .env, the build itself is fine');
160
168
  }
161
- (0, ui_1.note)((0, ui_1.dim)(' Show it to visitors: xflow publish'));
169
+ (0, ui_1.note)((0, ui_1.dim)(' The cloud functions of this build are already live: they are one per project'));
170
+ (0, ui_1.note)((0, ui_1.dim)(' and are not held back by publishing'));
171
+ (0, ui_1.note)((0, ui_1.dim)(' Show the new pages to visitors: xflow publish'));
162
172
  }
163
173
  async function publish() {
164
174
  const { root, config } = (0, config_1.requireProject)();
@@ -169,9 +179,11 @@ async function publish() {
169
179
  (0, ui_1.ok)('This version is already published');
170
180
  }
171
181
  else {
172
- (0, ui_1.ok)(`Version ${result.deploy_id} published: visitors see it now`);
182
+ (0, ui_1.ok)(`Version ${result.deploy_id} published: visitors see its pages now`);
173
183
  }
174
184
  (0, ui_1.out)(result.project_url);
185
+ (0, ui_1.note)((0, ui_1.dim)(' Publishing moves the pages only. Cloud functions are one per project: visitors'));
186
+ (0, ui_1.note)((0, ui_1.dim)(' have been running the code of the last xflow deploy since it finished'));
175
187
  }
176
188
  async function rollback(args) {
177
189
  const { root, config } = (0, config_1.requireProject)();
@@ -181,12 +193,15 @@ async function rollback(args) {
181
193
  }
182
194
  const client = await (0, session_1.connectProject)(root, config);
183
195
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/rollback`, { method: 'POST', body: { deploy_id: deployId } });
184
- (0, ui_1.ok)(`The dev version of the project is switched to ${result.deploy_id}`);
196
+ (0, ui_1.ok)(`The project now serves the pages of build ${result.deploy_id}`);
185
197
  (0, ui_1.out)(result.project_url);
198
+ (0, ui_1.note)((0, ui_1.dim)(' Only the pages came back. Cloud functions and the database are one per project:'));
199
+ (0, ui_1.note)((0, ui_1.dim)(' they are not versioned and stay as they are now'));
186
200
  if (result.revision !== null) {
187
- (0, ui_1.note)((0, ui_1.dim)(` The code of this version: xflow pull --revision ${result.revision} --into ./v${result.deploy_id}`));
201
+ (0, ui_1.note)((0, ui_1.dim)(` The code of this build: xflow pull --revision ${result.revision} --into ../v${result.deploy_id}`));
202
+ (0, ui_1.note)((0, ui_1.dim)(' Building that code over the current one is a separate step: it overwrites the server revision'));
188
203
  }
189
- (0, ui_1.note)((0, ui_1.dim)(' Visitors see it only after xflow publish'));
204
+ (0, ui_1.note)((0, ui_1.dim)(' Visitors see these pages only after xflow publish'));
190
205
  }
191
206
  async function deployments() {
192
207
  const { root, config } = (0, config_1.requireProject)();
@@ -203,4 +218,6 @@ async function deployments() {
203
218
  (0, ui_1.formatAge)(d.deployed_at ?? d.created_at),
204
219
  [d.is_dev ? (0, ui_1.bold)('dev') : '', d.is_live ? (0, ui_1.bold)('live') : ''].filter(Boolean).join(' '),
205
220
  ]));
221
+ (0, ui_1.note)((0, ui_1.dim)(' Serve the pages of an earlier build: xflow rollback <number>'));
222
+ (0, ui_1.note)((0, ui_1.dim)(' Fetch the code of a build: xflow pull --revision <its revision> --into ../copy'));
206
223
  }
@@ -66,7 +66,7 @@ async function confirmForce(client, projectId, server, local) {
66
66
  (0, ui_1.warn)('Could not read the server copy: the list of disappearing files is unavailable');
67
67
  }
68
68
  (0, ui_1.note)('');
69
- const confirmed = await (0, ui_1.confirmWord)(`This overwrites work that is not yours, with no way to recover it from the platform.`, card.name);
69
+ const confirmed = await (0, ui_1.confirmWord)(`The server revision will be replaced by the contents of this folder. Anything newer on the platform goes away, and it cannot be recovered from here.`, card.name);
70
70
  if (!confirmed)
71
71
  throw new errors_1.CliError('Cancelled');
72
72
  }
@@ -175,9 +175,12 @@ async function status() {
175
175
  const client = await (0, session_1.connectProject)(root, config);
176
176
  const tree = prepareTree(root, config);
177
177
  const state = (0, config_1.readState)(root);
178
- const [card, server] = await Promise.all([
178
+ const [card, server, history] = await Promise.all([
179
179
  (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}`),
180
180
  latestRevision(client, config.projectId),
181
+ // History only decorates the output, so its failure must not take the command
182
+ // down with it: without it the line below simply says less.
183
+ (0, api_1.apiJson)(client, `/api/v1/projects/${config.projectId}/deployments`).catch(() => ({ deployments: [] })),
181
184
  ]);
182
185
  (0, ui_1.out)(`${(0, ui_1.bold)(card.name)} ${(0, ui_1.dim)(card.id)}`);
183
186
  (0, ui_1.out)('');
@@ -200,9 +203,28 @@ async function status() {
200
203
  (0, ui_1.note)((0, ui_1.dim)(' Send and build them: xflow deploy'));
201
204
  }
202
205
  }
206
+ // Which revision the built version came from. Silent when there is nothing to say:
207
+ // the row fell out of the truncated history, it predates revisions, or a build is
208
+ // running right now and the answer would change in a minute.
209
+ const running = history.deployments.some((d) => ['pending', 'building', 'uploading'].includes(d.status));
210
+ const devRow = running ? null : (history.deployments.find((d) => d.is_dev) ?? null);
211
+ const builtFrom = devRow?.revision ?? null;
212
+ // The dev address holds a build that is not the newest one: the project was put back
213
+ // on earlier pages on purpose. Saying "the code is newer" here would name the wrong
214
+ // reason and send the developer to build what is already built.
215
+ const newest = history.deployments.find((d) => d.status === 'deployed');
216
+ const rolledBack = devRow?.status === 'deployed' && !!newest && newest.deploy_id !== devRow.deploy_id;
203
217
  (0, ui_1.out)('');
204
- (0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}`);
218
+ (0, ui_1.out)(`Built version: ${card.dev_deploy_id ?? '(no build)'}` +
219
+ (builtFrom !== null ? ` from revision ${builtFrom}` : ''));
205
220
  (0, ui_1.out)(`Visitors see: ${card.live_deploy_id ?? '(never published)'}`);
221
+ if (rolledBack) {
222
+ (0, ui_1.note)((0, ui_1.dim)(' The project serves the pages of an earlier build: a newer one is in the history'));
223
+ (0, ui_1.note)((0, ui_1.dim)(' Cloud functions and the database are not versioned and stay as they are'));
224
+ }
225
+ else if (builtFrom !== null && server && builtFrom < server.revision) {
226
+ (0, ui_1.note)((0, ui_1.dim)(` The built version is behind the sources: revision ${builtFrom} against ${server.revision} on the server`));
227
+ }
206
228
  // Two different problems, two different fixes: an expiring token is renewed by
207
229
  // any build, a revoked one is not renewed by anything until a human reconnects
208
230
  // the account. Telling them apart saves a pointless rebuild.
@@ -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
+ }