use extract-zip and tar libraries to extract archives (#10414)

Co-authored-by: christine betts <chrstn@uw.edu>
This commit is contained in:
Jacob MacDonald
2025-10-07 08:37:41 -07:00
committed by GitHub
parent c4656fb063
commit 343be47fa9
4 changed files with 646 additions and 46 deletions

View File

@@ -8,12 +8,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkForExtensionUpdate,
cloneFromGit,
extractFile,
findReleaseAsset,
parseGitHubRepoForReleases,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import type * as os from 'node:os';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
const mockPlatform = vi.hoisted(() => vi.fn());
@@ -328,4 +334,83 @@ describe('git extension helpers', () => {
);
});
});
describe('extractFile', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should extract a .tar.gz file', async () => {
const archivePath = path.join(tempDir, 'test.tar.gz');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello tar');
// Create the tar.gz file
await tar.c(
{
gzip: true,
file: archivePath,
cwd: tempDir,
},
['test.txt'],
);
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello tar');
});
it('should extract a .zip file', async () => {
const archivePath = path.join(tempDir, 'test.zip');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello zip');
// Create the zip file
const output = fsSync.createWriteStream(archivePath);
const archive = archiver.create('zip');
const streamFinished = new Promise((resolve, reject) => {
output.on('close', () => resolve(null));
archive.on('error', reject);
});
archive.pipe(output);
archive.file(dummyFilePath, { name: 'test.txt' });
await archive.finalize();
await streamFinished;
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello zip');
});
it('should throw an error for unsupported file types', async () => {
const unsupportedFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(unsupportedFilePath, 'some content');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
await expect(
extractFile(unsupportedFilePath, extractionDest),
).rejects.toThrow('Unsupported file extension for extraction:');
});
});
});

View File

@@ -15,8 +15,9 @@ import * as os from 'node:os';
import * as https from 'node:https';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js';
import * as tar from 'tar';
import extract from 'extract-zip';
function getGitHubToken(): string | undefined {
return process.env['GITHUB_TOKEN'];
@@ -258,7 +259,7 @@ export async function downloadFromGitHubRelease(
await downloadFile(archiveUrl, downloadedAssetPath);
extractFile(downloadedAssetPath, destination);
await extractFile(downloadedAssetPath, destination);
// For regular github releases, the repository is put inside of a top level
// directory. In this case we should see exactly two file in the destination
@@ -404,27 +405,15 @@ async function downloadFile(url: string, dest: string): Promise<void> {
});
}
function extractFile(file: string, dest: string) {
let args: string[];
let command: 'tar' | 'unzip';
export async function extractFile(file: string, dest: string): Promise<void> {
if (file.endsWith('.tar.gz')) {
args = ['-xzf', file, '-C', dest];
command = 'tar';
await tar.x({
file,
cwd: dest,
});
} else if (file.endsWith('.zip')) {
args = [file, '-d', dest];
command = 'unzip';
await extract(file, { dir: dest });
} else {
throw new Error(`Unsupported file extension for extraction: ${file}`);
}
const result = spawnSync(command, args, { stdio: 'pipe' });
if (result.status !== 0) {
if (result.error) {
throw new Error(`Failed to spawn '${command}': ${result.error.message}`);
}
throw new Error(
`'${command}' command failed with exit code ${result.status}: ${result.stderr.toString()}`,
);
}
}