Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@react-icons/all-files": "^4.1.0",
"@tailwindcss/vite": "^4.1.17",
"@zip.js/zip.js": "^2.8.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
Expand Down
15 changes: 15 additions & 0 deletions src/main/event/main-event-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import { PersistenceService } from '../persistence/service/persistence-service';
import './stream-events';
import { EnvironmentMap } from 'shim/objects/environment';
import { ImportService } from 'main/import/service/import-service';
import { ExportService } from 'main/export/service/export-service';
import { updateElectronApp } from 'update-electron-app';
import { Collection } from 'shim/objects/collection';

const persistenceService = PersistenceService.instance;
const environmentService = EnvironmentService.instance;
const importService = ImportService.instance;
const exportService = ExportService.instance;

declare type AsyncFunction<R> = (...args: unknown[]) => Promise<R>;

Expand Down Expand Up @@ -185,4 +188,16 @@ export class MainEventService implements IEventService {
updateInterval: '1 hour',
});
}

async exportCollection(
collection: Collection,
outputPath: string,
includeSecrets: boolean = false,
password?: string
) {
return await exportService.exportCollection(collection, outputPath, {
includeSecrets,
password,
});
}
}
100 changes: 100 additions & 0 deletions src/main/export/service/export-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ExportService } from './export-service';
import { Collection } from 'shim/objects/collection';
import path from 'path';
import fs from 'node:fs/promises';
import { tmpdir } from 'os';
import { ZipReader, BlobReader, TextWriter } from '@zip.js/zip.js';

const exportService = ExportService.instance;

describe('ExportService', () => {
let tempDir: string;
let testCollection: Collection;

beforeEach(async () => {
tempDir = path.join(tmpdir(), 'trufos-export-test-' + Date.now());
await fs.mkdir(tempDir, { recursive: true });

testCollection = {
id: 'test-collection-id',
type: 'collection',
title: 'Test Collection',
dirPath: path.join(tempDir, 'test-collection'),
variables: {},
environments: {},
children: [],
};

await fs.mkdir(testCollection.dirPath, { recursive: true });
await fs.writeFile(
path.join(testCollection.dirPath, 'collection.json'),
JSON.stringify({
title: 'Test Collection',
variables: {
normalVar: { value: 'normal-value', secret: false },
secretVar: { value: 'secret-value', secret: true },
},
environments: {
dev: {
variables: {
envNormal: { value: 'env-normal', secret: false },
envSecret: { value: 'env-secret', secret: true },
},
},
},
})
);
});

it('should export collection as ZIP file', async () => {
const outputPath = await exportService.exportCollection(testCollection, tempDir);

expect(outputPath).toBe(path.join(tempDir, 'Test Collection.trufos.zip'));
const stats = await fs.stat(outputPath);
expect(stats.isFile()).toBe(true);
});

it('should exclude secrets by default', async () => {
await fs.writeFile(path.join(testCollection.dirPath, '.secrets.bin'), 'secret-data');

await exportService.exportCollection(testCollection, tempDir);
});

it('should include secrets when option is set', async () => {
await fs.writeFile(path.join(testCollection.dirPath, '.secrets.bin'), 'secret-data');

await exportService.exportCollection(testCollection, tempDir, {
includeSecrets: true,
});
});

it('should clear secret values but keep keys when not including secrets', async () => {
const outputPath = await exportService.exportCollection(testCollection, tempDir, {
includeSecrets: false,
});

const zipBlob = new Blob([await fs.readFile(outputPath)]);
const zipReader = new ZipReader(new BlobReader(zipBlob));
const entries = await zipReader.getEntries();

const collectionJsonEntry = entries.find((e) => e.filename === 'collection.json');
expect(collectionJsonEntry).toBeDefined();

if (collectionJsonEntry && collectionJsonEntry.getData) {
const textWriter = new TextWriter();
const content = await collectionJsonEntry.getData(textWriter);
const json = JSON.parse(content);

expect(json.variables.normalVar.value).toBe('normal-value');
expect(json.variables.secretVar.value).toBe('');
expect(json.variables.secretVar.secret).toBe(true);

expect(json.environments.dev.variables.envNormal.value).toBe('env-normal');
expect(json.environments.dev.variables.envSecret.value).toBe('');
expect(json.environments.dev.variables.envSecret.secret).toBe(true);
}

await zipReader.close();
});
});
110 changes: 110 additions & 0 deletions src/main/export/service/export-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Collection } from 'shim/objects/collection';
import path from 'path';
import { BlobWriter, ZipWriter, TextReader } from '@zip.js/zip.js';
import fs from 'node:fs/promises';
import { SECRETS_FILE_NAME } from 'main/persistence/constants';
import { VariableMap } from 'shim/objects/variables';

