mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
Support GitHub shorthand refs for company import
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isHttpUrl, isGithubUrl } from "../commands/client/company.js";
|
||||
import {
|
||||
isGithubShorthand,
|
||||
isGithubUrl,
|
||||
isHttpUrl,
|
||||
normalizeGithubImportSource,
|
||||
} from "../commands/client/company.js";
|
||||
|
||||
describe("isHttpUrl", () => {
|
||||
it("matches http URLs", () => {
|
||||
@@ -29,3 +34,41 @@ describe("isGithubUrl", () => {
|
||||
expect(isGithubUrl("/tmp/my-company")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGithubShorthand", () => {
|
||||
it("matches owner/repo/path shorthands", () => {
|
||||
expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true);
|
||||
expect(isGithubShorthand("paperclipai/companies")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects local-looking paths", () => {
|
||||
expect(isGithubShorthand("./exports/acme")).toBe(false);
|
||||
expect(isGithubShorthand("/tmp/acme")).toBe(false);
|
||||
expect(isGithubShorthand("C:\\temp\\acme")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeGithubImportSource", () => {
|
||||
it("normalizes shorthand imports to canonical GitHub sources", () => {
|
||||
expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe(
|
||||
"https://github.com/paperclipai/companies?ref=main&path=gstack",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies --ref to shorthand imports", () => {
|
||||
expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe(
|
||||
"https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies --ref to existing GitHub tree URLs without losing the package path", () => {
|
||||
expect(
|
||||
normalizeGithubImportSource(
|
||||
"https://github.com/paperclipai/companies/tree/main/gstack",
|
||||
"release/2026-03-23",
|
||||
),
|
||||
).toBe(
|
||||
"https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,13 +42,13 @@ interface CompanyExportOptions extends BaseClientOptions {
|
||||
}
|
||||
|
||||
interface CompanyImportOptions extends BaseClientOptions {
|
||||
from?: string;
|
||||
include?: string;
|
||||
target?: CompanyImportTargetMode;
|
||||
companyId?: string;
|
||||
newCompanyName?: string;
|
||||
agents?: string;
|
||||
collision?: CompanyCollisionMode;
|
||||
ref?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,112 @@ export function isGithubUrl(input: string): boolean {
|
||||
return /^https?:\/\/github\.com\//i.test(input.trim());
|
||||
}
|
||||
|
||||
function isGithubSegment(input: string): boolean {
|
||||
return /^[A-Za-z0-9._-]+$/.test(input);
|
||||
}
|
||||
|
||||
export function isGithubShorthand(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isHttpUrl(trimmed)) return false;
|
||||
if (
|
||||
trimmed.startsWith(".") ||
|
||||
trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("~") ||
|
||||
trimmed.includes("\\") ||
|
||||
/^[A-Za-z]:/.test(trimmed)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const segments = trimmed.split("/").filter(Boolean);
|
||||
return segments.length >= 2 && segments.every(isGithubSegment);
|
||||
}
|
||||
|
||||
function normalizeGithubImportPath(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function buildGithubImportUrl(input: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref?: string | null;
|
||||
path?: string | null;
|
||||
companyPath?: string | null;
|
||||
}): string {
|
||||
const url = new URL(`https://github.com/${input.owner}/${input.repo.replace(/\.git$/i, "")}`);
|
||||
const ref = input.ref?.trim();
|
||||
if (ref) {
|
||||
url.searchParams.set("ref", ref);
|
||||
}
|
||||
const companyPath = normalizeGithubImportPath(input.companyPath);
|
||||
if (companyPath) {
|
||||
url.searchParams.set("companyPath", companyPath);
|
||||
return url.toString();
|
||||
}
|
||||
const sourcePath = normalizeGithubImportPath(input.path);
|
||||
if (sourcePath) {
|
||||
url.searchParams.set("path", sourcePath);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function normalizeGithubImportSource(input: string, refOverride?: string): string {
|
||||
const trimmed = input.trim();
|
||||
const ref = refOverride?.trim();
|
||||
|
||||
if (isGithubShorthand(trimmed)) {
|
||||
const [owner, repo, ...repoPath] = trimmed.split("/").filter(Boolean);
|
||||
return buildGithubImportUrl({
|
||||
owner: owner!,
|
||||
repo: repo!,
|
||||
ref: ref || "main",
|
||||
path: repoPath.join("/"),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isGithubUrl(trimmed)) {
|
||||
throw new Error("GitHub source must be a github.com URL or owner/repo[/path] shorthand.");
|
||||
}
|
||||
if (!ref) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const url = new URL(trimmed);
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw new Error("Invalid GitHub URL.");
|
||||
}
|
||||
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!;
|
||||
const existingPath = normalizeGithubImportPath(url.searchParams.get("path"));
|
||||
const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath"));
|
||||
if (existingCompanyPath) {
|
||||
return buildGithubImportUrl({ owner, repo, ref, companyPath: existingCompanyPath });
|
||||
}
|
||||
if (existingPath) {
|
||||
return buildGithubImportUrl({ owner, repo, ref, path: existingPath });
|
||||
}
|
||||
if (parts[2] === "tree") {
|
||||
return buildGithubImportUrl({ owner, repo, ref, path: parts.slice(4).join("/") });
|
||||
}
|
||||
if (parts[2] === "blob") {
|
||||
return buildGithubImportUrl({ owner, repo, ref, companyPath: parts.slice(4).join("/") });
|
||||
}
|
||||
return buildGithubImportUrl({ owner, repo, ref });
|
||||
}
|
||||
|
||||
async function pathExists(inputPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path.resolve(inputPath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPackageFiles(
|
||||
root: string,
|
||||
current: string,
|
||||
@@ -397,6 +503,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||
.option("--new-company-name <name>", "Name override for --target new")
|
||||
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
||||
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
||||
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
|
||||
.option("--dry-run", "Run preview only without applying", false)
|
||||
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
||||
try {
|
||||
@@ -439,15 +546,21 @@ export function registerCompanyCommands(program: Command): void {
|
||||
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
|
||||
| { type: "github"; url: string };
|
||||
|
||||
if (isHttpUrl(from)) {
|
||||
if (!isGithubUrl(from)) {
|
||||
const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from);
|
||||
const isGithubSource = isGithubUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
|
||||
|
||||
if (isHttpUrl(from) || isGithubSource) {
|
||||
if (!isGithubUrl(from) && !isGithubShorthand(from)) {
|
||||
throw new Error(
|
||||
"Only GitHub URLs and local paths are supported for import. " +
|
||||
"Generic HTTP URLs are not supported. Use a GitHub URL (https://github.com/...) or a local directory path.",
|
||||
);
|
||||
}
|
||||
sourcePayload = { type: "github", url: from };
|
||||
sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) };
|
||||
} else {
|
||||
if (opts.ref?.trim()) {
|
||||
throw new Error("--ref is only supported for GitHub import sources.");
|
||||
}
|
||||
const inline = await resolveInlineSourceFromPath(from);
|
||||
sourcePayload = {
|
||||
type: "inline",
|
||||
|
||||
Reference in New Issue
Block a user