bytifi 0.1.3 → 0.1.4

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
@@ -53,45 +53,57 @@ Prefer the environment variable — keys on the command line can appear in shell
53
53
 
54
54
  ```bash
55
55
  bytifi upload ./photo.png
56
- bytifi upload ./photo.png --api-key usk_your_api_key_here
57
- bytifi upload ./photo.png --expires 60 --json
58
- bytifi upload ./large.iso -q
56
+ bytifi upload "./my video (1).mp4"
57
+ bytifi upload ./report.pdf --expires 60 --delete-on-download
58
+ bytifi upload ./large.iso --json > upload.json
59
+ bytifi upload ./photo.png -q
59
60
  ```
60
61
 
61
- ### Decrypt
62
+ 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.
62
63
 
63
- Download and decrypt a shared file locally, or decrypt a file you already downloaded from `/f/...`. No API key required.
64
+ ### Decrypt from a link
65
+
66
+ Download and decrypt directly from a share URL or link token. No API key required.
67
+
68
+ ```bash
69
+ bytifi decrypt 'https://bytifi.com/link?link=LINK_ID#token=ENCRYPTION_TOKEN'
70
+ bytifi decrypt LINK_ID --token ENCRYPTION_TOKEN -o ./restored.mp4
71
+ ```
72
+
73
+ ### Decrypt a downloaded encrypted file
74
+
75
+ If you already downloaded the encrypted blob from `/f/LINK_ID` (browser, curl, etc.):
64
76
 
65
77
  ```bash
66
- # From share URL (downloads + decrypts)
67
- bytifi decrypt 'https://bytifi.com/link?link=TOKEN#token=ENCRYPTION_TOKEN'
68
- bytifi decrypt TOKEN --token ENCRYPTION_TOKEN -o ./restored-file.pdf
69
-
70
- # From a downloaded encrypted file
71
- bytifi decrypt ./downloaded.encrypted --link TOKEN --token ENCRYPTION_TOKEN
72
- bytifi decrypt ./downloaded.encrypted --meta ./upload-meta.json --token ENCRYPTION_TOKEN
73
- bytifi decrypt ./parts/ --link TOKEN --token ENCRYPTION_TOKEN
78
+ # Easiest use the upload JSON from when you uploaded
79
+ bytifi decrypt ./downloaded-file --upload-json upload.json
80
+
81
+ # Or pass both values manually
82
+ bytifi decrypt "./my video (1).mp4" \
83
+ --link LINK_ID \
84
+ --token ENCRYPTION_TOKEN
74
85
  ```
75
86
 
76
- Use the full share URL (with `#token=...`) when possible. For `/f/...` downloads, pass the encryption token with `--token` and either `--link` (fetches metadata from the API) or `--meta` (saved `clientEncryptionMeta` JSON from upload `--json` output).
87
+ #### Link ID vs encryption token
88
+
89
+ Bytifi uses two different values — don't swap them:
90
+
91
+ | Name | Upload JSON field | Example location |
92
+ |------|-------------------|------------------|
93
+ | **Link ID** | `token` | `/f/QeVuslvdaP-okMxG`, `link?link=QeVuslvdaP-okMxG` |
94
+ | **Encryption token** | `encryptionToken` | `#token=2LTlmBrDkO4GJg0...` in `shareUrl` |
77
95
 
78
- #### Offline decrypt workflow
96
+ - `--link` = link ID (short, ~16 chars)
97
+ - `--token` = encryption key (long, ~43 chars)
79
98
 
80
- Save metadata when you upload, so you can decrypt a downloaded `/f/...` file even after the link expires:
99
+ ### Offline decrypt workflow
100
+
101
+ Save metadata when you upload, so you can decrypt after the link expires:
81
102
 
