Restrict company imports to GitHub and zip packages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
0b829ea20b
commit
4a5aba5bac
6 changed files with 163 additions and 94 deletions
|
|
@ -120,10 +120,6 @@ export type CompanyPortabilitySource =
|
||||||
rootPath?: string | null;
|
rootPath?: string | null;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "url";
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "github";
|
type: "github";
|
||||||
url: string;
|
url: string;
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,6 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
||||||
rootPath: z.string().min(1).optional().nullable(),
|
rootPath: z.string().min(1).optional().nullable(),
|
||||||
files: z.record(z.string()),
|
files: z.record(z.string()),
|
||||||
}),
|
}),
|
||||||
z.object({
|
|
||||||
type: z.literal("url"),
|
|
||||||
url: z.string().url(),
|
|
||||||
}),
|
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("github"),
|
type: z.literal("github"),
|
||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
|
|
|
||||||
|
|
@ -1380,33 +1380,6 @@ export function companyPortabilityService(db: Db) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source.type === "url") {
|
|
||||||
const normalizedUrl = source.url.trim();
|
|
||||||
const companyUrl = normalizedUrl.endsWith(".md")
|
|
||||||
? normalizedUrl
|
|
||||||
: new URL("COMPANY.md", normalizedUrl.endsWith("/") ? normalizedUrl : `${normalizedUrl}/`).toString();
|
|
||||||
const companyMarkdown = await fetchText(companyUrl);
|
|
||||||
const files: Record<string, string> = {
|
|
||||||
"COMPANY.md": companyMarkdown,
|
|
||||||
};
|
|
||||||
const paperclipYaml = await fetchOptionalText(
|
|
||||||
new URL(".paperclip.yaml", companyUrl).toString(),
|
|
||||||
).catch(() => null);
|
|
||||||
if (paperclipYaml) {
|
|
||||||
files[".paperclip.yaml"] = paperclipYaml;
|
|
||||||
}
|
|
||||||
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
|
||||||
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
|
||||||
|
|
||||||
for (const includeEntry of includeEntries) {
|
|
||||||
const includePath = normalizePortablePath(includeEntry.path);
|
|
||||||
if (!includePath.endsWith(".md")) continue;
|
|
||||||
const includeUrl = new URL(includeEntry.path, companyUrl).toString();
|
|
||||||
files[includePath] = await fetchText(includeUrl);
|
|
||||||
}
|
|
||||||
return buildManifestFromPackageFiles(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseGitHubSourceUrl(source.url);
|
const parsed = parseGitHubSourceUrl(source.url);
|
||||||
let ref = parsed.ref;
|
let ref = parsed.ref;
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createZipArchive } from "./zip";
|
import { createZipArchive, readZipArchive } from "./zip";
|
||||||
|
|
||||||
function readUint16(bytes: Uint8Array, offset: number) {
|
function readUint16(bytes: Uint8Array, offset: number) {
|
||||||
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
||||||
|
|
@ -50,4 +50,24 @@ describe("createZipArchive", () => {
|
||||||
expect(readUint16(archive, endOffset + 8)).toBe(2);
|
expect(readUint16(archive, endOffset + 8)).toBe(2);
|
||||||
expect(readUint16(archive, endOffset + 10)).toBe(2);
|
expect(readUint16(archive, endOffset + 10)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads a Paperclip zip archive back into rootPath and file contents", () => {
|
||||||
|
const archive = createZipArchive(
|
||||||
|
{
|
||||||
|
"COMPANY.md": "# Company\n",
|
||||||
|
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||||
|
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||||
|
},
|
||||||
|
"paperclip-demo",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readZipArchive(archive)).toEqual({
|
||||||
|
rootPath: "paperclip-demo",
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": "# Company\n",
|
||||||
|
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||||
|
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const textEncoder = new TextEncoder();
|
const textEncoder = new TextEncoder();
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
const crcTable = new Uint32Array(256);
|
const crcTable = new Uint32Array(256);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
|
|
@ -37,6 +38,19 @@ function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||||
target[offset + 3] = (value >>> 24) & 0xff;
|
target[offset + 3] = (value >>> 24) & 0xff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readUint16(source: Uint8Array, offset: number) {
|
||||||
|
return source[offset]! | (source[offset + 1]! << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint32(source: Uint8Array, offset: number) {
|
||||||
|
return (
|
||||||
|
source[offset]! |
|
||||||
|
(source[offset + 1]! << 8) |
|
||||||
|
(source[offset + 2]! << 16) |
|
||||||
|
(source[offset + 3]! << 24)
|
||||||
|
) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
function getDosDateTime(date: Date) {
|
function getDosDateTime(date: Date) {
|
||||||
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
|
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
|
||||||
const month = date.getMonth() + 1;
|
const month = date.getMonth() + 1;
|
||||||
|
|
@ -62,6 +76,84 @@ function concatChunks(chunks: Uint8Array[]) {
|
||||||
return archive;
|
return archive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sharedArchiveRoot(paths: string[]) {
|
||||||
|
if (paths.length === 0) return null;
|
||||||
|
const firstSegments = paths
|
||||||
|
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
|
||||||
|
.filter((parts) => parts.length > 0);
|
||||||
|
if (firstSegments.length === 0) return null;
|
||||||
|
const candidate = firstSegments[0]![0]!;
|
||||||
|
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
|
||||||
|
? candidate
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readZipArchive(source: ArrayBuffer | Uint8Array): {
|
||||||
|
rootPath: string | null;
|
||||||
|
files: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
||||||
|
const entries: Array<{ path: string; body: string }> = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset + 4 <= bytes.length) {
|
||||||
|
const signature = readUint32(bytes, offset);
|
||||||
|
if (signature === 0x02014b50 || signature === 0x06054b50) break;
|
||||||
|
if (signature !== 0x04034b50) {
|
||||||
|
throw new Error("Invalid zip archive: unsupported local file header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + 30 > bytes.length) {
|
||||||
|
throw new Error("Invalid zip archive: truncated local file header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generalPurposeFlag = readUint16(bytes, offset + 6);
|
||||||
|
const compressionMethod = readUint16(bytes, offset + 8);
|
||||||
|
const compressedSize = readUint32(bytes, offset + 18);
|
||||||
|
const fileNameLength = readUint16(bytes, offset + 26);
|
||||||
|
const extraFieldLength = readUint16(bytes, offset + 28);
|
||||||
|
|
||||||
|
if ((generalPurposeFlag & 0x0008) !== 0) {
|
||||||
|
throw new Error("Unsupported zip archive: data descriptors are not supported.");
|
||||||
|
}
|
||||||
|
if (compressionMethod !== 0) {
|
||||||
|
throw new Error("Unsupported zip archive: only uncompressed entries are supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameOffset = offset + 30;
|
||||||
|
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
|
||||||
|
const bodyEnd = bodyOffset + compressedSize;
|
||||||
|
if (bodyEnd > bytes.length) {
|
||||||
|
throw new Error("Invalid zip archive: truncated file contents.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivePath = normalizeArchivePath(
|
||||||
|
textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)),
|
||||||
|
);
|
||||||
|
if (archivePath && !archivePath.endsWith("/")) {
|
||||||
|
entries.push({
|
||||||
|
path: archivePath,
|
||||||
|
body: textDecoder.decode(bytes.slice(bodyOffset, bodyEnd)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = bodyEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
|
||||||
|
const files: Record<string, string> = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
const normalizedPath =
|
||||||
|
rootPath && entry.path.startsWith(`${rootPath}/`)
|
||||||
|
? entry.path.slice(rootPath.length + 1)
|
||||||
|
: entry.path;
|
||||||
|
if (!normalizedPath) continue;
|
||||||
|
files[normalizedPath] = entry.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rootPath, files };
|
||||||
|
}
|
||||||
|
|
||||||
export function createZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
|
export function createZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
|
||||||
const normalizedRoot = normalizeArchivePath(rootPath);
|
const normalizedRoot = normalizeArchivePath(rootPath);
|
||||||
const localChunks: Uint8Array[] = [];
|
const localChunks: Uint8Array[] = [];
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
Check,
|
Check,
|
||||||
Download,
|
Download,
|
||||||
Github,
|
Github,
|
||||||
Link2,
|
|
||||||
Package,
|
Package,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -33,6 +32,7 @@ import {
|
||||||
FRONTMATTER_FIELD_LABELS,
|
FRONTMATTER_FIELD_LABELS,
|
||||||
PackageFileTree,
|
PackageFileTree,
|
||||||
} from "../components/PackageFileTree";
|
} from "../components/PackageFileTree";
|
||||||
|
import { readZipArchive } from "../lib/zip";
|
||||||
|
|
||||||
// ── Import-specific helpers ───────────────────────────────────────────
|
// ── Import-specific helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -253,12 +253,19 @@ function buildConflictList(
|
||||||
return conflicts;
|
return conflicts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract a prefix from the import source URL or local folder name */
|
/** Extract a prefix from the import source URL or uploaded zip package name */
|
||||||
function deriveSourcePrefix(sourceMode: string, importUrl: string, localRootPath: string | null): string | null {
|
function deriveSourcePrefix(
|
||||||
if (sourceMode === "local" && localRootPath) {
|
sourceMode: string,
|
||||||
return localRootPath.split("/").pop() ?? null;
|
importUrl: string,
|
||||||
|
localPackageName: string | null,
|
||||||
|
localRootPath: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (sourceMode === "local") {
|
||||||
|
if (localRootPath) return localRootPath.split("/").pop() ?? null;
|
||||||
|
if (!localPackageName) return null;
|
||||||
|
return localPackageName.replace(/\.zip$/i, "") || null;
|
||||||
}
|
}
|
||||||
if (sourceMode === "github" || sourceMode === "url") {
|
if (sourceMode === "github") {
|
||||||
const url = importUrl.trim();
|
const url = importUrl.trim();
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -407,30 +414,23 @@ function ConflictResolutionList({
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function readLocalPackageSelection(fileList: FileList): Promise<{
|
async function readLocalPackageZip(file: File): Promise<{
|
||||||
|
name: string;
|
||||||
rootPath: string | null;
|
rootPath: string | null;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}> {
|
}> {
|
||||||
const files: Record<string, string> = {};
|
if (!/\.zip$/i.test(file.name)) {
|
||||||
let rootPath: string | null = null;
|
throw new Error("Select a .zip company package.");
|
||||||
for (const file of Array.from(fileList)) {
|
|
||||||
const relativePath =
|
|
||||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace(
|
|
||||||
/\\/g,
|
|
||||||
"/",
|
|
||||||
) || file.name;
|
|
||||||
const isMarkdown = relativePath.endsWith(".md");
|
|
||||||
const isPaperclipYaml =
|
|
||||||
relativePath.endsWith(".paperclip.yaml") || relativePath.endsWith(".paperclip.yml");
|
|
||||||
if (!isMarkdown && !isPaperclipYaml) continue;
|
|
||||||
const topLevel = relativePath.split("/")[0] ?? null;
|
|
||||||
if (!rootPath && topLevel) rootPath = topLevel;
|
|
||||||
files[relativePath] = await file.text();
|
|
||||||
}
|
}
|
||||||
if (Object.keys(files).length === 0) {
|
const archive = readZipArchive(await file.arrayBuffer());
|
||||||
throw new Error("No package files were found in the selected folder.");
|
if (Object.keys(archive.files).length === 0) {
|
||||||
|
throw new Error("No package files were found in the selected zip archive.");
|
||||||
}
|
}
|
||||||
return { rootPath, files };
|
return {
|
||||||
|
name: file.name,
|
||||||
|
rootPath: archive.rootPath,
|
||||||
|
files: archive.files,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -447,9 +447,10 @@ export function CompanyImport() {
|
||||||
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Source state
|
// Source state
|
||||||
const [sourceMode, setSourceMode] = useState<"github" | "url" | "local">("github");
|
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
||||||
const [importUrl, setImportUrl] = useState("");
|
const [importUrl, setImportUrl] = useState("");
|
||||||
const [localPackage, setLocalPackage] = useState<{
|
const [localPackage, setLocalPackage] = useState<{
|
||||||
|
name: string;
|
||||||
rootPath: string | null;
|
rootPath: string | null;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
@ -484,15 +485,9 @@ export function CompanyImport() {
|
||||||
}
|
}
|
||||||
const url = importUrl.trim();
|
const url = importUrl.trim();
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
if (sourceMode === "github") return { type: "github", url };
|
return { type: "github", url };
|
||||||
return { type: "url", url };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePrefix = useMemo(
|
|
||||||
() => deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null),
|
|
||||||
[sourceMode, importUrl, localPackage],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Preview mutation
|
// Preview mutation
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
|
|
@ -513,7 +508,12 @@ export function CompanyImport() {
|
||||||
|
|
||||||
// Build conflicts and set default name overrides with prefix
|
// Build conflicts and set default name overrides with prefix
|
||||||
const conflicts = buildConflictList(result);
|
const conflicts = buildConflictList(result);
|
||||||
const prefix = deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null);
|
const prefix = deriveSourcePrefix(
|
||||||
|
sourceMode,
|
||||||
|
importUrl,
|
||||||
|
localPackage?.name ?? null,
|
||||||
|
localPackage?.rootPath ?? null,
|
||||||
|
);
|
||||||
const defaultOverrides: Record<string, string> = {};
|
const defaultOverrides: Record<string, string> = {};
|
||||||
|
|
||||||
for (const c of conflicts) {
|
for (const c of conflicts) {
|
||||||
|
|
@ -625,7 +625,7 @@ export function CompanyImport() {
|
||||||
const fileList = e.target.files;
|
const fileList = e.target.files;
|
||||||
if (!fileList || fileList.length === 0) return;
|
if (!fileList || fileList.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const pkg = await readLocalPackageSelection(fileList);
|
const pkg = await readLocalPackageZip(fileList[0]!);
|
||||||
setLocalPackage(pkg);
|
setLocalPackage(pkg);
|
||||||
setImportPreview(null);
|
setImportPreview(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -764,16 +764,15 @@ export function CompanyImport() {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold">Import source</h2>
|
<h2 className="text-base font-semibold">Import source</h2>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Choose a GitHub repo, direct URL, or local folder to import from.
|
Choose a GitHub repo or upload a local Paperclip zip package.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 md:grid-cols-3">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ key: "github", icon: Github, label: "GitHub repo" },
|
{ key: "github", icon: Github, label: "GitHub repo" },
|
||||||
{ key: "url", icon: Link2, label: "Direct URL" },
|
{ key: "local", icon: Upload, label: "Local zip" },
|
||||||
{ key: "local", icon: Upload, label: "Local folder" },
|
|
||||||
] as const
|
] as const
|
||||||
).map(({ key, icon: Icon, label }) => (
|
).map(({ key, icon: Icon, label }) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -785,7 +784,10 @@ export function CompanyImport() {
|
||||||
? "border-foreground bg-accent"
|
? "border-foreground bg-accent"
|
||||||
: "border-border hover:bg-accent/50",
|
: "border-border hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
onClick={() => setSourceMode(key)}
|
onClick={() => {
|
||||||
|
setSourceMode(key);
|
||||||
|
setImportPreview(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
|
@ -800,10 +802,8 @@ export function CompanyImport() {
|
||||||
<input
|
<input
|
||||||
ref={packageInputRef}
|
ref={packageInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
accept=".zip,application/zip"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
|
|
||||||
webkitdirectory=""
|
|
||||||
onChange={handleChooseLocalPackage}
|
onChange={handleChooseLocalPackage}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -812,11 +812,11 @@ export function CompanyImport() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => packageInputRef.current?.click()}
|
onClick={() => packageInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
Choose folder
|
Choose zip
|
||||||
</Button>
|
</Button>
|
||||||
{localPackage && (
|
{localPackage && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{localPackage.rootPath ?? "package"} with{" "}
|
{localPackage.name} with{" "}
|
||||||
{Object.keys(localPackage.files).length} file
|
{Object.keys(localPackage.files).length} file
|
||||||
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
|
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -824,28 +824,20 @@ export function CompanyImport() {
|
||||||
</div>
|
</div>
|
||||||
{!localPackage && (
|
{!localPackage && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
Select a folder that contains COMPANY.md and any referenced AGENTS.md files.
|
Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Field
|
<Field
|
||||||
label={sourceMode === "github" ? "GitHub URL" : "Package URL"}
|
label="GitHub URL"
|
||||||
hint={
|
hint="Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
|
||||||
sourceMode === "github"
|
|
||||||
? "Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
|
|
||||||
: "Point directly at COMPANY.md or a directory that contains it."
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
type="text"
|
type="text"
|
||||||
value={importUrl}
|
value={importUrl}
|
||||||
placeholder={
|
placeholder="https://github.com/owner/repo/tree/main/company"
|
||||||
sourceMode === "github"
|
|
||||||
? "https://github.com/owner/repo/tree/main/company"
|
|
||||||
: "https://example.com/company/COMPANY.md"
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setImportUrl(e.target.value);
|
setImportUrl(e.target.value);
|
||||||
setImportPreview(null);
|
setImportPreview(null);
|
||||||
|
|
@ -934,7 +926,7 @@ export function CompanyImport() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Import button — below renames */}
|
{/* Import button — below renames */}
|
||||||
<div className="mx-5 mt-3">
|
<div className="mx-5 mt-3 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => importMutation.mutate()}
|
onClick={() => importMutation.mutate()}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue