packup-cli 1.0.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 +128 -0
- package/package.json +27 -0
- package/src/clipboard.js +28 -0
- package/src/compress.js +37 -0
- package/src/decompress.js +46 -0
- package/src/exclude.js +30 -0
- package/src/format.js +44 -0
- package/src/index.js +87 -0
package/bin/packup.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/packup.js
|
|
3
|
+
import { pack, unpack, info } from '../src/index.js';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
10
|
+
|
|
11
|
+
const NAME = 'packup-cli';
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
console.log(`${NAME} ${pkg.version} — Pack folders into portable strings
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
${NAME} pack <folder> Pack folder, copy string to clipboard
|
|
18
|
+
${NAME} pack -o <file> <folder> Pack folder, write string to file
|
|
19
|
+
${NAME} unpack [dest] Unpack from clipboard to dest (default: .)
|
|
20
|
+
${NAME} unpack -i <file> [dest] Unpack from file to dest
|
|
21
|
+
${NAME} info Show contents from clipboard
|
|
22
|
+
${NAME} info -i <file> Show contents from file
|
|
23
|
+
|
|
24
|
+
Pack options:
|
|
25
|
+
--include-deps Include dependency dirs (node_modules, vendor, etc.)
|
|
26
|
+
|
|
27
|
+
General:
|
|
28
|
+
-h, --help Show this help
|
|
29
|
+
-v, --version Show version`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function humanSize(bytes) {
|
|
33
|
+
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
34
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
35
|
+
return `${bytes} B`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
const command = args[0];
|
|
41
|
+
|
|
42
|
+
if (!command || command === '-h' || command === '--help') {
|
|
43
|
+
usage();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (command === '-v' || command === '--version') {
|
|
48
|
+
console.log(`${NAME} ${pkg.version}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
if (command === 'pack') {
|
|
54
|
+
let output = null;
|
|
55
|
+
let folder = null;
|
|
56
|
+
let includeDeps = false;
|
|
57
|
+
|
|
58
|
+
for (let i = 1; i < args.length; i++) {
|
|
59
|
+
if (args[i] === '-o' || args[i] === '--output') { output = args[++i]; }
|
|
60
|
+
else if (args[i] === '--include-deps') { includeDeps = true; }
|
|
61
|
+
else if (!args[i].startsWith('-')) { folder = args[i]; }
|
|
62
|
+
else { console.error(`Error: Unknown option: ${args[i]}`); process.exit(1); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!folder) { console.error('Error: No folder specified.'); process.exit(1); }
|
|
66
|
+
|
|
67
|
+
const result = await pack(folder, {
|
|
68
|
+
clipboard: !output,
|
|
69
|
+
output: output || undefined,
|
|
70
|
+
includeDeps,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const target = output || 'clipboard';
|
|
74
|
+
console.log(`Packed '${folder}' -> ${target} (${humanSize(result.zipSize)} zip, ${humanSize(result.stringSize)} string)`);
|
|
75
|
+
|
|
76
|
+
} else if (command === 'unpack') {
|
|
77
|
+
let inputFile = null;
|
|
78
|
+
let dest = '.';
|
|
79
|
+
|
|
80
|
+
for (let i = 1; i < args.length; i++) {
|
|
81
|
+
if (args[i] === '-i' || args[i] === '--input') { inputFile = args[++i]; }
|
|
82
|
+
else if (!args[i].startsWith('-')) { dest = args[i]; }
|
|
83
|
+
else { console.error(`Error: Unknown option: ${args[i]}`); process.exit(1); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = await unpack(null, dest, {
|
|
87
|
+
input: inputFile || undefined,
|
|
88
|
+
clipboard: !inputFile,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
console.log('Integrity OK');
|
|
92
|
+
console.log(`Files: ${result.fileCount}`);
|
|
93
|
+
console.log(`Unpacked -> ${result.dest}/`);
|
|
94
|
+
|
|
95
|
+
} else if (command === 'info') {
|
|
96
|
+
let inputFile = null;
|
|
97
|
+
|
|
98
|
+
for (let i = 1; i < args.length; i++) {
|
|
99
|
+
if (args[i] === '-i' || args[i] === '--input') { inputFile = args[++i]; }
|
|
100
|
+
else { console.error(`Error: Unknown option: ${args[i]}`); process.exit(1); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const details = await info(null, {
|
|
104
|
+
input: inputFile || undefined,
|
|
105
|
+
clipboard: !inputFile,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
console.log(`${NAME} v${details.version} — integrity OK`);
|
|
109
|
+
console.log(` Source: ${details.source}/`);
|
|
110
|
+
console.log(` Files: ${details.fileCount}`);
|
|
111
|
+
console.log(` Size: ${humanSize(details.compressedSize)} (compressed)`);
|
|
112
|
+
console.log('');
|
|
113
|
+
for (const entry of details.entries) {
|
|
114
|
+
console.log(` ${entry}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
} else {
|
|
118
|
+
console.error(`Unknown command: ${command}`);
|
|
119
|
+
usage();
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`Error: ${err.message}`);
|
|
124
|
+
process.exit(err.message.includes('Integrity') || err.message.includes('not a valid') || err.message.includes('Unsupported') ? 2 : 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "packup-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pack folders into portable, integrity-verified strings",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"packup-cli": "bin/packup.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"archiver": "^7.0.0",
|
|
24
|
+
"adm-zip": "^0.5.0",
|
|
25
|
+
"clipboardy": "^4.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/clipboard.js
|
|
2
|
+
|
|
3
|
+
let clipboardModule = null;
|
|
4
|
+
|
|
5
|
+
async function getClipboard() {
|
|
6
|
+
if (!clipboardModule) {
|
|
7
|
+
try {
|
|
8
|
+
clipboardModule = await import('clipboardy');
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error('Clipboard not available. Use -i <file> or -o <file> instead.');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return clipboardModule;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeClipboard(text) {
|
|
17
|
+
const { default: clipboard } = await getClipboard();
|
|
18
|
+
await clipboard.write(text);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function readClipboard() {
|
|
22
|
+
const { default: clipboard } = await getClipboard();
|
|
23
|
+
const content = await clipboard.read();
|
|
24
|
+
if (!content) {
|
|
25
|
+
throw new Error('Clipboard is empty');
|
|
26
|
+
}
|
|
27
|
+
return content;
|
|
28
|
+
}
|
package/src/compress.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/compress.js
|
|
2
|
+
import archiver from 'archiver';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { basename, resolve } from 'node:path';
|
|
5
|
+
import { getExcludePatterns } from './exclude.js';
|
|
6
|
+
|
|
7
|
+
export async function createZipBuffer(folderPath, { includeDeps = false } = {}) {
|
|
8
|
+
const absPath = resolve(folderPath);
|
|
9
|
+
|
|
10
|
+
const stats = await stat(absPath).catch(() => null);
|
|
11
|
+
if (!stats || !stats.isDirectory()) {
|
|
12
|
+
throw new Error(`Folder not found: ${absPath}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const excludePatterns = getExcludePatterns({ includeDeps });
|
|
16
|
+
const folderName = basename(absPath);
|
|
17
|
+
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
21
|
+
|
|
22
|
+
archive.on('data', (chunk) => chunks.push(chunk));
|
|
23
|
+
archive.on('end', () => resolve(Buffer.concat(chunks)));
|
|
24
|
+
archive.on('error', reject);
|
|
25
|
+
archive.on('warning', (err) => {
|
|
26
|
+
if (err.code !== 'ENOENT') reject(err);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
archive.glob('**/*', {
|
|
30
|
+
cwd: absPath,
|
|
31
|
+
dot: true,
|
|
32
|
+
ignore: excludePatterns,
|
|
33
|
+
}, { prefix: folderName });
|
|
34
|
+
|
|
35
|
+
archive.finalize();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/decompress.js
|
|
2
|
+
import AdmZip from 'adm-zip';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
const MAX_FILES = 10_000;
|
|
6
|
+
const MAX_UNCOMPRESSED = 500 * 1024 * 1024; // 500 MB
|
|
7
|
+
|
|
8
|
+
export async function extractZipBuffer(zipBuffer, dest) {
|
|
9
|
+
let zip;
|
|
10
|
+
try {
|
|
11
|
+
zip = new AdmZip(zipBuffer);
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error('Invalid zip data — cannot extract');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const allEntries = zip.getEntries();
|
|
17
|
+
if (allEntries.length > MAX_FILES) {
|
|
18
|
+
throw new Error(`Archive contains too many entries (${allEntries.length}, max ${MAX_FILES})`);
|
|
19
|
+
}
|
|
20
|
+
const totalSize = allEntries.reduce((sum, e) => sum + (e.header?.size || 0), 0);
|
|
21
|
+
if (totalSize > MAX_UNCOMPRESSED) {
|
|
22
|
+
throw new Error(`Archive uncompressed size too large (${totalSize} bytes, max ${MAX_UNCOMPRESSED})`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entries = allEntries
|
|
26
|
+
.filter(e => !e.isDirectory)
|
|
27
|
+
.map(e => e.entryName);
|
|
28
|
+
|
|
29
|
+
await mkdir(dest, { recursive: true });
|
|
30
|
+
zip.extractAllTo(dest, true);
|
|
31
|
+
|
|
32
|
+
return { fileCount: entries.length, entries };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listZipEntries(zipBuffer) {
|
|
36
|
+
let zip;
|
|
37
|
+
try {
|
|
38
|
+
zip = new AdmZip(zipBuffer);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error('Invalid zip data — cannot list entries');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return zip.getEntries()
|
|
44
|
+
.filter(e => !e.isDirectory)
|
|
45
|
+
.map(e => e.entryName);
|
|
46
|
+
}
|
package/src/exclude.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// src/exclude.js
|
|
2
|
+
|
|
3
|
+
export const ALWAYS_EXCLUDE = [
|
|
4
|
+
'.git', '.git/**',
|
|
5
|
+
'**/.DS_Store',
|
|
6
|
+
'**/.env', '**/.env.*',
|
|
7
|
+
'__MACOSX', '__MACOSX/**',
|
|
8
|
+
'**/Thumbs.db', '**/desktop.ini',
|
|
9
|
+
'**/$RECYCLE.BIN', '**/$RECYCLE.BIN/**',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const DEPS_EXCLUDE = [
|
|
13
|
+
'node_modules', 'node_modules/**',
|
|
14
|
+
'vendor', 'vendor/**',
|
|
15
|
+
'.venv', '.venv/**', 'venv', 'venv/**',
|
|
16
|
+
'__pycache__', '__pycache__/**',
|
|
17
|
+
'.dart_tool', '.dart_tool/**',
|
|
18
|
+
'build', 'build/**', 'dist', 'dist/**',
|
|
19
|
+
'.next', '.next/**', '.nuxt', '.nuxt/**',
|
|
20
|
+
'target', 'target/**',
|
|
21
|
+
'Pods', 'Pods/**',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function getExcludePatterns({ includeDeps = false } = {}) {
|
|
25
|
+
const patterns = [...ALWAYS_EXCLUDE];
|
|
26
|
+
if (!includeDeps) {
|
|
27
|
+
patterns.push(...DEPS_EXCLUDE);
|
|
28
|
+
}
|
|
29
|
+
return patterns;
|
|
30
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const PREFIX = 'PACKUP';
|
|
4
|
+
const VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export function buildString(base64Data) {
|
|
7
|
+
const hash = createHash('sha256').update(base64Data).digest('hex');
|
|
8
|
+
return `${PREFIX}:${VERSION}:${hash}:${base64Data}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseString(raw) {
|
|
12
|
+
if (!raw.startsWith(`${PREFIX}:`)) {
|
|
13
|
+
throw new Error('Not a valid packup string (missing PACKUP prefix)');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const firstColon = raw.indexOf(':');
|
|
17
|
+
const secondColon = raw.indexOf(':', firstColon + 1);
|
|
18
|
+
const thirdColon = raw.indexOf(':', secondColon + 1);
|
|
19
|
+
|
|
20
|
+
if (secondColon === -1 || thirdColon === -1) {
|
|
21
|
+
throw new Error('Not a valid packup string (malformed)');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const version = parseInt(raw.slice(firstColon + 1, secondColon), 10);
|
|
25
|
+
const hash = raw.slice(secondColon + 1, thirdColon);
|
|
26
|
+
const data = raw.slice(thirdColon + 1);
|
|
27
|
+
|
|
28
|
+
if (version !== VERSION) {
|
|
29
|
+
throw new Error(`Unsupported packup version: ${version}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!hash || !data) {
|
|
33
|
+
throw new Error('Not a valid packup string (missing hash or data)');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const computedHash = createHash('sha256').update(data).digest('hex');
|
|
37
|
+
const hashBuf = Buffer.from(hash);
|
|
38
|
+
const computedBuf = Buffer.from(computedHash);
|
|
39
|
+
if (hashBuf.length !== computedBuf.length || !timingSafeEqual(hashBuf, computedBuf)) {
|
|
40
|
+
throw new Error('Integrity check FAILED (hash mismatch). Data may be corrupted or tampered with.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { version, hash, data };
|
|
44
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/index.js
|
|
2
|
+
import { createZipBuffer } from './compress.js';
|
|
3
|
+
import { extractZipBuffer, listZipEntries } from './decompress.js';
|
|
4
|
+
import { buildString, parseString } from './format.js';
|
|
5
|
+
import { readClipboard, writeClipboard } from './clipboard.js';
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { gzip, gunzip } from 'node:zlib';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const gzipAsync = promisify(gzip);
|
|
11
|
+
const gunzipAsync = promisify(gunzip);
|
|
12
|
+
|
|
13
|
+
export async function pack(folder, { clipboard = false, output, includeDeps = false } = {}) {
|
|
14
|
+
const zipBuffer = await createZipBuffer(folder, { includeDeps });
|
|
15
|
+
const gzipped = await gzipAsync(zipBuffer, { level: 9 });
|
|
16
|
+
const base64Data = gzipped.toString('base64');
|
|
17
|
+
const packupString = buildString(base64Data);
|
|
18
|
+
|
|
19
|
+
const fileCount = listZipEntries(zipBuffer).length;
|
|
20
|
+
const result = {
|
|
21
|
+
string: packupString,
|
|
22
|
+
zipSize: zipBuffer.length,
|
|
23
|
+
stringSize: packupString.length,
|
|
24
|
+
fileCount,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (output) {
|
|
28
|
+
await writeFile(output, packupString);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (clipboard) {
|
|
32
|
+
await writeClipboard(packupString);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function unpack(input, dest = '.', options = {}) {
|
|
39
|
+
let raw = input;
|
|
40
|
+
|
|
41
|
+
if (!raw) {
|
|
42
|
+
if (options.input) {
|
|
43
|
+
raw = await readFile(options.input, 'utf8');
|
|
44
|
+
} else if (options.clipboard !== false) {
|
|
45
|
+
raw = await readClipboard();
|
|
46
|
+
} else {
|
|
47
|
+
throw new Error('No input provided');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parsed = parseString(raw);
|
|
52
|
+
const gzipped = Buffer.from(parsed.data, 'base64');
|
|
53
|
+
const zipBuffer = await gunzipAsync(gzipped, { maxOutputLength: 500 * 1024 * 1024 });
|
|
54
|
+
|
|
55
|
+
const result = await extractZipBuffer(zipBuffer, dest);
|
|
56
|
+
return { fileCount: result.fileCount, dest };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function info(input, options = {}) {
|
|
60
|
+
let raw = input;
|
|
61
|
+
|
|
62
|
+
if (!raw) {
|
|
63
|
+
if (options.input) {
|
|
64
|
+
raw = await readFile(options.input, 'utf8');
|
|
65
|
+
} else if (options.clipboard !== false) {
|
|
66
|
+
raw = await readClipboard();
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error('No input provided');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed = parseString(raw);
|
|
73
|
+
const gzipped = Buffer.from(parsed.data, 'base64');
|
|
74
|
+
const zipBuffer = await gunzipAsync(gzipped, { maxOutputLength: 500 * 1024 * 1024 });
|
|
75
|
+
|
|
76
|
+
const entries = listZipEntries(zipBuffer);
|
|
77
|
+
const source = entries.length > 0 ? entries[0].split('/')[0] : 'unknown';
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
version: parsed.version,
|
|
81
|
+
hash: parsed.hash,
|
|
82
|
+
source,
|
|
83
|
+
fileCount: entries.length,
|
|
84
|
+
compressedSize: zipBuffer.length,
|
|
85
|
+
entries,
|
|
86
|
+
};
|
|
87
|
+
}
|