packup-cli 1.0.0 → 1.2.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/bin/packup.js +91 -11
- package/package.json +1 -1
- package/src/crypto.js +51 -0
- package/src/format.js +8 -5
- package/src/index.js +51 -8
package/bin/packup.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// bin/packup.js
|
|
3
|
-
import { pack, unpack, info } from '../src/index.js';
|
|
3
|
+
import { pack, unpack, info, isEncrypted } from '../src/index.js';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
5
6
|
import { join, dirname } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { readClipboard } from '../src/clipboard.js';
|
|
7
10
|
|
|
8
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
12
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -22,13 +25,59 @@ Usage:
|
|
|
22
25
|
${NAME} info -i <file> Show contents from file
|
|
23
26
|
|
|
24
27
|
Pack options:
|
|
28
|
+
-e, --encrypt Encrypt with password (AES-256-CBC)
|
|
25
29
|
--include-deps Include dependency dirs (node_modules, vendor, etc.)
|
|
26
30
|
|
|
31
|
+
Environment:
|
|
32
|
+
PACKUP_PASSWORD Password for encryption/decryption (skips prompt)
|
|
33
|
+
|
|
27
34
|
General:
|
|
28
35
|
-h, --help Show this help
|
|
29
36
|
-v, --version Show version`);
|
|
30
37
|
}
|
|
31
38
|
|
|
39
|
+
// Get password from env var or prompt
|
|
40
|
+
async function getPassword(confirm = false) {
|
|
41
|
+
// Use env var if set (AI agent friendly)
|
|
42
|
+
if (process.env.PACKUP_PASSWORD) {
|
|
43
|
+
return process.env.PACKUP_PASSWORD;
|
|
44
|
+
}
|
|
45
|
+
// Otherwise prompt interactively
|
|
46
|
+
return promptPassword('Password: ', confirm);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function promptPassword(prompt, confirm = false) {
|
|
50
|
+
const question = (text) => new Promise((resolve) => {
|
|
51
|
+
const rl = createInterface({
|
|
52
|
+
input: process.stdin,
|
|
53
|
+
output: process.stderr,
|
|
54
|
+
});
|
|
55
|
+
rl.question(text, (answer) => {
|
|
56
|
+
rl.close();
|
|
57
|
+
resolve(answer);
|
|
58
|
+
});
|
|
59
|
+
// Hide input on TTY
|
|
60
|
+
rl._writeToOutput = () => {};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const pass1 = await question(prompt);
|
|
64
|
+
process.stderr.write('\n');
|
|
65
|
+
|
|
66
|
+
if (confirm) {
|
|
67
|
+
const pass2 = await question('Confirm password: ');
|
|
68
|
+
process.stderr.write('\n');
|
|
69
|
+
if (pass1 !== pass2) {
|
|
70
|
+
throw new Error('Passwords do not match');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!pass1) {
|
|
75
|
+
throw new Error('Password cannot be empty');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return pass1;
|
|
79
|
+
}
|
|
80
|
+
|
|
32
81
|
function humanSize(bytes) {
|
|
33
82
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
34
83
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -54,9 +103,11 @@ async function main() {
|
|
|
54
103
|
let output = null;
|
|
55
104
|
let folder = null;
|
|
56
105
|
let includeDeps = false;
|
|
106
|
+
let encrypt = false;
|
|
57
107
|
|
|
58
108
|
for (let i = 1; i < args.length; i++) {
|
|
59
109
|
if (args[i] === '-o' || args[i] === '--output') { output = args[++i]; }
|
|
110
|
+
else if (args[i] === '-e' || args[i] === '--encrypt') { encrypt = true; }
|
|
60
111
|
else if (args[i] === '--include-deps') { includeDeps = true; }
|
|
61
112
|
else if (!args[i].startsWith('-')) { folder = args[i]; }
|
|
62
113
|
else { console.error(`Error: Unknown option: ${args[i]}`); process.exit(1); }
|
|
@@ -64,14 +115,21 @@ async function main() {
|
|
|
64
115
|
|
|
65
116
|
if (!folder) { console.error('Error: No folder specified.'); process.exit(1); }
|
|
66
117
|
|
|
118
|
+
let password;
|
|
119
|
+
if (encrypt) {
|
|
120
|
+
password = await getPassword(true);
|
|
121
|
+
}
|
|
122
|
+
|
|
67
123
|
const result = await pack(folder, {
|
|
68
124
|
clipboard: !output,
|
|
69
125
|
output: output || undefined,
|
|
70
126
|
includeDeps,
|
|
127
|
+
password,
|
|
71
128
|
});
|
|
72
129
|
|
|
73
130
|
const target = output || 'clipboard';
|
|
74
|
-
|
|
131
|
+
const encLabel = result.encrypted ? ' encrypted,' : '';
|
|
132
|
+
console.log(`Packed '${folder}' ->${encLabel} ${target} (${humanSize(result.zipSize)} zip, ${humanSize(result.stringSize)} string)`);
|
|
75
133
|
|
|
76
134
|
} else if (command === 'unpack') {
|
|
77
135
|
let inputFile = null;
|
|
@@ -83,10 +141,21 @@ async function main() {
|
|
|
83
141
|
else { console.error(`Error: Unknown option: ${args[i]}`); process.exit(1); }
|
|
84
142
|
}
|
|
85
143
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
144
|
+
// Read input first to check if encrypted
|
|
145
|
+
let raw;
|
|
146
|
+
if (inputFile) {
|
|
147
|
+
raw = await readFile(inputFile, 'utf8');
|
|
148
|
+
} else {
|
|
149
|
+
raw = await readClipboard();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let password;
|
|
153
|
+
if (isEncrypted(raw)) {
|
|
154
|
+
console.log('Encrypted archive detected');
|
|
155
|
+
password = await getPassword(false);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = await unpack(raw, dest, { password });
|
|
90
159
|
|
|
91
160
|
console.log('Integrity OK');
|
|
92
161
|
console.log(`Files: ${result.fileCount}`);
|
|
@@ -100,12 +169,23 @@ async function main() {
|
|
|
100
169
|
else { console.error(`Error: Unknown option: ${args[i]}`); process.exit(1); }
|
|
101
170
|
}
|
|
102
171
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
172
|
+
// Read input first to check if encrypted
|
|
173
|
+
let raw;
|
|
174
|
+
if (inputFile) {
|
|
175
|
+
raw = await readFile(inputFile, 'utf8');
|
|
176
|
+
} else {
|
|
177
|
+
raw = await readClipboard();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let password;
|
|
181
|
+
if (isEncrypted(raw)) {
|
|
182
|
+
password = await getPassword(false);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const details = await info(raw, { password });
|
|
107
186
|
|
|
108
|
-
|
|
187
|
+
const encLabel = details.encrypted ? ' (encrypted)' : '';
|
|
188
|
+
console.log(`${NAME} v${details.version}${encLabel} — integrity OK`);
|
|
109
189
|
console.log(` Source: ${details.source}/`);
|
|
110
190
|
console.log(` Files: ${details.fileCount}`);
|
|
111
191
|
console.log(` Size: ${humanSize(details.compressedSize)} (compressed)`);
|
package/package.json
CHANGED
package/src/crypto.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/crypto.js
|
|
2
|
+
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const ALGORITHM = 'aes-256-cbc';
|
|
5
|
+
const KEY_LENGTH = 32;
|
|
6
|
+
const IV_LENGTH = 16;
|
|
7
|
+
const SALT_LENGTH = 8; // OpenSSL uses 8-byte salt
|
|
8
|
+
const ITERATIONS = 100000;
|
|
9
|
+
const DIGEST = 'sha256';
|
|
10
|
+
|
|
11
|
+
// OpenSSL-compatible format: "Salted__" (8 bytes) + salt (8 bytes) + ciphertext
|
|
12
|
+
const OPENSSL_MAGIC = Buffer.from('Salted__');
|
|
13
|
+
|
|
14
|
+
function deriveKey(password, salt) {
|
|
15
|
+
return pbkdf2Sync(password, salt, ITERATIONS, KEY_LENGTH + IV_LENGTH, DIGEST);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function encrypt(data, password) {
|
|
19
|
+
const salt = randomBytes(SALT_LENGTH);
|
|
20
|
+
const derived = deriveKey(password, salt);
|
|
21
|
+
const key = derived.slice(0, KEY_LENGTH);
|
|
22
|
+
const iv = derived.slice(KEY_LENGTH, KEY_LENGTH + IV_LENGTH);
|
|
23
|
+
|
|
24
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
25
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
26
|
+
|
|
27
|
+
// OpenSSL-compatible format
|
|
28
|
+
return Buffer.concat([OPENSSL_MAGIC, salt, encrypted]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function decrypt(data, password) {
|
|
32
|
+
// Verify OpenSSL magic
|
|
33
|
+
const magic = data.slice(0, 8);
|
|
34
|
+
if (!magic.equals(OPENSSL_MAGIC)) {
|
|
35
|
+
throw new Error('Invalid encrypted data format');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const salt = data.slice(8, 8 + SALT_LENGTH);
|
|
39
|
+
const ciphertext = data.slice(8 + SALT_LENGTH);
|
|
40
|
+
|
|
41
|
+
const derived = deriveKey(password, salt);
|
|
42
|
+
const key = derived.slice(0, KEY_LENGTH);
|
|
43
|
+
const iv = derived.slice(KEY_LENGTH, KEY_LENGTH + IV_LENGTH);
|
|
44
|
+
|
|
45
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
46
|
+
try {
|
|
47
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new Error('Decryption failed. Wrong password or corrupted data.');
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/format.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
const PREFIX = 'PACKUP';
|
|
4
|
-
const
|
|
4
|
+
const VERSION_PLAIN = 1;
|
|
5
|
+
const VERSION_ENCRYPTED = 2;
|
|
5
6
|
|
|
6
|
-
export function buildString(base64Data) {
|
|
7
|
+
export function buildString(base64Data, encrypted = false) {
|
|
7
8
|
const hash = createHash('sha256').update(base64Data).digest('hex');
|
|
8
|
-
|
|
9
|
+
const version = encrypted ? VERSION_ENCRYPTED : VERSION_PLAIN;
|
|
10
|
+
return `${PREFIX}:${version}:${hash}:${base64Data}`;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function parseString(raw) {
|
|
@@ -25,7 +27,7 @@ export function parseString(raw) {
|
|
|
25
27
|
const hash = raw.slice(secondColon + 1, thirdColon);
|
|
26
28
|
const data = raw.slice(thirdColon + 1);
|
|
27
29
|
|
|
28
|
-
if (version !==
|
|
30
|
+
if (version !== VERSION_PLAIN && version !== VERSION_ENCRYPTED) {
|
|
29
31
|
throw new Error(`Unsupported packup version: ${version}`);
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -40,5 +42,6 @@ export function parseString(raw) {
|
|
|
40
42
|
throw new Error('Integrity check FAILED (hash mismatch). Data may be corrupted or tampered with.');
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
const encrypted = version === VERSION_ENCRYPTED;
|
|
46
|
+
return { version, hash, data, encrypted };
|
|
44
47
|
}
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createZipBuffer } from './compress.js';
|
|
|
3
3
|
import { extractZipBuffer, listZipEntries } from './decompress.js';
|
|
4
4
|
import { buildString, parseString } from './format.js';
|
|
5
5
|
import { readClipboard, writeClipboard } from './clipboard.js';
|
|
6
|
+
import { encrypt, decrypt } from './crypto.js';
|
|
6
7
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
8
|
import { gzip, gunzip } from 'node:zlib';
|
|
8
9
|
import { promisify } from 'node:util';
|
|
@@ -10,11 +11,19 @@ import { promisify } from 'node:util';
|
|
|
10
11
|
const gzipAsync = promisify(gzip);
|
|
11
12
|
const gunzipAsync = promisify(gunzip);
|
|
12
13
|
|
|
13
|
-
export async function pack(folder, { clipboard = false, output, includeDeps = false } = {}) {
|
|
14
|
+
export async function pack(folder, { clipboard = false, output, includeDeps = false, password } = {}) {
|
|
14
15
|
const zipBuffer = await createZipBuffer(folder, { includeDeps });
|
|
15
16
|
const gzipped = await gzipAsync(zipBuffer, { level: 9 });
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
let dataToEncode = gzipped;
|
|
19
|
+
const isEncrypted = Boolean(password);
|
|
20
|
+
|
|
21
|
+
if (isEncrypted) {
|
|
22
|
+
dataToEncode = encrypt(gzipped, password);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const base64Data = dataToEncode.toString('base64');
|
|
26
|
+
const packupString = buildString(base64Data, isEncrypted);
|
|
18
27
|
|
|
19
28
|
const fileCount = listZipEntries(zipBuffer).length;
|
|
20
29
|
const result = {
|
|
@@ -22,6 +31,7 @@ export async function pack(folder, { clipboard = false, output, includeDeps = fa
|
|
|
22
31
|
zipSize: zipBuffer.length,
|
|
23
32
|
stringSize: packupString.length,
|
|
24
33
|
fileCount,
|
|
34
|
+
encrypted: isEncrypted,
|
|
25
35
|
};
|
|
26
36
|
|
|
27
37
|
if (output) {
|
|
@@ -49,11 +59,22 @@ export async function unpack(input, dest = '.', options = {}) {
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
const parsed = parseString(raw);
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
let dataBuffer = Buffer.from(parsed.data, 'base64');
|
|
63
|
+
|
|
64
|
+
// Decrypt if encrypted
|
|
65
|
+
if (parsed.encrypted) {
|
|
66
|
+
if (!options.password) {
|
|
67
|
+
const error = new Error('Encrypted archive requires password');
|
|
68
|
+
error.encrypted = true;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
dataBuffer = decrypt(dataBuffer, options.password);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const zipBuffer = await gunzipAsync(dataBuffer, { maxOutputLength: 500 * 1024 * 1024 });
|
|
54
75
|
|
|
55
76
|
const result = await extractZipBuffer(zipBuffer, dest);
|
|
56
|
-
return { fileCount: result.fileCount, dest };
|
|
77
|
+
return { fileCount: result.fileCount, dest, encrypted: parsed.encrypted };
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
export async function info(input, options = {}) {
|
|
@@ -70,8 +91,19 @@ export async function info(input, options = {}) {
|
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
const parsed = parseString(raw);
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
let dataBuffer = Buffer.from(parsed.data, 'base64');
|
|
95
|
+
|
|
96
|
+
// Decrypt if encrypted
|
|
97
|
+
if (parsed.encrypted) {
|
|
98
|
+
if (!options.password) {
|
|
99
|
+
const error = new Error('Encrypted archive requires password');
|
|
100
|
+
error.encrypted = true;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
dataBuffer = decrypt(dataBuffer, options.password);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const zipBuffer = await gunzipAsync(dataBuffer, { maxOutputLength: 500 * 1024 * 1024 });
|
|
75
107
|
|
|
76
108
|
const entries = listZipEntries(zipBuffer);
|
|
77
109
|
const source = entries.length > 0 ? entries[0].split('/')[0] : 'unknown';
|
|
@@ -83,5 +115,16 @@ export async function info(input, options = {}) {
|
|
|
83
115
|
fileCount: entries.length,
|
|
84
116
|
compressedSize: zipBuffer.length,
|
|
85
117
|
entries,
|
|
118
|
+
encrypted: parsed.encrypted,
|
|
86
119
|
};
|
|
87
120
|
}
|
|
121
|
+
|
|
122
|
+
// Helper to check if archive is encrypted without full parsing
|
|
123
|
+
export function isEncrypted(raw) {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = parseString(raw);
|
|
126
|
+
return parsed.encrypted;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|