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 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
- console.log(`Packed '${folder}' -> ${target} (${humanSize(result.zipSize)} zip, ${humanSize(result.stringSize)} string)`);
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
- const result = await unpack(null, dest, {
87
- input: inputFile || undefined,
88
- clipboard: !inputFile,
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
- const details = await info(null, {
104
- input: inputFile || undefined,
105
- clipboard: !inputFile,
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
- console.log(`${NAME} v${details.version} integrity OK`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "packup-cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pack folders into portable, integrity-verified strings",
5
5
  "type": "module",
6
6
  "bin": {
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 VERSION = 1;
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
- return `${PREFIX}:${VERSION}:${hash}:${base64Data}`;
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 !== 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
- return { version, hash, data };
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
- const base64Data = gzipped.toString('base64');
17
- const packupString = buildString(base64Data);
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
- const gzipped = Buffer.from(parsed.data, 'base64');
53
- const zipBuffer = await gunzipAsync(gzipped, { maxOutputLength: 500 * 1024 * 1024 });
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
- const gzipped = Buffer.from(parsed.data, 'base64');
74
- const zipBuffer = await gunzipAsync(gzipped, { maxOutputLength: 500 * 1024 * 1024 });
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
+ }