Skip to content

Commit d6419b9

Browse files
feat: implement createArchive and CLI support + update workflow with yarn and release-it test hook
1 parent 2caa07f commit d6419b9

11 files changed

Lines changed: 489 additions & 17 deletions

File tree

.github/workflows/weekly-release.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@ jobs:
3838
uses: actions/setup-node@v4
3939
with:
4040
node-version: 20
41+
cache: 'yarn'
4142

4243
- name: Install dependencies
43-
run: npm ci
44+
run: yarn install --frozen-lockfile
4445

4546
- name: Configure Git user
4647
run: |

.release-it.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
}
2222
},
2323
"hooks": {
24-
"before:init": "yarn build",
24+
"before:init": "yarn test && yarn build",
2525
"before:commit": "git add .",
2626
"after:release": "git tag -f v${version.major} && git push -f origin v${version.major}"
2727
}

bin/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import fs from 'fs';
2+
import ora from 'ora';
23
import { Command } from 'commander';
4+
import { createArchive } from '../src';
35

46
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
57

@@ -11,4 +13,33 @@ program
1113
.version(pkg.version)
1214
.enablePositionalOptions();
1315

16+
program
17+
.command('createArchive')
18+
.description('Creates a archive from a specified directory')
19+
.requiredOption('-f, --format <format>', 'Archive format (zip or tar)')
20+
.requiredOption('-s, --source <source>', 'Source directory path')
21+
.requiredOption('-d, --destination <destination>', 'Destination archive file')
22+
.action(async (options) => {
23+
const spinner = ora('Creating archive...').start();
24+
try {
25+
await createArchive({
26+
format: options.format,
27+
source: options.source,
28+
destination: options.destination,
29+
log: false,
30+
onSuccess: (totalBytes) => {
31+
spinner.succeed(
32+
`Archive created successfully at ${options.destination} (${totalBytes} bytes).`,
33+
);
34+
},
35+
});
36+
} catch (err) {
37+
spinner.fail(
38+
'Failed to create archive: ' +
39+
(err instanceof Error ? err.message : 'Unknown error'),
40+
);
41+
process.exit(1);
42+
}
43+
});
44+
1445
program.parse();

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,18 @@
6767
"@rollup/plugin-node-resolve": "^16.0.1",
6868
"@rollup/plugin-terser": "^0.4.4",
6969
"@rollup/plugin-typescript": "^12.1.3",
70+
"@types/archiver": "^6.0.3",
7071
"@types/jest": "^30.0.0",
7172
"@types/node": "^24.0.4",
73+
"archiver": "^7.0.1",
7274
"commander": "^14.0.0",
7375
"eslint": "^9.29.0",
7476
"globals": "^16.2.0",
7577
"husky": "^9.1.7",
7678
"jest": "^30.0.3",
7779
"jest-environment-node": "^30.0.2",
7880
"lint-staged": "^16.1.2",
81+
"ora": "^8.2.0",
7982
"prettier": "^3.6.0",
8083
"release-it": "^19.0.3",
8184
"rollup": "^4.44.0",

rollup.config.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@ export default [
4949
peerDepsExternal(),
5050
resolve(),
5151
commonjs(),
52-
typescript({ tsconfig: './tsconfig.json', declaration: false }),
52+
typescript({
53+
tsconfig: './tsconfig.json',
54+
declaration: false,
55+
}),
5356
terser(),
5457
],
55-
external: ['commander'],
58+
external: ['commander', 'ora'],
5659
},
5760
];

