Skip to content

Commit 61310a4

Browse files
committed
fix: support portable zip install-source extraction
1 parent 406b3ed commit 61310a4

2 files changed

Lines changed: 66 additions & 2 deletions

File tree

src/platforms/__tests__/install-source.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test, onTestFinished } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { execFileSync } from 'node:child_process';
44
import http from 'node:http';
5+
import fsSync from 'node:fs';
56
import fs from 'node:fs/promises';
67
import os from 'node:os';
78
import path from 'node:path';
@@ -107,6 +108,44 @@ test('materializeInstallablePath rejects archive extraction when disabled', asyn
107108
}
108109
});
109110

111+
test.sequential('materializeInstallablePath extracts zip archives without ditto', async () => {
112+
const unzipPath = findExecutableInPath('unzip');
113+
assert.ok(unzipPath, 'unzip must be available for portable zip extraction');
114+
115+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-install-source-unzip-'));
116+
const archivePath = path.join(tempRoot, 'bundle.zip');
117+
const binDir = path.join(tempRoot, 'bin');
118+
const payloadDir = path.join(tempRoot, 'payload');
119+
const apkPath = path.join(payloadDir, 'Sample.apk');
120+
const previousPath = process.env.PATH;
121+
122+
try {
123+
await fs.mkdir(binDir);
124+
await fs.symlink(unzipPath, path.join(binDir, 'unzip'));
125+
await fs.mkdir(payloadDir);
126+
await fs.writeFile(apkPath, 'placeholder apk', 'utf8');
127+
execFileSync('zip', ['-qr', archivePath, 'payload'], { cwd: tempRoot });
128+
129+
process.env.PATH = binDir;
130+
const result = await materializeInstallablePath({
131+
source: { kind: 'path', path: archivePath },
132+
isInstallablePath: (candidatePath, stat) => stat.isFile() && candidatePath.endsWith('.apk'),
133+
installableLabel: 'Android installable (.apk or .aab)',
134+
allowArchiveExtraction: true,
135+
});
136+
137+
try {
138+
assert.equal(path.basename(result.installablePath), 'Sample.apk');
139+
assert.equal(await fs.readFile(result.installablePath, 'utf8'), 'placeholder apk');
140+
} finally {
141+
await result.cleanup();
142+
}
143+
} finally {
144+
process.env.PATH = previousPath;
145+
await fs.rm(tempRoot, { recursive: true, force: true });
146+
}
147+
});
148+
110149
test('prepareIosInstallArtifact rejects untrusted URL sources', async () => {
111150
await assert.rejects(
112151
async () =>
@@ -160,3 +199,20 @@ test('prepareAndroidInstallArtifact resolves package identity for direct APK URL
160199
await result.cleanup();
161200
}
162201
});
202+
203+
function findExecutableInPath(command: string): string | undefined {
204+
const pathValue = process.env.PATH;
205+
if (!pathValue) return undefined;
206+
for (const directory of pathValue.split(path.delimiter)) {
207+
if (!directory) continue;
208+
const candidate = path.join(directory, command);
209+
try {
210+
if (!fsSync.statSync(candidate).isFile()) continue;
211+
fsSync.accessSync(candidate, fsSync.constants.X_OK);
212+
return candidate;
213+
} catch {
214+
// Keep scanning PATH.
215+
}
216+
}
217+
return undefined;
218+
}

src/platforms/install-source.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
66
import { AppError } from '../utils/errors.ts';
7-
import { runCmd } from '../utils/exec.ts';
7+
import { runCmd, whichCmd } from '../utils/exec.ts';
88
import { expandUserHomePath } from '../utils/path-resolution.ts';
99
import { resolveTimeoutMs } from '../utils/timeouts.ts';
1010

@@ -428,7 +428,7 @@ async function extractArchive(
428428
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-archive-'));
429429
try {
430430
if (archivePath.toLowerCase().endsWith('.zip')) {
431-
await runCmd('ditto', ['-x', '-k', archivePath, tempDir]);
431+
await extractZipArchive(archivePath, tempDir);
432432
} else if (
433433
archivePath.toLowerCase().endsWith('.tar.gz') ||
434434
archivePath.toLowerCase().endsWith('.tgz')
@@ -449,6 +449,14 @@ async function extractArchive(
449449
}
450450
}
451451

452+
async function extractZipArchive(archivePath: string, outputPath: string): Promise<void> {
453+
if (process.platform === 'darwin' && (await whichCmd('ditto'))) {
454+
await runCmd('ditto', ['-x', '-k', archivePath, outputPath]);
455+
return;
456+
}
457+
await runCmd('unzip', ['-q', archivePath, '-d', outputPath]);
458+
}
459+
452460
function isArchivePath(candidatePath: string): boolean {
453461
const lower = candidatePath.toLowerCase();
454462
return INTERNAL_ARCHIVE_EXTENSIONS.some((extension) => lower.endsWith(extension));

0 commit comments

Comments
 (0)