82
103
  ```bash
83
- # 1. Upload and save the JSON output
84
104
  bytifi upload ./report.pdf --json > upload.json
85
-
86
- # 2. Download the encrypted file manually (browser, curl, etc.)
87
105
  curl -L "$(jq -r .encryptedFile upload.json)" -o report.encrypted
88
-
89
- # 3. Save metadata and decrypt locally (no API call needed)
90
- jq -c .clientEncryptionMeta upload.json > report.meta.json
91
- bytifi decrypt ./report.encrypted \
92
- --meta ./report.meta.json \
93
- --token "$(jq -r .encryptionToken upload.json)" \
94
- -o ./report.pdf
106
+ bytifi decrypt ./report.encrypted --upload-json upload.json -o ./report.pdf
95
107
  ```
96
108
 
97
109
  Without a global install:
@@ -120,9 +132,10 @@ Note: with `npm exec`, put `--` before the file path so npm does not swallow `--
120
132
 
121
133
  | Flag | Description |
122
134
  |------|-------------|
123
- | `--token` | Encryption token (required if not in URL `#token=...`) |
124
- | `--link` | Link token for metadata when decrypting a local encrypted file |
125
- | `--meta` | Saved `clientEncryptionMeta` JSON for offline local decrypt |
135
+ | `--token` | Encryption key from `#token=...` (`encryptionToken` in upload JSON) |
136
+ | `--link` | Link ID from upload JSON `token` field (`/f/TOKEN`) |
137
+ | `--upload-json` | Upload `--json` output file (recommended for downloaded files) |
138
+ | `--meta` | Saved `clientEncryptionMeta` JSON for offline decrypt |
126
139
  | `--share-url` | Share URL to read token/metadata while decrypting a local file |
127
140
  | `-o, --output` | Output file path |
128
141
  | `--output-dir` | Output directory when saving under the original filename |
@@ -149,13 +162,13 @@ Files over ~100 MB encrypted use multipart upload automatically. Progress prints
149
162
 
150
163
  **Decrypt**
151
164
 
152
- 1. Reads link metadata from `/api/link/:token`, or from a saved `--meta` JSON file
153
- 2. Downloads the encrypted file (single blob or per-part for large uploads), unless you pass a local encrypted file
165
+ 1. Reads link metadata from `/api/link/:token`, from `--upload-json`, or from `--meta`
166
+ 2. Downloads the encrypted file (unless you pass a local encrypted file)
154
167
  3. Decrypts locally and writes the original file to disk
155
168
 
156
169
  ## Development
157
170
 
158
171
  ```bash
159
- node bin/bytifi.js upload ./file.png --json
160
- node bin/bytifi.js decrypt ./file.encrypted --meta ./meta.json --token '...'
172
+ node bin/bytifi.js upload ./file.png --json > upload.json
173
+ node bin/bytifi.js decrypt ./file.encrypted --upload-json upload.json
161
174
  ```