export type ExportOptions = {
includeSecrets: boolean;
password?: string;
};

function clearSecretValues(variables: VariableMap): VariableMap {
const cleared: VariableMap = {};
for (const [key, variable] of Object.entries(variables)) {
if (variable.secret) {
cleared[key] = { ...variable, value: '' };
} else {
cleared[key] = variable;
}
}
return cleared;
}
Comment on lines +13 to +23
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be necessary. It would be the easiest to simply include or not include secret files from disk while zipping. No parsing needed.


export class ExportService {
public static readonly instance = new ExportService();

async exportCollection(
collection: Collection,
outputPath: string,
options: ExportOptions = { includeSecrets: false }
): Promise<string> {
const collectionDirPath = collection.dirPath;
const collectionName = collection.title;
const zipFileName = `${collectionName}.trufos.zip`;
const fullOutputPath = path.join(outputPath, zipFileName);

logger.info(`Exporting collection "${collectionName}" to "${fullOutputPath}"`);

const blobWriter = new BlobWriter('application/zip');
const zipWriter = new ZipWriter(blobWriter, {
password: options.password,
encryptionStrength: 3,
});

await this.addDirectoryToZip(zipWriter, collectionDirPath, '', options.includeSecrets);

await zipWriter.close();
const blob = await blobWriter.getData();
const buffer = await blob.arrayBuffer();

await fs.writeFile(fullOutputPath, Buffer.from(buffer));
logger.info(`Successfully exported collection to "${fullOutputPath}"`);

return fullOutputPath;
}

private async addDirectoryToZip(
zipWriter: ZipWriter<unknown>,
dirPath: string,
basePath: string,
includeSecrets: boolean
): Promise<void> {
const entries = await fs.readdir(dirPath, { withFileTypes: true });

for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
const zipPath = path.join(basePath, entry.name);

if (entry.isDirectory()) {
await this.addDirectoryToZip(zipWriter, entryPath, zipPath, includeSecrets);
} else {
if (entry.name === SECRETS_FILE_NAME) {
if (!includeSecrets) {
logger.debug(`Skipping secrets file: ${zipPath}`);
continue;
}
}

let fileContent = await fs.readFile(entryPath, 'utf-8');

if (!includeSecrets && entry.name.endsWith('.json')) {
try {
const json = JSON.parse(fileContent);

if (json.variables) {
json.variables = clearSecretValues(json.variables);
}

if (json.environments) {
for (const [envKey, envValue] of Object.entries(json.environments)) {
if (envValue && typeof envValue === 'object' && 'variables' in envValue) {
json.environments[envKey].variables = clearSecretValues(
(envValue as { variables: VariableMap }).variables
);
}
}
}

fileContent = JSON.stringify(json, null, 2);
} catch {
logger.debug(`Could not parse JSON file ${zipPath}, adding as-is`);
}
}

await zipWriter.add(zipPath, new TextReader(fileContent));
}
}
}
}
60 changes: 59 additions & 1 deletion src/renderer/components/sidebar/CollectionSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { MdOutlineContentCopy, MdOutlineModeEdit } from 'react-icons/md';
import { TypographyLineClamp } from '@/components/shared/TypographyLineClamp';
import { cn } from '@/lib/utils';
import { RendererEventService } from '@/services/event/renderer-event-service';
import { Download } from 'lucide-react';

