mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02: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",
|
||||
|
||||
@@ -60,7 +60,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
|
||||
|
||||
| File | Commands |
|
||||
|---|---|
|
||||
| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).<br>`company import <fromPathOrUrl>` — imports a company package from a file or folder (flags: positional source path/URL, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).<br>Reads/writes portable file entries and handles `.paperclip.yaml` filtering. |
|
||||
| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).<br>`company import <fromPathOrUrl>` — imports a company package from a file or folder (flags: positional source path/URL or GitHub shorthand, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--ref`, `--dryRun`).<br>Reads/writes portable file entries and handles `.paperclip.yaml` filtering. |
|
||||
|
||||
## 7. UI — Pages
|
||||
|
||||
|
||||
@@ -41,9 +41,10 @@ pnpm paperclipai company export <company-id> --out ./exports/acme --include comp
|
||||
|
||||
# Preview import (no writes)
|
||||
pnpm paperclipai company import \
|
||||
https://github.com/<owner>/<repo>/tree/main/<path> \
|
||||
<owner>/<repo>/<path> \
|
||||
--target existing \
|
||||
--company-id <company-id> \
|
||||
--ref main \
|
||||
--collision rename \
|
||||
--dry-run
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ vi.mock("../routes/org-chart-svg.js", () => ({
|
||||
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
||||
}));
|
||||
|
||||
const { companyPortabilityService } = await import("../services/company-portability.js");
|
||||
const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js");
|
||||
|
||||
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||
expect(typeof entry).toBe("string");
|
||||
@@ -301,6 +301,32 @@ describe("company portability", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("parses canonical GitHub import URLs with explicit ref and package path", () => {
|
||||
expect(
|
||||
parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"),
|
||||
).toEqual({
|
||||
owner: "paperclipai",
|
||||
repo: "companies",
|
||||
ref: "feature/demo",
|
||||
basePath: "gstack",
|
||||
companyPath: "gstack/COMPANY.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses canonical GitHub import URLs with explicit companyPath", () => {
|
||||
expect(
|
||||
parseGitHubSourceUrl(
|
||||
"https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md",
|
||||
),
|
||||
).toEqual({
|
||||
owner: "paperclipai",
|
||||
repo: "companies",
|
||||
ref: "abc123",
|
||||
basePath: "gstack",
|
||||
companyPath: "gstack/COMPANY.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
||||
@@ -1898,7 +1898,12 @@ function buildManifestFromPackageFiles(
|
||||
}
|
||||
|
||||
|
||||
function parseGitHubSourceUrl(rawUrl: string) {
|
||||
function normalizeGitHubSourcePath(value: string | null | undefined) {
|
||||
if (!value) return "";
|
||||
return value.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
export function parseGitHubSourceUrl(rawUrl: string) {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.hostname !== "github.com") {
|
||||
throw unprocessable("GitHub source must use github.com URL");
|
||||
@@ -1909,6 +1914,24 @@ function parseGitHubSourceUrl(rawUrl: string) {
|
||||
}
|
||||
const owner = parts[0]!;
|
||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||
const queryRef = url.searchParams.get("ref")?.trim();
|
||||
const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path"));
|
||||
const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath"));
|
||||
if (queryRef || queryPath || queryCompanyPath) {
|
||||
const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md";
|
||||
let basePath = queryPath;
|
||||
if (!basePath && companyPath !== "COMPANY.md") {
|
||||
basePath = path.posix.dirname(companyPath);
|
||||
if (basePath === ".") basePath = "";
|
||||
}
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
ref: queryRef || "main",
|
||||
basePath,
|
||||
companyPath,
|
||||
};
|
||||
}
|
||||
let ref = "main";
|
||||
let basePath = "";
|
||||
let companyPath = "COMPANY.md";
|
||||
|
||||
Reference in New Issue
Block a user