bytifi 0.2.5 → 0.2.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bytifi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -45,7 +45,8 @@ Create an API key in **Account → API** on bytifi.com.
45
45
  ## Setup
46
46
 
47
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.
48
+
49
+ **Security:** Prefer `BYTIFI_API_KEY` over `--api-key` on the command line. API keys and encryption tokens passed as CLI flags may appear in shell history and process lists (`ps`). The CLI warns when secrets are passed on argv.
49
50
 
50
51
  ## Usage
51
52
 
@@ -59,7 +60,9 @@ bytifi upload ./logs.txt --concurrency 8 --json > upload.json
59
60
  bytifi upload ./photo.png -q
60
61
  ```
61
62
 
62
- Files up to **10 MB** are gzip-compressed per chunk before encryption. Larger files use multipart upload with fixed-size encrypted parts (no compression — gzip can expand ISO/zip data past the server part limit).
63
+ Files up to **10 MB** use a single direct upload with gzip-compressed chunks. Files **over 10 MB** use multipart upload with fixed-size encrypted parts (no compression — gzip can expand ISO/zip data past the server part limit).
64
+
65
+ Upload concurrency auto-scales when `--concurrency` is omitted: ≤1 GB → 4 workers, 1–3 GB → 3 workers, ≥3 GB → 2 workers.
63
66
 
64
67
  Upload accepts **one file at a time**. Quote paths that contain spaces. Avoid shell globs like `**` — your shell may expand them into dozens of paths.
65
68
 
@@ -70,6 +73,7 @@ Download and decrypt directly from a share URL or link token. No API key require
70
73
  ```bash
71
74
  bytifi decrypt 'https://bytifi.com/link?link=LINK_ID#token=ENCRYPTION_TOKEN'
72
75
  bytifi decrypt LINK_ID --token ENCRYPTION_TOKEN -o ./restored.mp4
76
+ bytifi decrypt LINK_ID --token ENCRYPTION_TOKEN --concurrency 4
73
77
  ```
74
78
 
75
79
  ### Decrypt a downloaded encrypted file
@@ -84,6 +88,12 @@ bytifi decrypt ./downloaded-file --upload-json upload.json
84
88
  bytifi decrypt "./my video (1).mp4" \
85
89
  --link LINK_ID \
86
90
  --token ENCRYPTION_TOKEN
91
+
92
+ # Force local-path mode when the filename looks like a URL
93
+ bytifi decrypt ./https-example.bin --local-file --upload-json upload.json
94
+
95
+ # Overwrite an existing output file
96
+ bytifi decrypt ./downloaded-file --upload-json upload.json --force
87
97
  ```
88
98
 
89
99
  #### Link ID vs encryption token
@@ -128,7 +138,7 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
128
138
  | `-q, --quiet` | Print only the share URL |
129
139
  | `--verbose` | Print API error details to stderr |
130
140
  | `--mime-type` | Override detected MIME type |
131
- | `--concurrency` | Parallel encrypt/upload workers, 1–16 (default: `4`) |
141
+ | `--concurrency` | Parallel encrypt/upload workers, 1–16 (default: auto-scaled by file size) |
132
142
  | `--base-url` | API base URL (default: `https://bytifi.com`) |
133
143
 
134
144
  ### Decrypt options
@@ -140,24 +150,28 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
140
150
  | `--upload-json` | Upload `--json` output file (recommended for downloaded files) |
141
151
  | `--meta` | Saved `clientEncryptionMeta` JSON for offline decrypt |
142
152
  | `--share-url` | Share URL to read token/metadata while decrypting a local file |
153
+ | `--local-file` | Treat input as a local path even if it looks like a URL |
143
154
  | `-o, --output` | Output file path |
144
155
  | `--output-dir` | Output directory when saving under the original filename |
156
+ | `--force` | Overwrite existing output file |
157
+ | `--concurrency` | Parallel part download workers, 1–8 (default: `2`) |
145
158
  | `--json` | Machine-readable JSON output |
146
159
  | `-q, --quiet` | Print only the output file path |
147
160
  | `--verbose` | Print error details to stderr |
148
161
  | `--base-url` | API base URL (default: `https://bytifi.com`) |
149
162
 
150
- Exit codes: `0` success, `1` usage error, `2` API error, `3` network error.
163
+ Exit codes: `0` success, `1` usage error, `2` API error, `3` network error, `130` interrupted (Ctrl+C).
151
164
 
