bytifi 0.1.1 → 0.1.2

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
@@ -4,13 +4,15 @@ Official command-line tool for encrypting and uploading files to [Bytifi](https:
4
4
 
5
5
  ## Install
6
6
 
7
+ ### npm (all platforms)
8
+
7
9
  ```bash
8
10
  npm install -g bytifi
9
11
  ```
10
12
 
11
13
  Requires **Node.js 18+**.
12
14
 
13
- Or install from source:
15
+ Or from source:
14
16
 
15
17
  ```bash
16
18
  git clone https://github.com/jpwcguy/Bytifi.git
@@ -18,14 +20,33 @@ cd Bytifi
18
20
  npm link
19
21
  ```
20
22
 
21
- ## Setup
23
+ ### Windows (WinGet)
24
+
25
+ ```powershell
26
+ winget install Bytifi.Bytifi
27
+ ```
28
+
29
+ Or download [bytifi.exe from GitHub Releases](https://github.com/jpwcguy/Bytifi/releases/latest).
30
+
31
+ ### Environment variable (all platforms)
22
32
 
23
33
  ```bash
24
34
  export BYTIFI_API_KEY=usk_your_api_key_here
25
35
  ```
26
36
 
37
+ PowerShell:
38
+
39
+ ```powershell
40
+ $env:BYTIFI_API_KEY = "usk_your_api_key_here"
41
+ ```
42
+
27
43
  Create an API key in **Account → API** on bytifi.com.
28
44
 
45
+ ## Setup
46
+
47
+ Set your API key via environment variable (see Install above) or pass `--api-key` per command.
48
+ Prefer the environment variable — keys on the command line can appear in shell history and process lists.
49
+
29
50
  ## Usage
30
51
 
31
52
  ```bash
@@ -53,22 +74,24 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
53
74
  | `--delete-on-download` | Delete after first download |
54
75
  | `--json` | Machine-readable JSON output |
55
76
  | `-q, --quiet` | Print only the share URL |
56
- | `--mime-type` | Override MIME type detection |
77
+ | `--verbose` | Print API error details to stderr |
78
+ | `--mime-type` | Override detected MIME type |
79
+ | `--base-url` | API base URL (default: `https://bytifi.com`) |
80
+
81
+ Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
82
+
83
+ JSON output (`--json`) includes `shareUrl`, `downloadUrl`, `encryptionToken`, `expiresAt`, and `token`.
84
+
85
+ Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
57
86
 
58
87
  ## How it works
59
88
 
60
89
  1. Encrypts the file locally with AES-GCM (same format as the website)
61
- 2. Uploads encrypted bytes via the public API
90
+ 2. Uploads encrypted bytes via the public API (parallel part uploads for large files)
62
91
  3. Prints a share URL including `#token=...`
63
92
 
64
- The server never receives plaintext or the decryption token.
65
-
66
93
  ## Development
67
94
 
68
95
  ```bash
69
96
  node bin/bytifi.js upload ./file.png --json
70
97
  ```
71
-
72
- ## Status
73
-
74
- WIP — v0.1.0 direct + multipart upload supported.
package/bin/bytifi.js CHANGED
@@ -3,33 +3,50 @@
3
3
  import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
- import { fileURLToPath } from 'node:url'
6
+ import { createRequire } from 'node:module'
7
7
  import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
8
8
 
9
- const __filename = fileURLToPath(import.meta.url)
9
+ const require = createRequire(import.meta.url)
10
+ const { version } = require('../package.json')
10
11
 
11
12
  function printHelp() {
12
- process.stdout.write(`Bytifi CLI — encrypt and upload files
13
+ process.stdout.write(`Bytifi CLI v${version} — encrypt and upload files
13
14
 
14
15
  Usage:
15
16
  bytifi upload <file> [options]
16
17
 
17
18
  Options:
18
- -k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
19
- -e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
19
+ -k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
20
+ -e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
20
21
  --delete-on-download Remove file after first download
21
22
  --json Print machine-readable JSON to stdout
22
23
  -q, --quiet Print only the share URL
24
+ --verbose Show API error details on stderr
23
25
  --mime-type <type> Override detected MIME type
24
26
  --base-url <url> API base URL (default: https://bytifi.com)
27
+ -V, --version Show version
25
28
  -h, --help Show this help
26
29
 
30
+ Exit codes:
31
+ 0 success
32
+ 1 usage or validation error
33
+ 2 API error (4xx/5xx response)
34
+ 3 network error
35
+
27
36
  Examples:
28
37
  bytifi upload ./photo.png
29
38
  BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
30
39
  `)
31
40
  }
32
41
 
42
+ function readFlagValue(argv, index, flagName) {
43
+ const value = argv[index + 1]
44
+ if (!value || value.startsWith('-')) {
45
+ throw new Error(`Option ${flagName} requires a value.`)
46
+ }
47
+ return value
48
+ }
49
+
33
50
  function parseArgs(argv) {
34
51
  const positional = []
35
52
  const options = {
@@ -38,9 +55,11 @@ function parseArgs(argv) {
38
55
  deleteOnDownload: false,
39
56
  json: false,
40
57
  quiet: false,
58
+ verbose: false,
41
59
  mimeType: '',
42
60
  baseUrl: 'https://bytifi.com',
43
61
  help: false,
62
+ version: false,
44
63
  }
45
64
 
46
65
  for (let index = 0; index < argv.length; index += 1) {
@@ -51,6 +70,11 @@ function parseArgs(argv) {
51
70
  continue
52
71
  }
53
72
 
73
+ if (arg === '--version' || arg === '-V') {
74
+ options.version = true
75
+ continue
76
+ }
77
+
54
78
  if (arg === '--json') {
55
79
  options.json = true
56
80
  continue
@@ -61,31 +85,41 @@ function parseArgs(argv) {
61
85
  continue
62
86
  }
63
87
 
88
+ if (arg === '--verbose') {
89
+ options.verbose = true
90
+ continue
91
+ }
92
+
64
93
  if (arg === '--delete-on-download') {
65
94
  options.deleteOnDownload = true
66
95
  continue
67
96
  }
68
97
 
69
98
  if (arg === '--api-key' || arg === '-k') {
70
- options.apiKey = argv[index + 1] || ''
99
+ options.apiKey = readFlagValue(argv, index, arg)
71
100
  index += 1
72
101
  continue
73
102
  }
74
103
 
75
104
  if (arg === '--expires' || arg === '-e') {
76
- options.expiresInMinutes = Number(argv[index + 1])
105
+ const raw = readFlagValue(argv, index, arg)
106
+ const minutes = Number(raw)
107
+ if (!Number.isFinite(minutes)) {
108
+ throw new Error(`Invalid expires value: ${raw}`)
109
+ }
110
+ options.expiresInMinutes = minutes
77
111
  index += 1
78
112
  continue
79
113
  }
80
114
 
81
115
  if (arg === '--mime-type') {
82
- options.mimeType = argv[index + 1] || ''
116
+ options.mimeType = readFlagValue(argv, index, arg)
83
117
  index += 1
84
118
  continue
85
119
  }
86
120
 
87
121
  if (arg === '--base-url') {
88
- options.baseUrl = argv[index + 1] || options.baseUrl
122
+ options.baseUrl = readFlagValue(argv, index, arg)
89
123
  index += 1
90
124
  continue
91
125
  }
@@ -107,6 +141,14 @@ function validateExpires(minutes) {
107
141
  }
108
142
  }
109
143
 
144
+ function writeProgress(percent) {
145
+ process.stderr.write(`\rEncrypting and uploading: ${percent}%`)
146
+ }
147
+
148
+ function clearProgressLine() {
149
+ process.stderr.write('\r\x1b[K')
150
+ }
151
+
110
152
  async function runUpload(filePath, options) {
111
153
  validateExpires(options.expiresInMinutes)
112
154
 
@@ -115,34 +157,64 @@ async function runUpload(filePath, options) {
115
157
  }
116
158
 
117
159
  const resolvedPath = path.resolve(filePath)
118
- await fs.access(resolvedPath)
119
-
120
- const result = await uploadFile(resolvedPath, {
121
- apiKey: options.apiKey,
122
- baseUrl: options.baseUrl,
123
- expiresInMinutes: options.expiresInMinutes,
124
- deleteOnDownload: options.deleteOnDownload,
125
- mimeType: options.mimeType || undefined,
126
- onProgress: options.quiet || options.json
127
- ? undefined
128
- : (percent) => {
129
- process.stderr.write(`Encrypting and uploading: ${percent}%\n`)
130
- },
131
- })
132
-
133
- if (options.json) {
134
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
135
- return
136
- }
137
-
138
- if (options.quiet) {
139
- process.stdout.write(`${result.shareUrl}\n`)
140
- return
141
- }
142
-
143
- process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
144
- process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
145
- process.stdout.write(`Expires: ${result.expiresAt}\n`)
160
+
161
+ try {
162
+ await fs.access(resolvedPath)
163
+ } catch {
164
+ throw new Error(`File not found or not readable: ${resolvedPath}`)
165
+ }
166
+
167
+ const abortController = new AbortController()
168
+
169
+ const handleSignal = () => {
170
+ abortController.abort()
171
+ }
172
+
173
+ process.on('SIGINT', handleSignal)
174
+ process.on('SIGTERM', handleSignal)
175
+
176
+ const showProgress = !options.quiet && !options.json
177
+ let lastPercent = -1
178
+
179
+ try {
180
+ const result = await uploadFile(resolvedPath, {
181
+ apiKey: options.apiKey,
182
+ baseUrl: options.baseUrl,
183
+ expiresInMinutes: options.expiresInMinutes,
184
+ deleteOnDownload: options.deleteOnDownload,
185
+ mimeType: options.mimeType || undefined,
186
+ signal: abortController.signal,
187
+ onProgress: showProgress
188
+ ? (percent) => {
189
+ if (percent !== lastPercent) {
190
+ lastPercent = percent
191
+ writeProgress(percent)
192
+ }
193
+ }
194
+ : undefined,
195
+ })
196
+
197
+ if (showProgress) {
198
+ clearProgressLine()
199
+ }
200
+
201
+ if (options.json) {
202
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
203
+ return
204
+ }
205
+
206
+ if (options.quiet) {
207
+ process.stdout.write(`${result.shareUrl}\n`)
208
+ return
209
+ }
210
+
211
+ process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
212
+ process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
213
+ process.stdout.write(`Expires: ${result.expiresAt}\n`)
214
+ } finally {
215
+ process.off('SIGINT', handleSignal)
216
+ process.off('SIGTERM', handleSignal)
217
+ }
146
218
  }
147
219
 
148
220
  function exitCodeForError(error) {
@@ -151,6 +223,25 @@ function exitCodeForError(error) {
151
223
  return 1
152
224
  }
153
225
 
226
+ function printError(error, verbose) {
227
+ process.stderr.write(`${error.message || 'Upload failed.'}\n`)
228
+
229
+ if (!verbose) return
230
+
231
+ if (error instanceof BytifiApiError) {
232
+ if (error.status) {
233
+ process.stderr.write(`HTTP ${error.status}\n`)
234
+ }
235
+ if (error.body) {
236
+ process.stderr.write(`${JSON.stringify(error.body, null, 2)}\n`)
237
+ }
238
+ }
239
+
240
+ if (error instanceof BytifiNetworkError && error.cause) {
241
+ process.stderr.write(`${error.cause}\n`)
242
+ }
243
+ }
244
+
154
245
  async function main() {
155
246
  const [command, ...rest] = process.argv.slice(2)
156
247
 
@@ -159,6 +250,11 @@ async function main() {
159
250
  process.exit(0)
160
251
  }
161
252
 
253
+ if (command === '--version' || command === '-V') {
254
+ process.stdout.write(`${version}\n`)
255
+ process.exit(0)
256
+ }
257
+
162
258
  if (command !== 'upload') {
163
259
  if (command === 'help') {
164
260
  printHelp()
@@ -175,15 +271,30 @@ async function main() {
175
271
  process.exit(0)
176
272
  }
177
273
 
274
+ if (options.version) {
275
+ process.stdout.write(`${version}\n`)
276
+ process.exit(0)
277
+ }
278
+
279
+ if (options.json && options.quiet) {
280
+ throw new Error('Use either --json or --quiet, not both.')
281
+ }
282
+
178
283
  const filePath = positional[0]
179
284
  if (!filePath) {
180
285
  throw new Error('Missing file path. Usage: bytifi upload <file>')
181
286
  }
182
287
 
288
+ if (positional.length > 1) {
289
+ process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
290
+ }
291
+
183
292
  await runUpload(filePath, options)
184
293
  }
185
294
 
186
295
  main().catch((error) => {
187
- process.stderr.write(`${error.message || 'Upload failed.'}\n`)
296
+ clearProgressLine()
297
+ const verbose = process.argv.includes('--verbose')
298
+ printError(error, verbose)
188
299
  process.exit(exitCodeForError(error))
189
300
  })
package/lib/upload.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  DIRECT_UPLOAD_LIMIT_BYTES,
3
- collectEncryptedParts,
4
3
  encryptChunkFromFile,
5
4
  resolveUploadFile,
6
5
  } from './crypto.js'
7
6
  import fs from 'node:fs/promises'
8
7
 
9
8
  const DEFAULT_BASE_URL = 'https://bytifi.com'
9
+ const DEFAULT_CONCURRENCY = 4
10
+ const POLL_INTERVAL_MS = 1500
11
+ const POLL_TIMEOUT_MS = 30 * 60 * 1000
12
+ const MAX_RETRIES = 3
10
13
 
11
14
  export class BytifiApiError extends Error {
12
15
  constructor(message, { status = 0, body = null } = {}) {
@@ -25,10 +28,22 @@ export class BytifiNetworkError extends Error {
25
28
  }
26
29
  }
27
30
 
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms))
33
+ }
34
+
28
35
  function normalizeBaseUrl(baseUrl) {
29
36
  return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
30
37
  }
31
38
 
39
+ function isRetryableError(error) {
40
+ if (error instanceof BytifiNetworkError) return true
41
+ if (error instanceof BytifiApiError) {
42
+ return error.status === 429 || error.status >= 500
43
+ }
44
+ return false
45
+ }
46
+
32
47
  async function readResponseBody(response) {
33
48
  const text = await response.text()
34
49
  if (!text) return null
@@ -40,7 +55,7 @@ async function readResponseBody(response) {
40
55
  }
41
56
  }
42
57
 
43
- async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null } = {}) {
58
+ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal } = {}) {
44
59
  const url = `${normalizeBaseUrl(baseUrl)}${path}`
45
60
  const requestHeaders = {
46
61
  Authorization: `Bearer ${apiKey}`,
@@ -54,8 +69,12 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
54
69
  method,
55
70
  headers: requestHeaders,
56
71
  body,
72
+ signal,
57
73
  })
58
74
  } catch (error) {
75
+ if (signal?.aborted) {
76
+ throw new Error('Upload aborted.')
77
+ }
59
78
  throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
60
79
  }
61
80
 
@@ -73,6 +92,29 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
73
92
  return payload
74
93
  }
75
94
 
95
+ async function apiFetchWithRetry(baseUrl, path, options = {}) {
96
+ const { retries = MAX_RETRIES, signal } = options
97
+
98
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
99
+ try {
100
+ return await apiFetch(baseUrl, path, options)
101
+ } catch (error) {
102
+ if (signal?.aborted || error.message === 'Upload aborted.') {
103
+ throw error
104
+ }
105
+
106
+ if (!isRetryableError(error) || attempt === retries) {
107
+ throw error
108
+ }
109
+
110
+ const delayMs = Math.min(1000 * (2 ** attempt), 8000)
111
+ await sleep(delayMs)
112
+ }
113
+ }
114
+
115
+ throw new Error('Request failed after retries.')
116
+ }
117
+
76
118
  function buildShareUrl(payload, encryptionToken) {
77
119
  return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
78
120
  }
@@ -92,11 +134,34 @@ function buildResult(payload, context, shareUrl) {
92
134
  }
93
135
  }
94
136
 
137
+ async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
138
+ const fileHandle = await fs.open(filePath, 'r')
139
+ const encryptedParts = []
140
+
141
+ try {
142
+ for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
143
+ if (signal?.aborted) {
144
+ throw new Error('Upload aborted.')
145
+ }
146
+
147
+ encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
148
+ onProgress?.(Math.round(((chunkIndex + 1) / context.chunkCount) * 90))
149
+ }
150
+ } finally {
151
+ await fileHandle.close()
152
+ }
153
+
154
+ onProgress?.(95)
155
+ return Buffer.concat(encryptedParts)
156
+ }
157
+
95
158
  async function uploadDirect(context, encryptedBuffer, {
96
159
  apiKey,
97
160
  baseUrl,
98
161
  expiresInMinutes,
99
162
  deleteOnDownload,
163
+ onProgress,
164
+ signal,
100
165
  }) {
101
166
  const formData = new FormData()
102
167
  const blob = new Blob([encryptedBuffer], { type: 'application/octet-stream' })
@@ -107,33 +172,42 @@ async function uploadDirect(context, encryptedBuffer, {
107
172
  formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
108
173
  formData.append('expiresInMinutes', String(expiresInMinutes))
109
174
 
110
- const payload = await apiFetch(baseUrl, '/api/public/upload', {
175
+ const payload = await apiFetchWithRetry(baseUrl, '/api/public/upload', {
111
176
  apiKey,
112
177
  method: 'POST',
113
178
  body: formData,
179
+ signal,
114
180
  })
115
181
 
182
+ onProgress?.(100)
183
+
116
184
  const shareUrl = buildShareUrl(payload, context.token)
117
185
  return buildResult(payload, context, shareUrl)
118
186
  }
119
187
 
120
188
  async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
189
+ const startedAt = Date.now()
190
+
121
191
  while (true) {
122
192
  if (signal?.aborted) {
123
193
  throw new Error('Upload aborted.')
124
194
  }
125
195
 
126
- const payload = await apiFetch(
196
+ if (Date.now() - startedAt > POLL_TIMEOUT_MS) {
197
+ throw new Error('Upload finalization timed out. Try again later.')
198
+ }
199
+
200
+ const payload = await apiFetchWithRetry(
127
201
  baseUrl,
128
202
  `/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
129
- { apiKey },
203
+ { apiKey, signal },
130
204
  )
131
205
 
132
- if (payload.status !== 'processing') {
206
+ if (payload.status !== 'processing' && payload.status !== 'pending') {
133
207
  return payload
134
208
  }
135
209
 
136
- await new Promise((resolve) => setTimeout(resolve, 1500))
210
+ await sleep(POLL_INTERVAL_MS)
137
211
  }
138
212
  }
139
213
 
@@ -144,9 +218,10 @@ async function uploadMultipartStreaming(filePath, context, {
144
218
  deleteOnDownload,
145
219
  onProgress,
146
220
  signal,
221
+ concurrency = DEFAULT_CONCURRENCY,
147
222
  }) {
148
223
  const partSize = context.meta.chunkSize + 16
149
- const initPayload = await apiFetch(baseUrl, '/api/public/upload/init', {
224
+ const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
150
225
  apiKey,
151
226
  method: 'POST',
152
227
  headers: { 'Content-Type': 'application/json' },
@@ -161,21 +236,35 @@ async function uploadMultipartStreaming(filePath, context, {
161
236
  expiresInMinutes,
162
237
  deleteOnDownload,
163
238
  }),
239
+ signal,
164
240
  })
165
241
 
166
242
  const sessionToken = initPayload.sessionToken
243
+ const workerCount = Math.min(
244
+ Number(initPayload.concurrency) || concurrency,
245
+ context.chunkCount,
246
+ )
167
247
  const fileHandle = await fs.open(filePath, 'r')
248
+ let nextChunkIndex = 0
249
+ let completedParts = 0
168
250
 
169
- try {
170
- for (let chunkIndex = 0; chunkIndex < context.chunkCount; chunkIndex += 1) {
251
+ async function uploadWorker() {
252
+ while (true) {
171
253
  if (signal?.aborted) {
172
254
  throw new Error('Upload aborted.')
173
255
  }
174
256
 
257
+ const chunkIndex = nextChunkIndex
258
+ nextChunkIndex += 1
259
+
260
+ if (chunkIndex >= context.chunkCount) {
261
+ return
262
+ }
263
+
175
264
  const partNumber = chunkIndex + 1
176
265
  const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
177
266
 
178
- await apiFetch(
267
+ await apiFetchWithRetry(
179
268
  baseUrl,
180
269
  `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
181
270
  {
@@ -183,17 +272,24 @@ async function uploadMultipartStreaming(filePath, context, {
183
272
  method: 'PUT',
184
273
  headers: { 'Content-Type': 'application/octet-stream' },
185
274
  body: encryptedPart,
275
+ signal,
186
276
  },
187
277
  )
188
278
 
189
- onProgress?.(Math.round((partNumber / context.chunkCount) * 100))
279
+ completedParts += 1
280
+ onProgress?.(Math.round((completedParts / context.chunkCount) * 100))
190
281
  }
282
+ }
283
+
284
+ try {
285
+ await Promise.all(Array.from({ length: workerCount }, () => uploadWorker()))
191
286
  } catch (error) {
192
- await apiFetch(baseUrl, '/api/public/upload/abort', {
287
+ await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
193
288
  apiKey,
194
289
  method: 'POST',
195
290
  headers: { 'Content-Type': 'application/json' },
196
291
  body: JSON.stringify({ sessionToken }),
292
+ signal,
197
293
  }).catch(() => {})
198
294
 
199
295
  throw error
@@ -201,14 +297,15 @@ async function uploadMultipartStreaming(filePath, context, {
201
297
  await fileHandle.close()
202
298
  }
203
299
 
204
- let completePayload = await apiFetch(baseUrl, '/api/public/upload/complete', {
300
+ let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
205
301
  apiKey,
206
302
  method: 'POST',
207
303
  headers: { 'Content-Type': 'application/json' },
208
304
  body: JSON.stringify({ sessionToken }),
305
+ signal,
209
306
  })
210
307
 
211
- if (completePayload.status === 'processing') {
308
+ if (completePayload.status === 'processing' || completePayload.status === 'pending') {
212
309
  completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
213
310
  }
214
311
 
@@ -226,8 +323,10 @@ export async function uploadFile(filePath, options) {
226
323
  })
227
324
 
228
325
  if (context.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
229
- const encryptedParts = await collectEncryptedParts(absolutePath, context)
230
- const encryptedBuffer = Buffer.concat(encryptedParts)
326
+ const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
327
+ onProgress: options.onProgress,
328
+ signal: options.signal,
329
+ })
231
330
 
232
331
  return uploadDirect(context, encryptedBuffer, options)
233
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Official Bytifi CLI — encrypt and upload files from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
  "node": ">=18"
15
15
  },
16
16
  "scripts": {
17
- "upload": "node bin/bytifi.js upload"
17
+ "upload": "node bin/bytifi.js upload",
18
+ "build:bundle": "esbuild bin/bytifi.js --bundle --platform=node --format=cjs --outfile=dist/bytifi-bundle.cjs",
19
+ "build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip"
18
20
  },
19
21
  "repository": {
20
22
  "type": "git",
@@ -35,7 +37,8 @@
35
37
  "file-sharing"
36
38
  ],
37
39
  "license": "MIT",
38
- "dependencies": {
39
- "bytifi": "^0.1.0"
40
+ "devDependencies": {
41
+ "@yao-pkg/pkg": "^6.21.0",
42
+ "esbuild": "^0.25.0"
40
43
  }
41
44
  }