package/bin/bytifi.js CHANGED
@@ -29,9 +29,10 @@ Upload options:
29
29
  --base-url <url> API base URL (default: https://bytifi.com)
30
30
 
31
31
  Decrypt options:
32
- --token <token> Encryption token (if not in URL #token=...)
33
- --link <token> Link token for metadata (local encrypted files)
34
- --meta <path> Saved clientEncryptionMeta JSON (offline local decrypt)
32
+ --token <token> Encryption key from #token=... (not the link ID)
33
+ --link <token> Link ID from upload JSON "token" field (/f/TOKEN)
34
+ --upload-json <path> Upload --json output (easiest for downloaded files)
35
+ --meta <path> Saved clientEncryptionMeta JSON (offline decrypt)
35
36
  --share-url <url> Share URL for token/metadata when decrypting a local file
36
37
  -o, --output <path> Output file path (default: original filename)
37
38
  --output-dir <dir> Directory for decrypted file (default: cwd)
@@ -52,12 +53,14 @@ Exit codes:
52
53
 
53
54
  Examples:
54
55
  bytifi upload ./photo.png
55
- BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
56
- bytifi decrypt 'https://bytifi.com/link?link=abc123#token=...'
57
- bytifi decrypt abc123 --token '...' -o ./report.pdf
58
- bytifi decrypt ./downloaded.encrypted --link abc123 --token '...'
59
- bytifi decrypt ./downloaded.encrypted --meta ./upload-meta.json --token '...'
60
- bytifi decrypt ./parts/ --link abc123 --token '...'
56
+ bytifi upload ./video.mp4 --expires 60 --json > upload.json
57
+ bytifi upload ./large.iso -q
58
+
59
+ bytifi decrypt 'https://bytifi.com/link?link=abc#token=...'
60
+ bytifi decrypt abc --token 'ENCRYPTION_TOKEN' -o ./restored.mp4
61
+
62
+ bytifi decrypt ./downloaded.encrypted --upload-json upload.json
63
+ bytifi decrypt "./my file(1).mp4" --link abc --token 'ENCRYPTION_TOKEN'
61
64
  `)
62
65
  }
63
66
 
@@ -162,6 +165,7 @@ function parseDecryptArgs(argv) {
162
165
  encryptionToken: '',
163
166
  linkToken: '',
164
167
  metaPath: '',
168
+ uploadJsonPath: '',
165
169
  shareUrl: '',
166
170
  output: '',
167
171
  outputDirectory: '',
@@ -219,6 +223,12 @@ function parseDecryptArgs(argv) {
219
223
  continue
220
224
  }
221
225
 
226
+ if (arg === '--upload-json') {
227
+ options.uploadJsonPath = readFlagValue(argv, index, arg)
228
+ index += 1
229
+ continue
230
+ }
231
+
222
232
  if (arg === '--share-url') {
223
233
  options.shareUrl = readFlagValue(argv, index, arg)
224
234
  index += 1
@@ -354,6 +364,7 @@ async function runDecrypt(input, options) {
354
364
  encryptionToken: options.encryptionToken,
355
365
  linkToken: options.linkToken,
356
366
  metaPath: options.metaPath,
367
+ uploadJsonPath: options.uploadJsonPath,
357
368
  shareUrl: options.shareUrl,
358
369
  output: options.output || undefined,
359
370
  outputDirectory: options.outputDirectory || undefined,
@@ -458,7 +469,9 @@ async function main() {
458
469
  }
459
470
 
460
471
  if (positional.length > 1) {
461
- process.stderr.write(`Warning: ignoring extra files: ${positional.slice(1).join(', ')}\n`)
472
+ throw new Error(
473
+ `Upload accepts one file at a time (got ${positional.length}). Quote paths with spaces and avoid shell globs like **.`,
474
+ )
462
475
  }
463
476
 
464
477
  await runUpload(filePath, options)
package/lib/decrypt.js CHANGED
@@ -92,7 +92,10 @@ export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAU
92
92
  }
93
93
 
94
94
  if (!resolvedEncryptionToken) {
95
- throw new Error('Missing encryption token. Include #token=... in the URL or pass --token.')
95
+ throw new Error(
96
+ 'Missing encryption token. Pass --token with the `#token=...` value from the share URL '
97
+ + '(stored as `encryptionToken` in upload JSON). This is not the same as the link ID.',
98
+ )
96
99
  }
97
100
 
98
101
  return {
@@ -160,16 +163,24 @@ async function readMetaFile(metaPath) {
160
163
 
161
164
  async function resolveEncryptionMeta({
162
165
  metaPath = '',
166
+ inlineMeta = null,
163
167
  linkToken = '',
164
168
  baseUrl = DEFAULT_BASE_URL,
165
169
  signal,
166
170
  }) {
171
+ if (inlineMeta) {
172
+ return { meta: inlineMeta }
173
+ }
174
+
167
175
  if (metaPath) {
168
- return readMetaFile(metaPath)
176
+ return { meta: await readMetaFile(metaPath) }
169
177
  }
170
178
 
171
179
  if (!linkToken) {
172
- throw new Error('Pass --link to fetch encryption metadata, or --meta with a saved metadata JSON file.')
180
+ throw new Error(
181
+ 'Missing link metadata for a downloaded file. Pass --upload-json upload.json, --link LINK_ID, or --meta meta.json.\n'
182
+ + 'The link ID is the `token` field in upload JSON (also appears as /f/TOKEN and link?link=TOKEN).',
183
+ )
173
184
  }
174
185
 
175
186
  const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
@@ -487,11 +498,78 @@ async function decryptFromLocalParts({
487
498
  onProgress?.(100)
488
499
  }
489
500
 
490
- function resolveEncryptionToken(encryptionToken, shareReference) {
501
+ function hintTokenConfusion(encryptionToken, linkToken) {
502
+ const token = String(encryptionToken || '').trim()
503
+
504
+ if (!token || linkToken) return
505
+
506
+ if (token.length <= 20) {
507
+ throw new Error(
508
+ `"${token}" looks like a link ID, not an encryption token.\n`
509
+ + 'Use --link for the link ID (`token` in upload JSON) and --token for the encryption key (`encryptionToken`).\n'
510
+ + 'Easiest: bytifi decrypt ./file.encrypted --upload-json upload.json',
511
+ )
512
+ }
513
+ }
514
+
515
+ export async function loadUploadJson(uploadJsonPath) {
516
+ const raw = await fs.readFile(path.resolve(uploadJsonPath), 'utf8')
517
+ let parsed
518
+
519
+ try {
520
+ parsed = JSON.parse(raw)
521
+ } catch {
522
+ throw new Error('Invalid upload JSON file.')
523
+ }
524
+
525
+ const meta = normalizeClientEncryptionMeta(parsed.clientEncryptionMeta)
526
+ if (!meta) {
527
+ throw new Error('Upload JSON is missing clientEncryptionMeta.')
528
+ }
529
+
530
+ const encryptionToken = String(parsed.encryptionToken || '').trim()
531
+ if (!encryptionToken) {
532
+ throw new Error('Upload JSON is missing encryptionToken.')
533
+ }
534
+
535
+ return {
536
+ linkToken: String(parsed.token || '').trim(),
537
+ encryptionToken,
538
+ meta,
539
+ originalName: parsed.originalName || 'download',
540
+ shareUrl: parsed.shareUrl || '',
541
+ expiresAt: parsed.expiresAt || null,
542
+ }
543
+ }
544
+
545
+ async function applyUploadJsonDefaults(options) {
546
+ if (!options.uploadJsonPath) {
547
+ return options
548
+ }
549
+
550
+ const upload = await loadUploadJson(options.uploadJsonPath)
551
+
552
+ return {
553
+ ...options,
554
+ linkToken: options.linkToken || upload.linkToken,
555
+ encryptionToken: options.encryptionToken || upload.encryptionToken,
556
+ inlineMeta: options.inlineMeta || upload.meta,
557
+ originalName: options.originalName || upload.originalName,
558
+ shareUrl: options.shareUrl || upload.shareUrl,
559
+ uploadExpiresAt: upload.expiresAt,
560
+ }
561
+ }
562
+
563
+ function resolveEncryptionToken(encryptionToken, shareReference, linkToken = '') {
491
564
  const resolved = String(encryptionToken || shareReference?.encryptionToken || '').trim()
492
565
 
566
+ hintTokenConfusion(resolved, linkToken)
567
+
493
568
  if (!resolved) {
494
- throw new Error('Missing encryption token. Pass --token or include #token=... in --share-url.')
569
+ throw new Error(
570
+ 'Missing encryption token. Pass --token with the `#token=...` value from the share URL '
571
+ + '(stored as `encryptionToken` in upload JSON), or use --upload-json upload.json.',
572
+ )
495
573
  }
496
574
 
497
575
  return resolved
@@ -520,27 +598,45 @@ function buildDecryptResult({
520
598
  }
521
599
 
522
600
  async function decryptLocalFile(inputPath, options = {}) {
601
+ const resolvedOptions = await applyUploadJsonDefaults(options)
523
602
  const absolutePath = path.resolve(inputPath)
524
603
  const stat = await fs.stat(absolutePath)
525
- const shareReference = parseShareReference(options.shareUrl || '', {
526
- encryptionToken: options.encryptionToken,
527
- baseUrl: options.baseUrl,
604
+ const shareReference = parseShareReference(resolvedOptions.shareUrl || '', {
605
+ encryptionToken: resolvedOptions.encryptionToken,
606
+ baseUrl: resolvedOptions.baseUrl,
528
607
  })
529
- const linkToken = options.linkToken || shareReference.linkToken
608
+ const linkToken = resolvedOptions.linkToken || shareReference.linkToken
609
+
610
+ if (
611
+ !resolvedOptions.uploadJsonPath
612
+ && !resolvedOptions.metaPath
613
+ && !resolvedOptions.inlineMeta
614
+ && !linkToken
615
+ ) {
616
+ hintTokenConfusion(resolvedOptions.encryptionToken, '')
617
+ }
618
+
530
619
  const resolved = await resolveEncryptionMeta({
531
- metaPath: options.metaPath,
620
+ metaPath: resolvedOptions.metaPath,
621
+ inlineMeta: resolvedOptions.inlineMeta,
532
622
  linkToken,
533
- baseUrl: options.baseUrl || shareReference.baseUrl,
534
- signal: options.signal,
623
+ baseUrl: resolvedOptions.baseUrl || shareReference.baseUrl,
624
+ signal: resolvedOptions.signal,
535
625
  })
536
626
  const meta = resolved.meta || resolved
537
627
  const linkInfo = resolved.linkInfo || null
538
- const encryptionToken = resolveEncryptionToken(options.encryptionToken, shareReference)
628
+ const encryptionToken = resolveEncryptionToken(
629
+ resolvedOptions.encryptionToken,
630
+ shareReference,
631
+ linkToken,
632
+ )
539
633
  const tokenBytes = importToken(encryptionToken)
540
634
  const noncePrefix = fromBase64Url(meta.noncePrefix)
541
- const originalName = linkInfo?.originalName || options.originalName || 'download'
542
- const outputName = sanitizeOutputName(options.output ? path.basename(options.output) : originalName)
543
- const outputPath = path.resolve(options.output || path.join(options.outputDirectory || process.cwd(), outputName))
635
+ const originalName = linkInfo?.originalName || resolvedOptions.originalName || 'download'
636
+ const outputName = sanitizeOutputName(resolvedOptions.output ? path.basename(resolvedOptions.output) : originalName)
637
+ const outputPath = path.resolve(
638
+ resolvedOptions.output || path.join(resolvedOptions.outputDirectory || process.cwd(), outputName),
639
+ )
544
640
 
545
641
  await fs.mkdir(path.dirname(outputPath), { recursive: true })
546
642
 
@@ -551,8 +647,8 @@ async function decryptLocalFile(inputPath, options = {}) {
551
647
  tokenBytes,
552
648
  noncePrefix,
553
649
  outputPath,
554
- onProgress: options.onProgress,
555
- signal: options.signal,
650
+ onProgress: resolvedOptions.onProgress,
651
+ signal: resolvedOptions.signal,
556
652
  })
557
653
 
558
654
  return buildDecryptResult({
@@ -560,7 +656,7 @@ async function decryptLocalFile(inputPath, options = {}) {
560
656
  originalName,
561
657
  size: meta.originalSize,
562
658
  mimeType: meta.mimeType,
563
- expiresAt: linkInfo?.expiresAt || null,
659
+ expiresAt: linkInfo?.expiresAt || resolvedOptions.uploadExpiresAt || null,
564
660
  linkToken,
565
661
  storageMode: 'parts',
566
662
  sourcePath: absolutePath,
@@ -573,8 +669,8 @@ async function decryptLocalFile(inputPath, options = {}) {
573
669
  tokenBytes,
574
670
  noncePrefix,
575
671
  outputPath,
576
- onProgress: options.onProgress,
577
- signal: options.signal,
672
+ onProgress: resolvedOptions.onProgress,
673
+ signal: resolvedOptions.signal,
578
674
  })
579
675
 
580
676
  return buildDecryptResult({
@@ -582,7 +678,7 @@ async function decryptLocalFile(inputPath, options = {}) {
582
678
  originalName,
583
679
  size: meta.originalSize,
584
680
  mimeType: meta.mimeType,
585
- expiresAt: linkInfo?.expiresAt || null,
681
+ expiresAt: linkInfo?.expiresAt || resolvedOptions.uploadExpiresAt || null,
586
682
  linkToken,
587
683
  storageMode: 'single',
588
684
  sourcePath: absolutePath,
@@ -590,21 +686,22 @@ async function decryptLocalFile(inputPath, options = {}) {
590
686
  }
591
687
 
592
688
  export async function decryptFile(input, options = {}) {
689
+ const resolvedOptions = await applyUploadJsonDefaults(options)
593
690
  const trimmedInput = String(input || '').trim()
594
691
  if (!trimmedInput) {
595
692
  throw new Error('Missing share URL, link token, or encrypted file path.')
596
693
  }
597
694
 
598
- if (options.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
599
- return decryptLocalFile(trimmedInput, options)
695
+ if (resolvedOptions.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
696
+ return decryptLocalFile(trimmedInput, resolvedOptions)
600
697
  }
601
698
 
602
699
  const parsed = parseDecryptInput(trimmedInput, {
603
- encryptionToken: options.encryptionToken,
604
- baseUrl: options.baseUrl,
700
+ encryptionToken: resolvedOptions.encryptionToken,
701
+ baseUrl: resolvedOptions.baseUrl,
605
702
  })
606
703
 
607
- const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, options.signal)
704
+ const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal)
608
705
 
609
706
  if (linkInfo.status === 'expired') {
610
707
  throw new Error('This file link has expired.')
@@ -621,8 +718,12 @@ export async function decryptFile(input, options = {}) {
621
718
 
622
719
  const tokenBytes = importToken(parsed.encryptionToken)
623
720
  const noncePrefix = fromBase64Url(meta.noncePrefix)
624
- const outputName = sanitizeOutputName(options.output ? path.basename(options.output) : linkInfo.originalName)
625
- const outputPath = path.resolve(options.output || path.join(options.outputDirectory || process.cwd(), outputName))
721
+ const outputName = sanitizeOutputName(
722
+ resolvedOptions.output ? path.basename(resolvedOptions.output) : linkInfo.originalName,
723
+ )
724
+ const outputPath = path.resolve(
725
+ resolvedOptions.output || path.join(resolvedOptions.outputDirectory || process.cwd(), outputName),
726
+ )
626
727
 
627
728
  await fs.mkdir(path.dirname(outputPath), { recursive: true })
628
729
 
@@ -634,8 +735,8 @@ export async function decryptFile(input, options = {}) {
634
735
  tokenBytes,
635
736
  noncePrefix,
636
737
  outputPath,
637
- onProgress: options.onProgress,
638
- signal: options.signal,
738
+ onProgress: resolvedOptions.onProgress,
739
+ signal: resolvedOptions.signal,
639
740
  })
640
741
  } else {
641
742
  const encryptedFileUrl = linkInfo.downloadUrl
@@ -647,8 +748,8 @@ export async function decryptFile(input, options = {}) {
647
748
  tokenBytes,
648
749
  noncePrefix,
649
750
  outputPath,
650
- onProgress: options.onProgress,
651
- signal: options.signal,
751
+ onProgress: resolvedOptions.onProgress,
752
+ signal: resolvedOptions.signal,
652
753
  })
653
754
  }
654
755
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
5
5
  "type": "module",
6
6
  "bin": {