src/file/createArchive.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import archiver, { Archiver, ArchiverOptions } from 'archiver';
4+
export type { ArchiverOptions } from 'archiver';
5+
6+
/**
7+
* Supported archive formats.
8+
*/
9+
export type ArchiveFormat = 'zip' | 'tar';
10+
11+
/**
12+
* Configuration options.
13+
*/
14+
export interface CreateArchiveOptions {
15+
/**
16+
* Archive format to use {@link ArchiveFormat}.
17+
*/
18+
format: ArchiveFormat;
19+
20+
/**
21+
* Path to the source directory that should be archived.
22+
*/
23+
source: string;
24+
25+
/**
26+
* Destination file path where the archive will be written
27+
* @example
28+
* - For `zip` format: `dist.zip`
29+
* - For `tar` format: `dist.tar`
30+
*/
31+
destination: string;
32+
33+
/**
34+
* Additional options passed directly to the archiver library. See {@link ArchiverOptions}.
35+
*/
36+
options?: ArchiverOptions;
37+
38+
/**
39+
* Optional flag to enable internal logging. Useful for CLI mode.
40+
*/
41+
log?: boolean;
42+
43+
/**
44+
* Called after archiving is complete — receives total size in bytes.
45+
*/
46+
onSuccess?: (bytes: number) => void;
47+
}
48+
49+
/**
50+
* Creates a {@link ArchiveFormat } archive from a specified directory.
51+
*
52+
* This function uses the `archiver` library to package a directory into an archive file.
53+
* It supports optional compression for `zip` and returns a Promise that resolves
54+
* when the archive is successfully created.
55+
*
56+
* @example
57+
*
58+
* Function usage:
59+
*
60+
* ```ts
61+
* await createArchive({
62+
* format: "zip",
63+
* source: "dist/",
64+
* destination: "dist.zip",
65+
* });
66+
* ```
67+
*
68+
* CLI usage:
69+
* ```sh
70+
* npx js-utils-kit createArchive -f zip -s dist -d dist.zip
71+
* ```
72+
*
73+
* @param options - Archive creation options
74+
* @returns A Promise that resolves when the archive is created
75+
* @throws If an error occurs during the archiving process
76+
*/
77+
export function createArchive({
78+
format,
79+
source,
80+
destination,
81+
options = {},
82+
log = true,
83+
onSuccess,
84+
}: CreateArchiveOptions): Promise<void> {
85+
const resolvedSource = path.resolve(source);
86+
87+
if (
88+
!fs.existsSync(resolvedSource) ||
89+
!fs.statSync(resolvedSource).isDirectory()
90+
) {
91+
throw new Error(
92+
`Source directory "${source}" does not exist or is not a directory.`,
93+
);
94+
}
95+
96+
const output = fs.createWriteStream(destination);
97+
98+
if (format === 'zip') {
99+
options = {
100+
...options,
101+
/**
102+
* Maximum compression level for zip is 9.
103+
*/
104+
zlib: { level: 9 },
105+
};
106+
}
107+
108+
const archive: Archiver = archiver(format, options);
109+
110+
return new Promise((resolve, reject) => {
111+
output.on('close', () => {
112+
const size = archive.pointer();
113+
114+
if (log) {
115+
console.log(`${destination} created: ${size} total bytes`);
116+
}
117+
if (onSuccess) {
118+
onSuccess(size);
119+
}
120+
121+
resolve();
122+
});
123+
124+
archive.on('error', (err: Error) => {
125+
reject(err);
126+
});
127+
128+
archive.pipe(output);
129+
archive.directory(source, false);
130+
archive.finalize();
131+
});
132+
}

src/file/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './createArchive';

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './file/index';

tests/createArchive.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import fs from 'fs';
2+
import archiver from 'archiver';
3+
import { createArchive } from '../src/file/createArchive';
4+
5+
jest.mock('fs');
6+
jest.mock('archiver');
7+
8+
describe('createArchive', () => {
9+
const mockOutput = {
10+
on: jest.fn(),
11+
once: jest.fn(),
12+
emit: jest.fn(),
13+
end: jest.fn(),
14+
};
15+
16+
const mockArchive = {
17+
pipe: jest.fn(),
18+
directory: jest.fn(),
19+
finalize: jest.fn(),
20+
pointer: jest.fn().mockReturnValue(1024),
21+
on: jest.fn(),
22+
};
23+
24+
beforeEach(() => {
25+
(fs.createWriteStream as jest.Mock).mockReturnValue(mockOutput);
26+
(archiver as unknown as jest.Mock).mockReturnValue(mockArchive);
27+
jest.clearAllMocks();
28+
});
29+
30+
it('creates a zip archive and resolves on success', async () => {
31+
const promise = createArchive({
32+
format: 'zip',
33+
source: 'src',
34+
destination: 'out.zip',
35+
});
36+
37+
// Simulate close event
38+
const closeCallback = mockOutput.on.mock.calls.find(
39+
(call) => call[0] === 'close',
40+
)?.[1];
41+
closeCallback?.();
42+
43+
await expect(promise).resolves.toBeUndefined();
44+
45+
expect(fs.createWriteStream).toHaveBeenCalledWith('out.zip');
46+
expect(archiver).toHaveBeenCalledWith(
47+
'zip',
48+
expect.objectContaining({
49+
zlib: { level: 9 },
50+
}),
51+
);
52+
expect(mockArchive.pipe).toHaveBeenCalledWith(mockOutput);
53+
expect(mockArchive.directory).toHaveBeenCalledWith('src', false);
54+
expect(mockArchive.finalize).toHaveBeenCalled();
55+
});
56+
57+
it('rejects if archiver emits an error', async () => {
58+
const promise = createArchive({
59+
format: 'zip',
60+
source: 'src',
61+
destination: 'out.zip',
62+
});
63+
64+
// Simulate error event
65+
const errorCallback = mockArchive.on.mock.calls.find(
66+
(call) => call[0] === 'error',
67+
)?.[1];
68+
const error = new Error('Archiver failed');
69+
errorCallback?.(error);
70+
71+
await expect(promise).rejects.toThrow('Archiver failed');
72+
});
73+
});

typedoc.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
"out": "docs",
44
"includeVersion": true,
55
"excludePrivate": true,
6-
"excludeProtected": true,
6+
"excludeProtected": false,
77
"excludeInternal": true,
8-
"excludeExternals": true,
8+
"excludeExternals": false,
99
"readme": "README.md",
1010
"tsconfig": "tsconfig.json",
1111
"name": "JS Utils Kit",
1212
"categorizeByGroup": true,
1313
"sort": ["source-order"],
14-
"plugin": []
14+
"plugin": [],
15+
"gitRemote": "origin",
16+
"gitRevision": "main",
17+
"githubPages": true
1518
}

0 commit comments

Comments
 (0)