152
165
  JSON output (`--json`) for upload includes `shareUrl`, `encryptedFile`, `link`, `encryptionToken`, `clientEncryptionMeta`, `compression`, and `expiresAt`.
153
166
 
154
167
  JSON output for decrypt includes `outputPath`, `originalName`, `size`, `mimeType`, `expiresAt`, `link`, `storageMode`, and `sourcePath` (for local decrypt).
155
168
 
156
- Files over ~100 MB encrypted use multipart upload automatically. Progress prints to stderr unless `--json` or `--quiet` is set.
169
+ Progress prints to stderr with throughput and ETA unless `--json` or `--quiet` is set.
157
170
 
158
171
  ## Development
159
172
 
160
173
  ```bash
174
+ npm test
161
175
  node bin/bytifi.js upload ./file.png --json > upload.json
162
176
  node bin/bytifi.js decrypt ./file.encrypted --upload-json upload.json
163
177
  ```
package/bin/bytifi.js CHANGED
@@ -5,7 +5,8 @@ import path from 'node:path'
5
5
  import process from 'node:process'
6
6
  import { createRequire } from 'node:module'
7
7
  import { decryptFile } from '../lib/decrypt.js'
8
- import { formatProgressLine } from '../lib/progress.js'
8
+ import { createProgressTracker, formatProgressLine } from '../lib/progress.js'
9
+ import { validateBaseUrl } from '../lib/url.js'
9
10
  import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
10
11
 
11
12
  const require = createRequire(import.meta.url)
@@ -14,15 +15,41 @@ const version =
14
15
  ? __BYTIFI_VERSION__
15
16
  : require('../package.json').version
16
17
 
