Skip to content

Commit c1ab10b

Browse files
Adicionou exportação ZIP privada
X-Lovable-Edit-ID: edt-214a6aa6-f78a-419f-88a9-a7bb5ff143c0 Co-authored-by: criptogus <128640021+criptogus@users.noreply.github.com>
2 parents 1e5c53d + b101221 commit c1ab10b

5 files changed

Lines changed: 434 additions & 0 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { useMemo, useState } from "react";
2+
import { useServerFn } from "@tanstack/react-start";
3+
import { useMutation } from "@tanstack/react-query";
4+
import { toast } from "sonner";
5+
import { Button } from "@/components/ui/button";
6+
import { Badge } from "@/components/ui/badge";
7+
import { PROVIDERS, getProvider, scopesFor, type ProviderScope } from "@/lib/cloud-skills/providers";
8+
import { bundlePath } from "@/lib/cloud-skills/bundle";
9+
import { exportSkillBundle } from "@/lib/cloud-skills/bundle.functions";
10+
11+
function download(filename: string, base64: string) {
12+
const bin = atob(base64);
13+
const bytes = new Uint8Array(bin.length);
14+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
15+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
16+
const a = document.createElement("a");
17+
a.href = url;
18+
a.download = filename;
19+
a.click();
20+
URL.revokeObjectURL(url);
21+
}
22+
23+
/**
24+
* "Export as a private package" panel: one zip, already shaped for the chosen
25+
* tool, downloadable and usable offline.
26+
*/
27+
export function ExportBundle({
28+
skills = [],
29+
}: {
30+
skills?: { id: string; slug: string; name: string }[];
31+
}) {
32+
const [toolId, setToolId] = useState(PROVIDERS[0]!.id);
33+
const provider = getProvider(toolId)!;
34+
const scopes = scopesFor(provider);
35+
const [scope, setScope] = useState<ProviderScope>(scopes[0]!);
36+
const activeScope = scopes.includes(scope) ? scope : scopes[0]!;
37+
const [selected, setSelected] = useState<string[]>([]);
38+
39+
const exportFn = useServerFn(exportSkillBundle);
40+
const chosen = selected.length ? skills.filter((s) => selected.includes(s.id)) : skills;
41+
42+
const preview = useMemo(
43+
() =>
44+
chosen
45+
.slice(0, 4)
46+
.map((s) => bundlePath(provider, activeScope, s.slug))
47+
.filter(Boolean) as string[],
48+
[chosen, provider, activeScope],
49+
);
50+
51+
const mut = useMutation({
52+
mutationFn: () =>
53+
exportFn({ data: { tool: provider.id, scope: activeScope, skill_ids: selected } }),
54+
onSuccess: (r) => {
55+
download(r.filename, r.base64);
56+
toast.success(
57+
`${r.skill_count} skill${r.skill_count === 1 ? "" : "s"} exported for ${r.tool.label}`,
58+
);
59+
},
60+
onError: (e: any) => toast.error(e?.message ?? "Export failed"),
61+
});
62+
63+
const toggle = (id: string) =>
64+
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
65+
66+
return (
67+
<section className="mt-6 rounded-2xl border border-border bg-surface p-5">
68+
<div className="flex flex-wrap items-center justify-between gap-2">
69+
<h2 className="text-lg font-semibold">Export as a private package (.zip)</h2>
70+
<Badge variant="secondary">Private to your account</Badge>
71+
</div>
72+
<p className="mt-2 text-sm text-muted-foreground">
73+
One archive with your own skills, already in the folder structure the tool expects.
74+
Unzip it anywhere — no MCP connection, no account needed on the machine that uses it.
75+
</p>
76+
77+
<div className="mt-5">
78+
<div className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
79+
Target tool
80+
</div>
81+
<div className="mt-2 flex flex-wrap gap-2">
82+
{PROVIDERS.map((p) => (
83+
<Button
84+
key={p.id}
85+
size="sm"
86+
variant={p.id === toolId ? "default" : "outline"}
87+
onClick={() => {
88+
setToolId(p.id);
89+
setScope(scopesFor(p)[0]!);
90+
}}
91+
>
92+
{p.label}
93+
</Button>
94+
))}
95+
</div>
96+
</div>
97+
98+
<div className="mt-5">
99+
<div className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
100+
Scope
101+
</div>
102+
<div className="mt-2 flex flex-wrap gap-2">
103+
{scopes.map((s) => (
104+
<Button
105+
key={s}
106+
size="sm"
107+
variant={s === activeScope ? "default" : "outline"}
108+
onClick={() => setScope(s)}
109+
>
110+
{s === "project" ? "This project" : "All projects (global)"}
111+
</Button>
112+
))}
113+
</div>
114+
</div>
115+
116+
{skills.length > 0 && (
117+
<div className="mt-5">
118+
<div className="flex items-center justify-between">
119+
<div className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
120+
Skills
121+
</div>
122+
{selected.length > 0 && (
123+
<Button size="sm" variant="ghost" onClick={() => setSelected([])}>
124+
Select all ({skills.length})
125+
</Button>
126+
)}
127+
</div>
128+
<div className="mt-2 flex flex-wrap gap-2">
129+
{skills.map((s) => {
130+
const on = selected.length === 0 || selected.includes(s.id);
131+
return (
132+
<Button
133+
key={s.id}
134+
size="sm"
135+
variant={on ? "secondary" : "outline"}
136+
onClick={() => toggle(s.id)}
137+
>
138+
{s.slug}
139+
</Button>
140+
);
141+
})}
142+
</div>
143+
<p className="mt-2 text-xs text-muted-foreground">
144+
{selected.length === 0
145+
? "Whole library included. Tap a skill to export only a subset."
146+
: `${selected.length} selected.`}
147+
</p>
148+
</div>
149+
)}
150+
151+
<div className="mt-5 rounded-xl border border-border/60 bg-background/60 p-4">
152+
<div className="font-mono text-xs uppercase tracking-wider text-muted-foreground">
153+
Inside the zip
154+
</div>
155+
<ul className="mt-2 space-y-1 font-mono text-xs">
156+
{preview.map((p) => (
157+
<li key={p} className="break-all">
158+
{p}
159+
</li>
160+
))}
161+
{chosen.length > preview.length && (
162+
<li className="text-muted-foreground">+{chosen.length - preview.length} more</li>
163+
)}
164+
<li className="break-all">README.md</li>
165+
<li className="break-all">install.sh</li>
166+
<li className="break-all">sak-bundle.json</li>
167+
</ul>
168+
<p className="mt-2 text-xs text-muted-foreground">
169+
{activeScope === "global"
170+
? "Global files are staged under home/ — install.sh copies them into $HOME."
171+
: "Project files sit at the repo root — run install.sh inside your repo."}{" "}
172+
Existing files are backed up as <code className="font-mono">.bak</code>, never deleted.
173+
</p>
174+
<div className="mt-3">
175+
<Button
176+
size="sm"
177+
onClick={() => mut.mutate()}
178+
disabled={mut.isPending || chosen.length === 0}
179+
>
180+
{mut.isPending ? "Packaging..." : `Download .zip for ${provider.label}`}
181+
</Button>
182+
</div>
183+
</div>
184+
</section>
185+
);
186+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createServerFn } from "@tanstack/react-start";
2+
import { z } from "zod";
3+
import { requirePaidSubscription } from "./subscription-guard";
4+
import { PROVIDER_IDS } from "./providers";
5+
6+
const ExportBundleInput = z.object({
7+
tool: z.string().refine((v) => PROVIDER_IDS.includes(v), "Unknown tool"),
8+
scope: z.enum(["project", "global"]),
9+
/** Empty = every skill in the private library. */
10+
skill_ids: z.array(z.string().uuid()).max(500).default([]),
11+
});
12+
13+
/**
14+
* Exports the caller's own cloud skills as a private zip, laid out exactly the
15+
* way the chosen agent tool reads skills from disk. Only the caller's rows are
16+
* ever included (no public skills, no other accounts).
17+
*/
18+
export const exportSkillBundle = createServerFn({ method: "POST" })
19+
.middleware([requirePaidSubscription])
20+
.inputValidator((d: unknown) => ExportBundleInput.parse(d ?? {}))
21+
.handler(async ({ data, context }) => {
22+
const { supabase: sb, userId } = context as any;
23+
const supabase = sb as any;
24+
25+
let q = supabase
26+
.from("cloud_skills")
27+
.select("slug, name, description, category, tags, version, content")
28+
.eq("user_id", userId)
29+
.order("slug", { ascending: true })
30+
.limit(500);
31+
if (data.skill_ids.length) q = q.in("id", data.skill_ids);
32+
33+
const { data: rows, error } = await q;
34+
if (error) throw new Response(error.message, { status: 500 });
35+
if (!rows?.length) throw new Response("No skills to export", { status: 400 });
36+
37+
const { buildBundleFiles, bundleFileName } = await import("./bundle");
38+
const { zipBundle, toBase64 } = await import("./bundle.server");
39+
40+
const { provider, files } = buildBundleFiles(data.tool, data.scope, rows);
41+
const bytes = await zipBundle(files);
42+
43+
return {
44+
filename: bundleFileName(provider.id, data.scope, rows.length),
45+
base64: toBase64(bytes),
46+
bytes: bytes.length,
47+
tool: { id: provider.id, label: provider.label },
48+
scope: data.scope,
49+
skill_count: rows.length,
50+
paths: files.map((f) => f.path),
51+
};
52+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import JSZip from "jszip";
2+
import type { BundleFile } from "./bundle";
3+
4+
/**
5+
* Deterministic zip: fixed timestamps + sorted entries, so re-exporting the
6+
* same skills yields byte-identical archives (easy to diff and checksum).
7+
*/
8+
const FIXED_DATE = new Date("2020-01-01T00:00:00.000Z");
9+
10+
export async function zipBundle(files: BundleFile[]): Promise<Uint8Array> {
11+
const zip = new JSZip();
12+
for (const f of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
13+
zip.file(f.path, f.content, {
14+
date: FIXED_DATE,
15+
unixPermissions: f.path.endsWith(".sh") ? 0o755 : 0o644,
16+
});
17+
}
18+
const out = await zip.generateAsync({
19+
type: "uint8array",
20+
compression: "DEFLATE",
21+
compressionOptions: { level: 9 },
22+
});
23+
return out;
24+
}
25+
26+
export function toBase64(bytes: Uint8Array): string {
27+
let binary = "";
28+
const chunk = 0x8000;
29+
for (let i = 0; i < bytes.length; i += chunk) {
30+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
31+
}
32+
return btoa(binary);
33+
}

0 commit comments

Comments
 (0)