Support GitHub shorthand refs for company import

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-23 06:47:32 -05:00
parent 5a73556871
commit e6df9fa078
6 changed files with 215 additions and 9 deletions

View File

@@ -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",
);
});
});

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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";