bytifi 0.1.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 ADDED
@@ -0,0 +1,64 @@
1
+ # Bytifi CLI
2
+
3
+ Official command-line tool for encrypting and uploading files to [Bytifi](https://bytifi.com).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g bytifi
9
+ ```
10
+
11
+ Requires **Node.js 18+**.
12
+
13
+ Or install from source:
14
+
15
+ ```bash
16
+ git clone https://github.com/jpwcguy/Bytifi.git
17
+ cd Bytifi
18
+ npm link
19
+ ```
20
+
21
+ ## Setup
22
+
23
+ ```bash
24
+ export BYTIFI_API_KEY=usk_your_api_key_here
25
+ ```
26
+
27
+ Create an API key in **Account → API** on bytifi.com.
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ bytifi upload ./photo.png
33
+ bytifi upload ./photo.png --expires 60 --json
34
+ bytifi upload ./large.iso -q
35
+ ```
36
+
37
+ ### Options
38
+
39
+ | Flag | Description |
40
+ |------|-------------|
41
+ | `-k, --api-key` | API key (default: `BYTIFI_API_KEY`) |
42
+ | `-e, --expires` | Link lifetime in minutes: `5`, `15`, `30`, `60`, `120` |
43
+ | `--delete-on-download` | Delete after first download |
44
+ | `--json` | Machine-readable JSON output |
45
+ | `-q, --quiet` | Print only the share URL |
46
+ | `--mime-type` | Override MIME type detection |
47
+
48
+ ## How it works
49
+
50
+ 1. Encrypts the file locally with AES-GCM (same format as the website)
51
+ 2. Uploads encrypted bytes via the public API
52
+ 3. Prints a share URL including `#token=...`
53
+
54
+ The server never receives plaintext or the decryption token.
55
+
56
+ ## Development
57
+
58
+ ```bash
59
+ node bin/bytifi.js upload ./file.png --json
60
+ ```
61
+
62
+ ## Status
63
+
64
+ WIP — v0.1.0 direct + multipart upload supported.
package/bin/bytifi.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+
11
+ function printHelp() {
12
+ process.stdout.write(`Bytifi CLI — encrypt and upload files
13
+
14
+ Usage:
15
+ bytifi upload <file> [options]
16
+
17
+ 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)
20
+ --delete-on-download Remove file after first download
21
+ --json Print machine-readable JSON to stdout
22
+ -q, --quiet Print only the share URL
23
+ --mime-type <type> Override detected MIME type
24
+ --base-url <url> API base URL (default: https://bytifi.com)
25
+ -h, --help Show this help
26
+
27
+ Examples:
28
+ bytifi upload ./photo.png
29
+ BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
30
+ `)
31
+ }
32
+
33
+ function parseArgs(argv) {
34
+ const positional = []
35
+ const options = {
36
+ apiKey: process.env.BYTIFI_API_KEY || '',
37
+ expiresInMinutes: 30,
38
+ deleteOnDownload: false,
39
+ json: false,
40
+ quiet: false,
41
+ mimeType: '',
42
+ baseUrl: 'https://bytifi.com',
43
+ help: false,
44
+ }
45
+
46
+ for (let index = 0; index < argv.length; index += 1) {
47
+ const arg = argv[index]
48
+
49
+ if (arg === '--help' || arg === '-h') {
50
+ options.help = true
51
+ continue
52
+ }
53
+
54
+ if (arg === '--json') {
55
+ options.json = true
56
+ continue
57
+ }
58
+
59
+ if (arg === '--quiet' || arg === '-q') {
60
+ options.quiet = true
61
+ continue
62
+ }
63
+
64
+ if (arg === '--delete-on-download') {
65
+ options.deleteOnDownload = true
66
+ continue
67
+ }
68
+
69
+ if (arg === '--api-key' || arg === '-k') {
70
+ options.apiKey = argv[index + 1] || ''
71
+ index += 1
72
+ continue
73
+ }
74
+
75
+ if (arg === '--expires' || arg === '-e') {
76
+ options.expiresInMinutes = Number(argv[index + 1])
77
+ index += 1
78
+ continue
79
+ }
80
+
81
+ if (arg === '--mime-type') {
82
+ options.mimeType = argv[index + 1] || ''
83
+ index += 1
84
+ continue
85
+ }
86
+
87
+ if (arg === '--base-url') {
88
+ options.baseUrl = argv[index + 1] || options.baseUrl
89
+ index += 1
90
+ continue
91
+ }
92
+
93
+ if (arg.startsWith('-')) {
94
+ throw new Error(`Unknown option: ${arg}`)
95
+ }
96
+
97
+ positional.push(arg)
98
+ }
99
+
100
+ return { positional, options }
101
+ }
102
+
103
+ function validateExpires(minutes) {
104
+ const allowed = new Set([5, 15, 30, 60, 120])
105
+ if (!allowed.has(minutes)) {
106
+ throw new Error('expires must be one of: 5, 15, 30, 60, 120')
107
+ }
108
+ }
109
+
110
+ async function runUpload(filePath, options) {
111
+ validateExpires(options.expiresInMinutes)
112
+
113
+ if (!options.apiKey) {
114
+ throw new Error('Missing API key. Pass --api-key or set BYTIFI_API_KEY.')
115
+ }
116
+
117
+ 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(`Uploading encrypted parts: ${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`)
146
+ }
147
+
148
+ function exitCodeForError(error) {
149
+ if (error instanceof BytifiNetworkError) return 3
150
+ if (error instanceof BytifiApiError) return 2
151
+ return 1
152
+ }
153
+
154
+ async function main() {
155
+ const [command, ...rest] = process.argv.slice(2)
156
+
157
+ if (!command || command === '--help' || command === '-h') {
158
+ printHelp()
159
+ process.exit(0)
160
+ }
161
+
162
+ if (command !== 'upload') {
163
+ if (command === 'help') {
164
+ printHelp()
165
+ process.exit(0)
166
+ }
167
+
168
+ throw new Error(`Unknown command: ${command}`)
169
+ }
170
+
171
+ const { positional, options } = parseArgs(rest)
172
+
173
+ if (options.help) {
174
+ printHelp()
175
+ process.exit(0)
176
+ }
177
+
178
+ const filePath = positional[0]
179
+ if (!filePath) {
180
+ throw new Error('Missing file path. Usage: bytifi upload <file>')
181
+ }
182
+
183
+ await runUpload(filePath, options)
184
+ }
185
+
186
+ main().catch((error) => {
187
+ process.stderr.write(`${error.message || 'Upload failed.'}\n`)
188
+ process.exit(exitCodeForError(error))
189
+ })
@@ -0,0 +1,14 @@
1
+ export function toBase64Url(bytes) {
2
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
3
+ return buffer
4
+ .toString('base64')
5
+ .replace(/\+/g, '-')
6
+ .replace(/\//g, '_')
7
+ .replace(/=+$/g, '')
8
+ }
9
+
10
+ export function fromBase64Url(value) {
11
+ const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/')
12
+ const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
13
+ return Buffer.from(padded, 'base64')
14
+ }
package/lib/crypto.js ADDED
@@ -0,0 +1,148 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { fromBase64Url, toBase64Url } from './base64url.js'
5
+
6
+ export const ENCRYPTED_PART_SIZE = 32 * 1024 * 1024
7
+ export const PLAIN_CHUNK_SIZE = ENCRYPTED_PART_SIZE - 16
8
+ export const DIRECT_UPLOAD_LIMIT_BYTES = 100 * 1024 * 1024
9
+
10
+ export function buildChunkIv(noncePrefix, chunkIndex) {
11
+ const iv = Buffer.alloc(12)
12
+ noncePrefix.copy(iv, 0, 0, 8)
13
+ iv.writeUInt32BE(chunkIndex, 8)
14
+ return iv
15
+ }
16
+
17
+ export function encryptChunk(plainChunk, key, noncePrefix, chunkIndex) {
18
+ const iv = buildChunkIv(noncePrefix, chunkIndex)
19
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
20
+ const encrypted = Buffer.concat([cipher.update(plainChunk), cipher.final()])
21
+ const tag = cipher.getAuthTag()
22
+ return Buffer.concat([encrypted, tag])
23
+ }
24
+
25
+ export function createEncryptionToken(tokenBytes = crypto.randomBytes(32)) {
26
+ if (tokenBytes.length !== 32) {
27
+ throw new Error('Encryption token must be 32 bytes.')
28
+ }
29
+
30
+ return {
31
+ tokenBytes,
32
+ token: toBase64Url(tokenBytes),
33
+ }
34
+ }
35
+
36
+ export function buildClientEncryptionMeta({
37
+ plainChunkSize,
38
+ chunkCount,
39
+ noncePrefix,
40
+ originalSize,
41
+ mimeType,
42
+ }) {
43
+ return {
44
+ version: 1,
45
+ algorithm: 'AES-GCM',
46
+ chunkSize: plainChunkSize,
47
+ chunkCount,
48
+ noncePrefix: toBase64Url(noncePrefix),
49
+ originalSize,
50
+ mimeType: mimeType || 'application/octet-stream',
51
+ }
52
+ }
53
+
54
+ export async function encryptFileBuffer(fileBuffer, {
55
+ originalName = 'upload',
56
+ mimeType = 'application/octet-stream',
57
+ tokenBytes = crypto.randomBytes(32),
58
+ noncePrefix = crypto.randomBytes(8),
59
+ plainChunkSize = PLAIN_CHUNK_SIZE,
60
+ } = {}) {
61
+ if (tokenBytes.length !== 32) throw new Error('Encryption token must be 32 bytes.')
62
+ if (noncePrefix.length !== 8) throw new Error('Nonce prefix must be 8 bytes.')
63
+
64
+ const key = tokenBytes
65
+ const chunkCount = Math.ceil(fileBuffer.length / plainChunkSize) || 1
66
+ const encryptedParts = []
67
+ let encryptedSize = 0
68
+
69
+ for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
70
+ const start = chunkIndex * plainChunkSize
71
+ const end = Math.min(fileBuffer.length, start + plainChunkSize)
72
+ const plainChunk = fileBuffer.subarray(start, end)
73
+ const encryptedChunk = encryptChunk(plainChunk, key, noncePrefix, chunkIndex)
74
+ encryptedParts.push(encryptedChunk)
75
+ encryptedSize += encryptedChunk.length
76
+ }
77
+
78
+ const meta = buildClientEncryptionMeta({
79
+ plainChunkSize,
80
+ chunkCount,
81
+ noncePrefix,
82
+ originalSize: fileBuffer.length,
83
+ mimeType,
84
+ })
85
+
86
+ return {
87
+ token: toBase64Url(tokenBytes),
88
+ tokenBytes,
89
+ meta,
90
+ encryptedParts,
91
+ encryptedBuffer: Buffer.concat(encryptedParts),
92
+ encryptedSize,
93
+ originalName,
94
+ mimeType,
95
+ originalSize: fileBuffer.length,
96
+ }
97
+ }
98
+
99
+ export async function encryptFile(filePath, options = {}) {
100
+ const absolutePath = path.resolve(filePath)
101
+ const fileBuffer = await fs.readFile(absolutePath)
102
+ const originalName = options.originalName || path.basename(absolutePath)
103
+ const mimeType = options.mimeType || guessMimeType(originalName)
104
+
105
+ return encryptFileBuffer(fileBuffer, {
106
+ ...options,
107
+ originalName,
108
+ mimeType,
109
+ })
110
+ }
111
+
112
+ export function decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunkIndex) {
113
+ if (encryptedChunk.length < 16) {
114
+ throw new Error('Encrypted chunk is too small.')
115
+ }
116
+
117
+ const iv = buildChunkIv(noncePrefix, chunkIndex)
118
+ const ciphertext = encryptedChunk.subarray(0, encryptedChunk.length - 16)
119
+ const tag = encryptedChunk.subarray(encryptedChunk.length - 16)
120
+ const decipher = crypto.createDecipheriv('aes-256-gcm', tokenBytes, iv)
121
+ decipher.setAuthTag(tag)
122
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()])
123
+ }
124
+
125
+ export function importToken(token) {
126
+ const tokenBytes = fromBase64Url(token)
127
+ if (tokenBytes.length !== 32) {
128
+ throw new Error('Invalid encryption token length.')
129
+ }
130
+ return tokenBytes
131
+ }
132
+
133
+ function guessMimeType(filename) {
134
+ const ext = path.extname(filename).toLowerCase()
135
+ const map = {
136
+ '.png': 'image/png',
137
+ '.jpg': 'image/jpeg',
138
+ '.jpeg': 'image/jpeg',
139
+ '.gif': 'image/gif',
140
+ '.webp': 'image/webp',
141
+ '.pdf': 'application/pdf',
142
+ '.zip': 'application/zip',
143
+ '.txt': 'text/plain',
144
+ '.json': 'application/json',
145
+ '.iso': 'application/octet-stream',
146
+ }
147
+ return map[ext] || 'application/octet-stream'
148
+ }
package/lib/upload.js ADDED
@@ -0,0 +1,242 @@
1
+ import { DIRECT_UPLOAD_LIMIT_BYTES } from './crypto.js'
2
+
3
+ const DEFAULT_BASE_URL = 'https://bytifi.com'
4
+
5
+ export class BytifiApiError extends Error {
6
+ constructor(message, { status = 0, body = null } = {}) {
7
+ super(message)
8
+ this.name = 'BytifiApiError'
9
+ this.status = status
10
+ this.body = body
11
+ }
12
+ }
13
+
14
+ export class BytifiNetworkError extends Error {
15
+ constructor(message, { cause } = {}) {
16
+ super(message)
17
+ this.name = 'BytifiNetworkError'
18
+ this.cause = cause
19
+ }
20
+ }
21
+
22
+ function normalizeBaseUrl(baseUrl) {
23
+ return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
24
+ }
25
+
26
+ async function readResponseBody(response) {
27
+ const text = await response.text()
28
+ if (!text) return null
29
+
30
+ try {
31
+ return JSON.parse(text)
32
+ } catch {
33
+ return text
34
+ }
35
+ }
36
+
37
+ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null } = {}) {
38
+ const url = `${normalizeBaseUrl(baseUrl)}${path}`
39
+ const requestHeaders = {
40
+ Authorization: `Bearer ${apiKey}`,
41
+ ...headers,
42
+ }
43
+
44
+ let response
45
+
46
+ try {
47
+ response = await fetch(url, {
48
+ method,
49
+ headers: requestHeaders,
50
+ body,
51
+ })
52
+ } catch (error) {
53
+ throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
54
+ }
55
+
56
+ const payload = await readResponseBody(response)
57
+
58
+ if (!response.ok) {
59
+ const message = typeof payload === 'object' && payload?.error
60
+ ? payload.error
61
+ : typeof payload === 'string' && payload
62
+ ? payload
63
+ : `Request failed with status ${response.status}.`
64
+ throw new BytifiApiError(message, { status: response.status, body: payload })
65
+ }
66
+
67
+ return payload
68
+ }
69
+
70
+ function buildShareUrl(payload, encryptionToken) {
71
+ return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
72
+ }
73
+
74
+ function buildResult(payload, encryption, shareUrl) {
75
+ return {
76
+ shareUrl,
77
+ url: payload.url,
78
+ downloadUrl: payload.downloadUrl,
79
+ token: payload.token,
80
+ encryptionToken: encryption.token,
81
+ originalName: payload.originalName || encryption.originalName,
82
+ size: payload.size,
83
+ expiresAt: payload.expiresAt,
84
+ deleteOnDownload: payload.deleteOnDownload,
85
+ clientEncrypted: payload.clientEncrypted,
86
+ }
87
+ }
88
+
89
+ async function uploadDirect(encryption, {
90
+ apiKey,
91
+ baseUrl,
92
+ expiresInMinutes,
93
+ deleteOnDownload,
94
+ }) {
95
+ const formData = new FormData()
96
+ const blob = new Blob([encryption.encryptedBuffer], { type: 'application/octet-stream' })
97
+
98
+ formData.append('file', blob, encryption.originalName)
99
+ formData.append('clientEncrypted', 'true')
100
+ formData.append('clientEncryptionMeta', JSON.stringify(encryption.meta))
101
+ formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
102
+ formData.append('expiresInMinutes', String(expiresInMinutes))
103
+
104
+ const payload = await apiFetch(baseUrl, '/api/public/upload', {
105
+ apiKey,
106
+ method: 'POST',
107
+ body: formData,
108
+ })
109
+
110
+ const shareUrl = buildShareUrl(payload, encryption.token)
111
+ return buildResult(payload, encryption, shareUrl)
112
+ }
113
+
114
+ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
115
+ while (true) {
116
+ if (signal?.aborted) {
117
+ throw new Error('Upload aborted.')
118
+ }
119
+
120
+ const payload = await apiFetch(
121
+ baseUrl,
122
+ `/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
123
+ { apiKey },
124
+ )
125
+
126
+ if (payload.status !== 'processing') {
127
+ return payload
128
+ }
129
+
130
+ await new Promise((resolve) => setTimeout(resolve, 1500))
131
+ }
132
+ }
133
+
134
+ async function uploadMultipart(encryption, {
135
+ apiKey,
136
+ baseUrl,
137
+ expiresInMinutes,
138
+ deleteOnDownload,
139
+ onProgress,
140
+ signal,
141
+ concurrency = 3,
142
+ }) {
143
+ const partSize = encryption.meta.chunkSize + 16
144
+ const initPayload = await apiFetch(baseUrl, '/api/public/upload/init', {
145
+ apiKey,
146
+ method: 'POST',
147
+ headers: { 'Content-Type': 'application/json' },
148
+ body: JSON.stringify({
149
+ originalName: encryption.originalName,
150
+ mimeType: encryption.mimeType,
151
+ size: encryption.encryptedSize,
152
+ originalSize: encryption.originalSize,
153
+ clientEncrypted: true,
154
+ clientEncryptionMeta: encryption.meta,
155
+ partSize,
156
+ expiresInMinutes,
157
+ deleteOnDownload,
158
+ }),
159
+ })
160
+
161
+ const sessionToken = initPayload.sessionToken
162
+ const totalParts = encryption.encryptedParts.length
163
+ let completedParts = 0
164
+
165
+ const uploadPart = async (partNumber) => {
166
+ const partBuffer = encryption.encryptedParts[partNumber - 1]
167
+ await apiFetch(
168
+ baseUrl,
169
+ `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
170
+ {
171
+ apiKey,
172
+ method: 'PUT',
173
+ headers: { 'Content-Type': 'application/octet-stream' },
174
+ body: partBuffer,
175
+ },
176
+ )
177
+
178
+ completedParts += 1
179
+ onProgress?.(Math.round((completedParts / totalParts) * 100))
180
+ }
181
+
182
+ const queue = Array.from({ length: totalParts }, (_, index) => index + 1)
183
+ const workers = Array.from({ length: Math.min(concurrency, totalParts) }, async () => {
184
+ while (queue.length > 0) {
185
+ if (signal?.aborted) {
186
+ throw new Error('Upload aborted.')
187
+ }
188
+
189
+ const partNumber = queue.shift()
190
+ if (!partNumber) return
191
+ await uploadPart(partNumber)
192
+ }
193
+ })
194
+
195
+ try {
196
+ await Promise.all(workers)
197
+ } catch (error) {
198
+ await apiFetch(baseUrl, '/api/public/upload/abort', {
199
+ apiKey,
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ sessionToken }),
203
+ }).catch(() => {})
204
+
205
+ throw error
206
+ }
207
+
208
+ let completePayload = await apiFetch(baseUrl, '/api/public/upload/complete', {
209
+ apiKey,
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ sessionToken }),
213
+ })
214
+
215
+ if (completePayload.status === 'processing') {
216
+ completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
217
+ }
218
+
219
+ const shareUrl = buildShareUrl(completePayload, encryption.token)
220
+ return buildResult(completePayload, encryption, shareUrl)
221
+ }
222
+
223
+ export async function uploadEncrypted(encryption, options) {
224
+ if (!options?.apiKey) {
225
+ throw new Error('API key is required.')
226
+ }
227
+
228
+ if (encryption.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
229
+ return uploadDirect(encryption, options)
230
+ }
231
+
232
+ return uploadMultipart(encryption, options)
233
+ }
234
+
235
+ export async function uploadFile(filePath, options) {
236
+ const { encryptFile } = await import('./crypto.js')
237
+ const encryption = await encryptFile(filePath, {
238
+ mimeType: options?.mimeType,
239
+ })
240
+
241
+ return uploadEncrypted(encryption, options)
242
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "bytifi",
3
+ "version": "0.1.0",
4
+ "description": "Official Bytifi CLI — encrypt and upload files from the terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "bytifi": "./bin/bytifi.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "upload": "node bin/bytifi.js upload"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/jpwcguy/Bytifi.git"
22
+ },
23
+ "homepage": "https://bytifi.com",
24
+ "bugs": {
25
+ "url": "https://github.com/jpwcguy/Bytifi/issues"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "keywords": [
31
+ "bytifi",
32
+ "cli",
33
+ "encryption",
34
+ "upload",
35
+ "file-sharing"
36
+ ],
37
+ "license": "MIT"
38
+ }