17
- function printHelp() {
18
+ const SECRET_FLAGS = new Set(['--api-key', '-k', '--token'])
19
+
20
+ function printGlobalHelp() {
18
21
  process.stdout.write(`Bytifi CLI v${version} — encrypt, upload, and decrypt files
19
22
 
20
23
  Usage:
21
24
  bytifi upload <file> [options]
22
- bytifi decrypt <url-or-token> [options]
23
- bytifi decrypt <encrypted-file> [options]
25
+ bytifi decrypt <input> [options]
26
+
27
+ Commands:
28
+ upload Encrypt and upload a file
29
+ decrypt Decrypt from a share link or local encrypted file
30
+
31
+ Global:
32
+ -V, --version Show version
33
+ -h, --help Show help
34
+
35
+ Run \`bytifi upload --help\` or \`bytifi decrypt --help\` for command options.
36
+
37
+ Exit codes:
38
+ 0 success
39
+ 1 usage or validation error
40
+ 2 API error (4xx/5xx response)
41
+ 3 network error
42
+ 130 interrupted (Ctrl+C)
43
+ `)
44
+ }
45
+
46
+ function printUploadHelp() {
47
+ process.stdout.write(`Bytifi upload — encrypt and upload a file
48
+
49
+ Usage:
50
+ bytifi upload <file> [options]
24
51
 
25
- Upload options:
52
+ Options:
26
53
  -k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
27
54
  -e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
28
55
  --delete-on-download Remove file after first download
@@ -30,43 +57,49 @@ Upload options:
30
57
  -q, --quiet Print only the share URL
31
58
  --verbose Show API error details on stderr
32
59
  --mime-type <type> Override detected MIME type
33
- --concurrency <n> Parallel part workers (default: 4)
60
+ --concurrency <n> Parallel part workers, 1–16 (default: auto)
34
61
  --base-url <url> API base URL (default: https://bytifi.com)
62
+ -h, --help Show this help
63
+
64
+ Concurrency auto-scales by file size when --concurrency is omitted:
65
+ ≤1 GB → 4 workers, 1–3 GB → 3 workers, ≥3 GB → 2 workers.
66
+
67
+ Examples:
68
+ export BYTIFI_API_KEY=usk_your_key
69
+ bytifi upload ./photo.png
70
+ bytifi upload "./my report.pdf" --expires 60 --delete-on-download
71
+ bytifi upload ./logs.txt --concurrency 8 --json > upload.json
72
+ bytifi upload ./large.iso -q
73
+ `)
74
+ }
75
+
76
+ function printDecryptHelp() {
77
+ process.stdout.write(`Bytifi decrypt — decrypt from a link or local encrypted file
35
78
 
36
- Decrypt options:
79
+ Usage:
80
+ bytifi decrypt <url-or-token|encrypted-file> [options]
81
+
82
+ Options:
37
83
  --token <token> Encryption key from #token=... (not the link ID)
38
84
  --link <id> Link ID from upload JSON "link" field (/f/LINK_ID)
39
85
  --upload-json <path> Upload --json output (easiest for downloaded files)
40
86
  --meta <path> Saved clientEncryptionMeta JSON (offline decrypt)
41
- --share-url <url> Share URL for token/metadata when decrypting a local file
87
+ --share-url <url> Share URL for token/metadata when decrypting locally
88
+ --local-file Treat input as a local path even if it looks like a URL
42
89
  -o, --output <path> Output file path (default: original filename)
43
90
  --output-dir <dir> Directory for decrypted file (default: cwd)
91
+ --force Overwrite existing output file
92
+ --concurrency <n> Parallel download workers, 1–8 (default: 2)
44
93
  --json Print machine-readable JSON to stdout
45
94
  -q, --quiet Print only the output file path
46
95
  --verbose Show error details on stderr
47
96
  --base-url <url> API base URL (default: https://bytifi.com)
48
-
49
- Global:
50
- -V, --version Show version
51
97
  -h, --help Show this help
52
98
 
53
- Exit codes:
54
- 0 success
55
- 1 usage or validation error
56
- 2 API error (4xx/5xx response)
57
- 3 network error
58
-
59
99
  Examples:
60
- bytifi --version
61
- export BYTIFI_API_KEY=usk_your_key
62
-
63
- bytifi upload ./photo.png
64
- bytifi upload "./my report.pdf" --expires 60 --delete-on-download
65
- bytifi upload ./logs.txt --concurrency 8 --json > upload.json
66
- bytifi upload ./large.iso -q
67
-
68
100
  bytifi decrypt 'https://bytifi.com/link?link=LINK#token=KEY'
69
101
  bytifi decrypt ./downloaded.bin --upload-json upload.json -o ./restored.bin
102
+ bytifi decrypt ./parts-dir/ --upload-json upload.json --force
70
103
  `)
71
104
  }
72
105
 
@@ -88,7 +121,7 @@ function parseUploadArgs(argv) {
88
121
  quiet: false,
89
122
  verbose: false,
90
123
  mimeType: '',
91
- concurrency: 4,
124
+ concurrency: null,
92
125
  baseUrl: 'https://bytifi.com',
93
126
  help: false,
94
127
  version: false,
@@ -187,6 +220,9 @@ function parseDecryptArgs(argv) {
187
220
  shareUrl: '',
188
221
  output: '',
189
222
  outputDirectory: '',
223
+ localFile: false,
224
+ force: false,
225
+ concurrency: 2,
190
226
  json: false,
191
227
  quiet: false,
192
228
  verbose: false,
@@ -223,6 +259,16 @@ function parseDecryptArgs(argv) {
223
259
  continue
224
260
  }
225
261
 
262
+ if (arg === '--force') {
263
+ options.force = true
264
+ continue
265
+ }
266
+
267
+ if (arg === '--local-file') {
268
+ options.localFile = true
269
+ continue
270
+ }
271
+
226
272
  if (arg === '--token') {
227
273
  options.encryptionToken = readFlagValue(argv, index, arg)
228
274
  index += 1
@@ -265,6 +311,17 @@ function parseDecryptArgs(argv) {
265
311
  continue
266
312
  }
267
313
 
314
+ if (arg === '--concurrency') {
315
+ const raw = readFlagValue(argv, index, arg)
316
+ const concurrency = Number(raw)
317
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 8) {
318
+ throw new Error('concurrency must be an integer between 1 and 8.')
319
+ }
320
+ options.concurrency = concurrency
321
+ index += 1
322
+ continue
323
+ }
324
+
268
325
  if (arg === '--base-url') {
269
326
  options.baseUrl = readFlagValue(argv, index, arg)
270
327
  index += 1
@@ -281,6 +338,18 @@ function parseDecryptArgs(argv) {
281
338
  return { positional, options }
282
339
  }
283
340
 
341
+ function warnSecretFlags(argv) {
342
+ for (let index = 0; index < argv.length; index += 1) {
343
+ const arg = argv[index]
344
+ if (SECRET_FLAGS.has(arg)) {
345
+ process.stderr.write(
346
+ `Warning: ${arg} on the command line may appear in shell history and process lists. Prefer BYTIFI_API_KEY for uploads.\n`,
347
+ )
348
+ return
349
+ }
350
+ }
351
+ }
352
+
284
353
  function validateExpires(minutes) {
285
354
  const allowed = new Set([5, 15, 30, 60, 120])
286
355
  if (!allowed.has(minutes)) {
@@ -288,8 +357,7 @@ function validateExpires(minutes) {
288
357
  }
289
358
  }
290
359
 
291
- function writeProgress(info) {
292
- const line = formatProgressLine(info)
360
+ function writeProgress(line) {
293
361
  if (line) {
294
362
  process.stderr.write(`\r${line}`)
295
363
  }
@@ -303,31 +371,52 @@ function clearProgressLine() {
303
371
  process.stderr.write('\r\x1b[K')
304
372
  }
305
373
 
374
+ function createAbortContext(label) {
375
+ const abortController = new AbortController()
376
+ let abortedByUser = false
377
+
378
+ const handleSignal = () => {
379
+ abortedByUser = true
380
+ abortController.abort()
381
+ }
382
+
383
+ process.on('SIGINT', handleSignal)
384
+ process.on('SIGTERM', handleSignal)
385
+
386
+ return {
387
+ signal: abortController.signal,
388
+ cleanup() {
389
+ process.off('SIGINT', handleSignal)
390
+ process.off('SIGTERM', handleSignal)
391
+ },
392
+ checkAborted() {
393
+ if (abortedByUser || abortController.signal.aborted) {
394
+ clearProgressLine()
395
+ process.stderr.write(`${label} aborted.\n`)
396
+ process.exit(130)
397
+ }
398
+ },
399
+ }
400
+ }
401
+
306
402
  async function runUpload(filePath, options) {
307
403
  validateExpires(options.expiresInMinutes)
404
+ options.baseUrl = validateBaseUrl(options.baseUrl)
308
405
 
309
406
  if (!options.apiKey) {
310
407
  throw new Error('Missing API key. Pass --api-key or set BYTIFI_API_KEY.')
311
408
  }
312
409
 
313
410
  const resolvedPath = path.resolve(filePath)
411
+ const stat = await fs.stat(resolvedPath)
314
412
 
315
- try {
316
- await fs.access(resolvedPath)
317
- } catch {
318
- throw new Error(`File not found or not readable: ${resolvedPath}`)
319
- }
320
-
321
- const abortController = new AbortController()
322
-
323
- const handleSignal = () => {
324
- abortController.abort()
413
+ if (!stat.isFile()) {
414
+ throw new Error(`Upload path must be a file: ${resolvedPath}`)
325
415
  }
326
416
 
327
- process.on('SIGINT', handleSignal)
328
- process.on('SIGTERM', handleSignal)
329
-
417
+ const abort = createAbortContext('Upload')
330
418
  const showProgress = !options.quiet && !options.json
419
+ const tracker = createProgressTracker()
331
420
  let lastLine = ''
332
421
 
333
422
  try {
@@ -337,19 +426,22 @@ async function runUpload(filePath, options) {
337
426
  expiresInMinutes: options.expiresInMinutes,
338
427
  deleteOnDownload: options.deleteOnDownload,
339
428
  mimeType: options.mimeType || undefined,
340
- concurrency: options.concurrency,
341
- signal: abortController.signal,
429
+ concurrency: options.concurrency ?? undefined,
430
+ signal: abort.signal,
342
431
  onProgress: showProgress
343
432
  ? (info) => {
344
- const line = formatProgressLine(info)
433
+ const enriched = tracker.update(info)
434
+ const line = formatProgressLine(enriched)
345
435
  if (line && line !== lastLine) {
346
436
  lastLine = line
347
- writeProgress(info)
437
+ writeProgress(line)
348
438
  }
349
439
  }
350
440
  : undefined,
351
441
  })
352
442
 
443
+ abort.checkAborted()
444
+
353
445
  if (showProgress) {
354
446
  finishProgressLine()
355
447
  }
@@ -367,23 +459,20 @@ async function runUpload(filePath, options) {
367
459
  process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
368
460
  process.stdout.write(`Encrypted file:\n${result.encryptedFile}\n`)
369
461
  process.stdout.write(`Expires: ${result.expiresAt}\n`)
462
+ } catch (error) {
463
+ abort.checkAborted()
464
+ throw error
370
465
  } finally {
371
- process.off('SIGINT', handleSignal)
372
- process.off('SIGTERM', handleSignal)
466
+ abort.cleanup()
373
467
  }
374
468
  }
375
469
 
376
470
  async function runDecrypt(input, options) {
377
- const abortController = new AbortController()
378
-
379
- const handleSignal = () => {
380
- abortController.abort()
381
- }
382
-
383
- process.on('SIGINT', handleSignal)
384
- process.on('SIGTERM', handleSignal)
471
+ options.baseUrl = validateBaseUrl(options.baseUrl)
385
472
 
473
+ const abort = createAbortContext('Decrypt')
386
474
  const showProgress = !options.quiet && !options.json
475
+ const tracker = createProgressTracker()
387
476
  let lastLine = ''
388
477
 
389
478
  try {
@@ -395,19 +484,25 @@ async function runDecrypt(input, options) {
395
484
  shareUrl: options.shareUrl,
396
485
  output: options.output || undefined,
397
486
  outputDirectory: options.outputDirectory || undefined,
487
+ localFile: options.localFile,
488
+ force: options.force,
489
+ concurrency: options.concurrency,
398
490
  baseUrl: options.baseUrl,
399
- signal: abortController.signal,
491
+ signal: abort.signal,
400
492
  onProgress: showProgress
401
493
  ? (info) => {
402
- const line = formatProgressLine(info)
494
+ const enriched = tracker.update(info)
495
+ const line = formatProgressLine(enriched)
403
496
  if (line && line !== lastLine) {
404
497
  lastLine = line
405
- writeProgress(info)
498
+ writeProgress(line)
406
499
  }
407
500
  }
408
501
  : undefined,
409
502
  })
410
503
 
504
+ abort.checkAborted()
505
+
411
506
  if (showProgress) {
412
507
  finishProgressLine()
413
508
  }
@@ -425,9 +520,11 @@ async function runDecrypt(input, options) {
425
520
  process.stdout.write(`Saved: ${result.outputPath}\n`)
426
521
  process.stdout.write(`Original name: ${result.originalName}\n`)
427
522
  process.stdout.write(`Expires: ${result.expiresAt}\n`)
523
+ } catch (error) {
524
+ abort.checkAborted()
525
+ throw error
428
526
  } finally {
429
- process.off('SIGINT', handleSignal)
430
- process.off('SIGTERM', handleSignal)
527
+ abort.cleanup()
431
528
  }
432
529
  }
433
530
 
@@ -457,10 +554,13 @@ function printError(error, verbose) {
457
554
  }
458
555
 
459
556
  async function main() {
460
- const [command, ...rest] = process.argv.slice(2)
557
+ const argv = process.argv.slice(2)
558
+ warnSecretFlags(argv)
559
+
560
+ const [command, ...rest] = argv
461
561
 
462
562
  if (!command || command === '--help' || command === '-h') {
463
- printHelp()
563
+ printGlobalHelp()
464
564
  process.exit(0)
465
565
  }
466
566
 
@@ -470,7 +570,7 @@ async function main() {
470
570
  }
471
571
 
472
572
  if (command === 'help') {
473
- printHelp()
573
+ printGlobalHelp()
474
574
  process.exit(0)
475
575
  }
476
576
 
@@ -478,7 +578,7 @@ async function main() {
478
578
  const { positional, options } = parseUploadArgs(rest)
479
579
 
480
580
  if (options.help) {
481
- printHelp()
581
+ printUploadHelp()
482
582
  process.exit(0)
483
583
  }
484
584
 
@@ -510,7 +610,7 @@ async function main() {
510
610
  const { positional, options } = parseDecryptArgs(rest)
511
611
 
512
612
  if (options.help) {
513
- printHelp()
613
+ printDecryptHelp()
514
614
  process.exit(0)
515
615
  }
516
616
 
@@ -529,7 +629,9 @@ async function main() {
529
629
  }
530
630
 
531
631
  if (positional.length > 1) {
532
- process.stderr.write(`Warning: ignoring extra arguments: ${positional.slice(1).join(', ')}\n`)
632
+ throw new Error(
633
+ `Decrypt accepts one input at a time (got ${positional.length}). Quote paths with spaces.`,
634
+ )
533
635
  }
534
636
 
535
637
  await runDecrypt(input, options)