Skip to content

Commit 7e294e8

Browse files
committed
feat: add Brewfile import functionality
- Add /api/brewfile/parse endpoint to parse Brewfile content - Add Import Brewfile button and modal to dashboard - Parsed packages auto-populate new config form
1 parent 035b0f7 commit 7e294e8

File tree

2 files changed

+166
-1
lines changed

2 files changed

+166
-1
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { json } from '@sveltejs/kit';
2+
import type { RequestHandler } from './$types';
3+
4+
interface ParseResult {
5+
packages: string[];
6+
taps: string[];
7+
casks: string[];
8+
formulas: string[];
9+
}
10+
11+
export const POST: RequestHandler = async ({ request }) => {
12+
const { content } = await request.json();
13+
14+
if (!content || typeof content !== 'string') {
15+
return json({ error: 'Brewfile content required' }, { status: 400 });
16+
}
17+
18+
const result = parseBrewfile(content);
19+
return json(result);
20+
};
21+
22+
function parseBrewfile(content: string): ParseResult {
23+
const lines = content.split('\n');
24+
const taps: string[] = [];
25+
const formulas: string[] = [];
26+
const casks: string[] = [];
27+
28+
for (const line of lines) {
29+
const trimmed = line.trim();
30+
31+
if (!trimmed || trimmed.startsWith('#')) {
32+
continue;
33+
}
34+
35+
const tapMatch = trimmed.match(/^tap\s+["']([^"']+)["']/);
36+
if (tapMatch) {
37+
taps.push(tapMatch[1]);
38+
continue;
39+
}
40+
41+
const brewMatch = trimmed.match(/^brew\s+["']([^"']+)["']/);
42+
if (brewMatch) {
43+
formulas.push(brewMatch[1]);
44+
continue;
45+
}
46+
47+
const caskMatch = trimmed.match(/^cask\s+["']([^"']+)["']/);
48+
if (caskMatch) {
49+
casks.push(caskMatch[1]);
50+
continue;
51+
}
52+
}
53+
54+
const packages = [...formulas, ...casks];
55+
56+
return {
57+
packages,
58+
taps,
59+
formulas,
60+
casks
61+
};
62+
}

src/routes/dashboard/+page.svelte

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@
5454
let searchLoading = $state(false);
5555
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
5656
57+
let showImportModal = $state(false);
58+
let brewfileContent = $state('');
59+
let importLoading = $state(false);
60+
let importError = $state('');
61+
5762
async function searchHomebrew(query: string) {
5863
if (query.length < 2) {
5964
searchResults = [];
@@ -281,6 +286,56 @@
281286
}
282287
return `openboot.dev/${$auth.user?.username}/${config.slug}/install`;
283288
}
289+
290+
async function importBrewfile() {
291+
if (!brewfileContent.trim()) {
292+
importError = 'Please paste your Brewfile content';
293+
return;
294+
}
295+
296+
importLoading = true;
297+
importError = '';
298+
299+
try {
300+
const response = await fetch('/api/brewfile/parse', {
301+
method: 'POST',
302+
headers: { 'Content-Type': 'application/json' },
303+
body: JSON.stringify({ content: brewfileContent })
304+
});
305+
306+
const data = await response.json();
307+
308+
if (!response.ok) {
309+
importError = data.error || 'Failed to parse Brewfile';
310+
return;
311+
}
312+
313+
if (data.packages.length === 0) {
314+
importError = 'No packages found in Brewfile';
315+
return;
316+
}
317+
318+
showImportModal = false;
319+
brewfileContent = '';
320+
321+
formData = {
322+
name: 'Imported Config',
323+
description: `Imported from Brewfile (${data.packages.length} packages)`,
324+
base_preset: 'minimal',
325+
is_public: true,
326+
alias: '',
327+
packages: data.packages,
328+
custom_script: '',
329+
dotfiles_repo: ''
330+
};
331+
selectedPackages = new Set(data.packages);
332+
showModal = true;
333+
} catch (e) {
334+
importError = 'Failed to parse Brewfile';
335+
} finally {
336+
importLoading = false;
337+
}
338+
}
284339
</script>
285340

286341
<svelte:head>
@@ -305,7 +360,10 @@
305360
<h1 class="page-title">My Configurations</h1>
306361
<p class="page-subtitle">Create custom install configs for different teams or projects</p>
307362
</div>
308-
<Button variant="primary" onclick={() => openModal()}>+ New Config</Button>
363+
<div class="header-actions">
364+
<Button variant="secondary" onclick={() => showImportModal = true}>Import Brewfile</Button>
365+
<Button variant="primary" onclick={() => openModal()}>+ New Config</Button>
366+
</div>
309367
</div>
310368

311369
{#if configs.length === 0}
@@ -499,6 +557,36 @@
499557
</div>
500558
{/if}
501559

560+
{#if showImportModal}
561+
<div class="modal-overlay" onclick={() => showImportModal = false}>
562+
<div class="modal import-modal" onclick={(e) => e.stopPropagation()}>
563+
<div class="modal-header">
564+
<h3 class="modal-title">Import Brewfile</h3>
565+
<button class="close-btn" onclick={() => showImportModal = false}>&times;</button>
566+
</div>
567+
<div class="modal-body">
568+
{#if importError}
569+
<div class="error-message">{importError}</div>
570+
{/if}
571+
572+
<div class="form-group">
573+
<label class="form-label">Paste your Brewfile content</label>
574+
<textarea
575+
class="form-textarea brewfile-input"
576+
bind:value={brewfileContent}
577+
placeholder={'tap "homebrew/cask"\nbrew "git"\nbrew "node"\ncask "visual-studio-code"\ncask "docker"'}
578+
></textarea>
579+
<p class="form-hint">Supports tap, brew, and cask entries</p>
580+
</div>
581+
</div>
582+
<div class="modal-footer">
583+
<Button variant="secondary" onclick={() => showImportModal = false}>Cancel</Button>
584+
<Button variant="primary" onclick={importBrewfile}>{importLoading ? 'Parsing...' : 'Import'}</Button>
585+
</div>
586+
</div>
587+
</div>
588+
{/if}
589+
502590
<style>
503591
.header {
504592
background: var(--bg-secondary);
@@ -558,6 +646,21 @@
558646
margin-top: 4px;
559647
}
560648
649+
.header-actions {
650+
display: flex;
651+
gap: 8px;
652+
}
653+
654+
.import-modal {
655+
max-width: 500px;
656+
}
657+
658+
.brewfile-input {
659+
min-height: 200px;
660+
font-family: 'JetBrains Mono', monospace;
661+
font-size: 0.85rem;
662+
}
663+
561664
.empty-state {
562665
text-align: center;
563666
padding: 60px 20px;

0 commit comments

Comments
 (0)