const eventService = RendererEventService.instance;

Expand All @@ -28,6 +30,8 @@ export const CollectionSettings = ({ trufosObject, isOpen, onClose }: Collection
const [name, setName] = useState('');
const [pathName, setPathName] = useState('');
const [collections, setCollections] = useState<CollectionBase[]>([]);
const [isExporting, setIsExporting] = useState(false);
const [includeSecrets, setIncludeSecrets] = useState(false);

const loadCollections = useCallback(async () => {
setCollections(await eventService.listCollections());
Expand Down Expand Up @@ -71,6 +75,27 @@ export const CollectionSettings = ({ trufosObject, isOpen, onClose }: Collection
await navigator.clipboard.writeText(trufosObject.dirPath);
};

const handleExportCollection = async () => {
try {
setIsExporting(true);
const result = await eventService.showOpenDialog({
title: 'Select Export Location',
buttonLabel: 'Export',
properties: ['openDirectory'],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why directory and not (ZIP)-file?

});

if (!result.canceled && result.filePaths.length > 0) {
const outputPath = result.filePaths[0];
await eventService.exportCollection(trufosObject, outputPath, includeSecrets);
console.info('Collection exported successfully');
}
} catch (err) {
console.error('Failed to export collection', err);
} finally {
setIsExporting(false);
}
};

const isValid = useMemo(() => {
return name.trim().length > 0 && name !== trufosObject.title;
}, [name, trufosObject.title]);
Expand All @@ -80,7 +105,7 @@ export const CollectionSettings = ({ trufosObject, isOpen, onClose }: Collection
<DialogContent>
<DialogHeader>
<DialogTitle>Collection Settings</DialogTitle>
<DialogDescription className={'text-[var(--text-secondary)]'}>
<DialogDescription className={'text-text-secondary'}>
Manage your collection options below.
</DialogDescription>
</DialogHeader>
Expand Down Expand Up @@ -114,6 +139,39 @@ export const CollectionSettings = ({ trufosObject, isOpen, onClose }: Collection

<Separator />

<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-text-secondary font-medium">Export Collection</span>

<Button
variant={'secondary'}
size="sm"
disabled={isExporting}
className="flex gap-2 rounded-full"
onClick={handleExportCollection}
>
<Download size={16} />
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</div>

<div className="flex items-center gap-2 pl-4">
<Checkbox
id="include-secrets"
checked={includeSecrets}
onCheckedChange={(checked) => setIncludeSecrets(checked === true)}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this also work?

onCheckedChange={setIncludeSecrets}

/>
<label
htmlFor="include-secrets"
className="text-text-secondary cursor-pointer text-sm"
>
Include secret values
</label>
</div>
</div>

<Separator />

<div className="flex items-center justify-between">
<span className="text-destructive font-medium">Close Collection</span>

Expand Down
1 change: 1 addition & 0 deletions src/renderer/services/event/renderer-event-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class RendererEventService implements IEventService {
showOpenDialog = createEventMethod('showOpenDialog');
listCollections = createEventMethod('listCollections');
importCollection = createEventMethod('importCollection');
exportCollection = createEventMethod('exportCollection');
rename = createEventMethod('rename');
updateApp = createEventMethod('updateApp');
}
15 changes: 15 additions & 0 deletions src/shim/event-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,19 @@ export interface IEventService {
* Will show a dialog to the user if an update is available.
*/
updateApp(): void;

/**
* Export the given collection to a ZIP file.
* @param collection The collection to export.
* @param outputPath The directory where the ZIP file should be saved.
* @param includeSecrets Whether to include secrets in the export. Default is false.
* @param password Optional password to encrypt the ZIP file.
* @returns The path to the exported ZIP file.
*/
exportCollection(
collection: Collection,
outputPath: string,
includeSecrets?: boolean,
password?: string
): Promise<string>;
}
Loading