Support GitHub shorthand refs for company import
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5a73556871
commit
e6df9fa078
6 changed files with 215 additions and 9 deletions
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("isHttpUrl", () => {
|
||||||
it("matches http URLs", () => {
|
it("matches http URLs", () => {
|
||||||
|
|
@ -29,3 +34,41 @@ describe("isGithubUrl", () => {
|
||||||
expect(isGithubUrl("/tmp/my-company")).toBe(false);
|
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 {
|
interface CompanyImportOptions extends BaseClientOptions {
|
||||||
from?: string;
|
|
||||||
include?: string;
|
include?: string;
|
||||||
target?: CompanyImportTargetMode;
|
target?: CompanyImportTargetMode;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
newCompanyName?: string;
|
newCompanyName?: string;
|
||||||
agents?: string;
|
agents?: string;
|
||||||
collision?: CompanyCollisionMode;
|
collision?: CompanyCollisionMode;
|
||||||
|
ref?: string;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,6 +122,112 @@ export function isGithubUrl(input: string): boolean {
|
||||||
return /^https?:\/\/github\.com\//i.test(input.trim());
|
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(
|
async function collectPackageFiles(
|
||||||
root: string,
|
root: string,
|
||||||
current: string,
|
current: string,
|
||||||
|
|
@ -397,6 +503,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
.option("--new-company-name <name>", "Name override for --target new")
|
.option("--new-company-name <name>", "Name override for --target new")
|
||||||
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
||||||
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
.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)
|
.option("--dry-run", "Run preview only without applying", false)
|
||||||
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -439,15 +546,21 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
|
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
|
||||||
| { type: "github"; url: string };
|
| { type: "github"; url: string };
|
||||||
|
|
||||||
if (isHttpUrl(from)) {
|
const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from);
|
||||||
if (!isGithubUrl(from)) {
|
const isGithubSource = isGithubUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
|
||||||
|
|
||||||
|
if (isHttpUrl(from) || isGithubSource) {
|
||||||
|
if (!isGithubUrl(from) && !isGithubShorthand(from)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Only GitHub URLs and local paths are supported for import. " +
|
"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.",
|
"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 {
|
} else {
|
||||||
|
if (opts.ref?.trim()) {
|
||||||
|
throw new Error("--ref is only supported for GitHub import sources.");
|
||||||
|
}
|
||||||
const inline = await resolveInlineSourceFromPath(from);
|
const inline = await resolveInlineSourceFromPath(from);
|
||||||
sourcePayload = {
|
sourcePayload = {
|
||||||
type: "inline",
|
type: "inline",
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
|
||||||
|
|
||||||
| File | Commands |
|
| 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
|
## 7. UI — Pages
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,10 @@ pnpm paperclipai company export <company-id> --out ./exports/acme --include comp
|
||||||
|
|
||||||
# Preview import (no writes)
|
# Preview import (no writes)
|
||||||
pnpm paperclipai company import \
|
pnpm paperclipai company import \
|
||||||
https://github.com/<owner>/<repo>/tree/main/<path> \
|
<owner>/<repo>/<path> \
|
||||||
--target existing \
|
--target existing \
|
||||||
--company-id <company-id> \
|
--company-id <company-id> \
|
||||||
|
--ref main \
|
||||||
--collision rename \
|
--collision rename \
|
||||||
--dry-run
|
--dry-run
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ vi.mock("../routes/org-chart-svg.js", () => ({
|
||||||
renderOrgChartPng: vi.fn(async () => Buffer.from("png")),
|
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) {
|
function asTextFile(entry: CompanyPortabilityFileEntry | undefined) {
|
||||||
expect(typeof entry).toBe("string");
|
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 () => {
|
it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
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);
|
const url = new URL(rawUrl);
|
||||||
if (url.hostname !== "github.com") {
|
if (url.hostname !== "github.com") {
|
||||||
throw unprocessable("GitHub source must use github.com URL");
|
throw unprocessable("GitHub source must use github.com URL");
|
||||||
|
|
@ -1909,6 +1914,24 @@ function parseGitHubSourceUrl(rawUrl: string) {
|
||||||
}
|
}
|
||||||
const owner = parts[0]!;
|
const owner = parts[0]!;
|
||||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
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 ref = "main";
|
||||||
let basePath = "";
|
let basePath = "";
|
||||||
let companyPath = "COMPANY.md";
|
let companyPath = "COMPANY.md";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue