Merge pull request #1655 from paperclipai/pr/pap-795-company-portability
feat(portability): improve company import and export flow
This commit is contained in:
commit
eeb7e1a91a
36 changed files with 5238 additions and 271 deletions
|
|
@ -1,11 +1,12 @@
|
||||||
import { execFile, spawn } from "node:child_process";
|
import { execFile, spawn } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createStoredZipArchive } from "./helpers/zip.js";
|
||||||
|
|
||||||
type EmbeddedPostgresInstance = {
|
type EmbeddedPostgresInstance = {
|
||||||
initialise(): Promise<void>;
|
initialise(): Promise<void>;
|
||||||
|
|
@ -182,6 +183,19 @@ function createCliEnv() {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
|
||||||
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const absolutePath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
collectTextFiles(root, absolutePath, files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
||||||
|
files[relativePath] = readFileSync(absolutePath, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function stopServerProcess(child: ServerProcess | null) {
|
async function stopServerProcess(child: ServerProcess | null) {
|
||||||
if (!child || child.exitCode !== null) return;
|
if (!child || child.exitCode !== null) return;
|
||||||
child.kill("SIGTERM");
|
child.kill("SIGTERM");
|
||||||
|
|
@ -345,6 +359,8 @@ describe("paperclipai company import/export e2e", () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
|
||||||
|
|
||||||
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
|
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
|
||||||
apiBase,
|
apiBase,
|
||||||
`/api/companies/${sourceCompany.id}/issues`,
|
`/api/companies/${sourceCompany.id}/issues`,
|
||||||
|
|
@ -353,7 +369,7 @@ describe("paperclipai company import/export e2e", () => {
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: "Validate company import/export",
|
title: "Validate company import/export",
|
||||||
description: "Round-trip the company package through the CLI.",
|
description: largeIssueDescription,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
projectId: sourceProject.id,
|
projectId: sourceProject.id,
|
||||||
assigneeAgentId: sourceAgent.id,
|
assigneeAgentId: sourceAgent.id,
|
||||||
|
|
@ -397,6 +413,7 @@ describe("paperclipai company import/export e2e", () => {
|
||||||
`Imported ${sourceCompany.name}`,
|
`Imported ${sourceCompany.name}`,
|
||||||
"--include",
|
"--include",
|
||||||
"company,agents,projects,issues",
|
"company,agents,projects,issues",
|
||||||
|
"--yes",
|
||||||
],
|
],
|
||||||
{ apiBase, configPath },
|
{ apiBase, configPath },
|
||||||
);
|
);
|
||||||
|
|
@ -470,6 +487,7 @@ describe("paperclipai company import/export e2e", () => {
|
||||||
"company,agents,projects,issues",
|
"company,agents,projects,issues",
|
||||||
"--collision",
|
"--collision",
|
||||||
"rename",
|
"rename",
|
||||||
|
"--yes",
|
||||||
],
|
],
|
||||||
{ apiBase, configPath },
|
{ apiBase, configPath },
|
||||||
);
|
);
|
||||||
|
|
@ -494,5 +512,32 @@ describe("paperclipai company import/export e2e", () => {
|
||||||
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
|
||||||
expect(twiceImportedProjects).toHaveLength(2);
|
expect(twiceImportedProjects).toHaveLength(2);
|
||||||
expect(twiceImportedIssues).toHaveLength(2);
|
expect(twiceImportedIssues).toHaveLength(2);
|
||||||
|
|
||||||
|
const zipPath = path.join(tempRoot, "exported-company.zip");
|
||||||
|
const portableFiles: Record<string, string> = {};
|
||||||
|
collectTextFiles(exportDir, exportDir, portableFiles);
|
||||||
|
writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
|
||||||
|
|
||||||
|
const importedFromZip = await runCliJson<{
|
||||||
|
company: { id: string; name: string; action: string };
|
||||||
|
agents: Array<{ id: string | null; action: string; name: string }>;
|
||||||
|
}>(
|
||||||
|
[
|
||||||
|
"company",
|
||||||
|
"import",
|
||||||
|
zipPath,
|
||||||
|
"--target",
|
||||||
|
"new",
|
||||||
|
"--new-company-name",
|
||||||
|
`Zip Imported ${sourceCompany.name}`,
|
||||||
|
"--include",
|
||||||
|
"company,agents,projects,issues",
|
||||||
|
"--yes",
|
||||||
|
],
|
||||||
|
{ apiBase, configPath },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(importedFromZip.company.action).toBe("created");
|
||||||
|
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
44
cli/src/__tests__/company-import-zip.test.ts
Normal file
44
cli/src/__tests__/company-import-zip.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
|
||||||
|
import { createStoredZipArchive } from "./helpers/zip.js";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveInlineSourceFromPath", () => {
|
||||||
|
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
|
||||||
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
|
||||||
|
const archivePath = path.join(tempDir, "paperclip-demo.zip");
|
||||||
|
const archive = createStoredZipArchive(
|
||||||
|
{
|
||||||
|
"COMPANY.md": "# Company\n",
|
||||||
|
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||||
|
"agents/ceo/AGENT.md": "# CEO\n",
|
||||||
|
"notes/todo.txt": "ignore me\n",
|
||||||
|
},
|
||||||
|
"paperclip-demo",
|
||||||
|
);
|
||||||
|
await writeFile(archivePath, archive);
|
||||||
|
|
||||||
|
const resolved = await resolveInlineSourceFromPath(archivePath);
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
rootPath: "paperclip-demo",
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": "# Company\n",
|
||||||
|
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||||
|
"agents/ceo/AGENT.md": "# CEO\n",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveCompanyImportApiPath } from "../commands/client/company.js";
|
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
buildCompanyDashboardUrl,
|
||||||
|
buildDefaultImportAdapterOverrides,
|
||||||
|
buildDefaultImportSelectionState,
|
||||||
|
buildImportSelectionCatalog,
|
||||||
|
buildSelectedFilesFromImportSelection,
|
||||||
|
renderCompanyImportPreview,
|
||||||
|
renderCompanyImportResult,
|
||||||
|
resolveCompanyImportApplyConfirmationMode,
|
||||||
|
resolveCompanyImportApiPath,
|
||||||
|
} from "../commands/client/company.js";
|
||||||
|
|
||||||
describe("resolveCompanyImportApiPath", () => {
|
describe("resolveCompanyImportApiPath", () => {
|
||||||
it("uses company-scoped preview route for existing-company dry runs", () => {
|
it("uses company-scoped preview route for existing-company dry runs", () => {
|
||||||
|
|
@ -48,3 +59,529 @@ describe("resolveCompanyImportApiPath", () => {
|
||||||
).toThrow(/require a companyId/i);
|
).toThrow(/require a companyId/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveCompanyImportApplyConfirmationMode", () => {
|
||||||
|
it("skips confirmation when --yes is set", () => {
|
||||||
|
expect(
|
||||||
|
resolveCompanyImportApplyConfirmationMode({
|
||||||
|
yes: true,
|
||||||
|
interactive: false,
|
||||||
|
json: false,
|
||||||
|
}),
|
||||||
|
).toBe("skip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts in interactive text mode when --yes is not set", () => {
|
||||||
|
expect(
|
||||||
|
resolveCompanyImportApplyConfirmationMode({
|
||||||
|
yes: false,
|
||||||
|
interactive: true,
|
||||||
|
json: false,
|
||||||
|
}),
|
||||||
|
).toBe("prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires --yes for non-interactive apply", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveCompanyImportApplyConfirmationMode({
|
||||||
|
yes: false,
|
||||||
|
interactive: false,
|
||||||
|
json: false,
|
||||||
|
})
|
||||||
|
).toThrow(/non-interactive terminal requires --yes/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires --yes for json apply", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveCompanyImportApplyConfirmationMode({
|
||||||
|
yes: false,
|
||||||
|
interactive: false,
|
||||||
|
json: true,
|
||||||
|
})
|
||||||
|
).toThrow(/with --json requires --yes/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCompanyDashboardUrl", () => {
|
||||||
|
it("preserves the configured base path when building a dashboard URL", () => {
|
||||||
|
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
|
||||||
|
"https://paperclip.example/app/PAP/dashboard",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderCompanyImportPreview", () => {
|
||||||
|
it("summarizes the preview with counts, selection info, and truncated examples", () => {
|
||||||
|
const preview: CompanyPortabilityPreviewResult = {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
},
|
||||||
|
targetCompanyId: "company-123",
|
||||||
|
targetCompanyName: "Imported Co",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
|
||||||
|
plan: {
|
||||||
|
companyAction: "update",
|
||||||
|
agentPlans: [
|
||||||
|
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
|
||||||
|
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
|
||||||
|
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
|
||||||
|
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
|
||||||
|
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
|
||||||
|
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
|
||||||
|
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
|
||||||
|
],
|
||||||
|
projectPlans: [
|
||||||
|
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
|
||||||
|
],
|
||||||
|
issuePlans: [
|
||||||
|
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: "2026-03-23T17:00:00.000Z",
|
||||||
|
source: {
|
||||||
|
companyId: "company-src",
|
||||||
|
companyName: "Source Co",
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
path: "COMPANY.md",
|
||||||
|
name: "Source Co",
|
||||||
|
description: null,
|
||||||
|
brandColor: null,
|
||||||
|
logoPath: null,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
agents: ["ceo"],
|
||||||
|
projects: ["alpha"],
|
||||||
|
},
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
slug: "ceo",
|
||||||
|
name: "CEO",
|
||||||
|
path: "agents/ceo/AGENT.md",
|
||||||
|
skills: [],
|
||||||
|
role: "ceo",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
capabilities: null,
|
||||||
|
reportsToSlug: null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
key: "skill-a",
|
||||||
|
slug: "skill-a",
|
||||||
|
name: "Skill A",
|
||||||
|
path: "skills/skill-a/SKILL.md",
|
||||||
|
description: null,
|
||||||
|
sourceType: "inline",
|
||||||
|
sourceLocator: null,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: null,
|
||||||
|
compatibility: null,
|
||||||
|
metadata: null,
|
||||||
|
fileInventory: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
slug: "alpha",
|
||||||
|
name: "Alpha",
|
||||||
|
path: "projects/alpha/PROJECT.md",
|
||||||
|
description: null,
|
||||||
|
ownerAgentSlug: null,
|
||||||
|
leadAgentSlug: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
workspaces: [],
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
slug: "kickoff",
|
||||||
|
identifier: null,
|
||||||
|
title: "Kickoff",
|
||||||
|
path: "projects/alpha/issues/kickoff/TASK.md",
|
||||||
|
projectSlug: "alpha",
|
||||||
|
projectWorkspaceKey: null,
|
||||||
|
assigneeAgentSlug: "ceo",
|
||||||
|
description: null,
|
||||||
|
recurring: false,
|
||||||
|
routine: null,
|
||||||
|
legacyRecurrence: null,
|
||||||
|
status: null,
|
||||||
|
priority: null,
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
envInputs: [
|
||||||
|
{
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
description: null,
|
||||||
|
agentSlug: "ceo",
|
||||||
|
kind: "secret",
|
||||||
|
requirement: "required",
|
||||||
|
defaultValue: null,
|
||||||
|
portability: "portable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": "# Source Co",
|
||||||
|
},
|
||||||
|
envInputs: [
|
||||||
|
{
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
description: null,
|
||||||
|
agentSlug: "ceo",
|
||||||
|
kind: "secret",
|
||||||
|
requirement: "required",
|
||||||
|
defaultValue: null,
|
||||||
|
portability: "portable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: ["One warning"],
|
||||||
|
errors: ["One error"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendered = renderCompanyImportPreview(preview, {
|
||||||
|
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
|
||||||
|
targetLabel: "Imported Co (company-123)",
|
||||||
|
infoMessages: ["Using claude-local adapter"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rendered).toContain("Include");
|
||||||
|
expect(rendered).toContain("company, projects, tasks, agents, skills");
|
||||||
|
expect(rendered).toContain("7 agents total");
|
||||||
|
expect(rendered).toContain("1 project total");
|
||||||
|
expect(rendered).toContain("1 task total");
|
||||||
|
expect(rendered).toContain("skills: 1 skill packaged");
|
||||||
|
expect(rendered).toContain("+1 more");
|
||||||
|
expect(rendered).toContain("Using claude-local adapter");
|
||||||
|
expect(rendered).toContain("Warnings");
|
||||||
|
expect(rendered).toContain("Errors");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderCompanyImportResult", () => {
|
||||||
|
it("summarizes import results with created, updated, and skipped counts", () => {
|
||||||
|
const rendered = renderCompanyImportResult(
|
||||||
|
{
|
||||||
|
company: {
|
||||||
|
id: "company-123",
|
||||||
|
name: "Imported Co",
|
||||||
|
action: "updated",
|
||||||
|
},
|
||||||
|
agents: [
|
||||||
|
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
|
||||||
|
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
|
||||||
|
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
|
||||||
|
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
|
||||||
|
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
|
||||||
|
],
|
||||||
|
envInputs: [],
|
||||||
|
warnings: ["Review API keys"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetLabel: "Imported Co (company-123)",
|
||||||
|
companyUrl: "https://paperclip.example/PAP/dashboard",
|
||||||
|
infoMessages: ["Using claude-local adapter"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rendered).toContain("Company");
|
||||||
|
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
|
||||||
|
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
|
||||||
|
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
|
||||||
|
expect(rendered).toContain("Agent results");
|
||||||
|
expect(rendered).toContain("Project results");
|
||||||
|
expect(rendered).toContain("Using claude-local adapter");
|
||||||
|
expect(rendered).toContain("Review API keys");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import selection catalog", () => {
|
||||||
|
it("defaults to everything and keeps project selection separate from task selection", () => {
|
||||||
|
const preview: CompanyPortabilityPreviewResult = {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
},
|
||||||
|
targetCompanyId: "company-123",
|
||||||
|
targetCompanyName: "Imported Co",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
selectedAgentSlugs: ["ceo"],
|
||||||
|
plan: {
|
||||||
|
companyAction: "create",
|
||||||
|
agentPlans: [],
|
||||||
|
projectPlans: [],
|
||||||
|
issuePlans: [],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: "2026-03-23T18:00:00.000Z",
|
||||||
|
source: {
|
||||||
|
companyId: "company-src",
|
||||||
|
companyName: "Source Co",
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
path: "COMPANY.md",
|
||||||
|
name: "Source Co",
|
||||||
|
description: null,
|
||||||
|
brandColor: null,
|
||||||
|
logoPath: "images/company-logo.png",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
agents: ["ceo"],
|
||||||
|
projects: ["alpha"],
|
||||||
|
},
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
slug: "ceo",
|
||||||
|
name: "CEO",
|
||||||
|
path: "agents/ceo/AGENT.md",
|
||||||
|
skills: [],
|
||||||
|
role: "ceo",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
capabilities: null,
|
||||||
|
reportsToSlug: null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
key: "skill-a",
|
||||||
|
slug: "skill-a",
|
||||||
|
name: "Skill A",
|
||||||
|
path: "skills/skill-a/SKILL.md",
|
||||||
|
description: null,
|
||||||
|
sourceType: "inline",
|
||||||
|
sourceLocator: null,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: null,
|
||||||
|
compatibility: null,
|
||||||
|
metadata: null,
|
||||||
|
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
slug: "alpha",
|
||||||
|
name: "Alpha",
|
||||||
|
path: "projects/alpha/PROJECT.md",
|
||||||
|
description: null,
|
||||||
|
ownerAgentSlug: null,
|
||||||
|
leadAgentSlug: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
workspaces: [],
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
slug: "kickoff",
|
||||||
|
identifier: null,
|
||||||
|
title: "Kickoff",
|
||||||
|
path: "projects/alpha/issues/kickoff/TASK.md",
|
||||||
|
projectSlug: "alpha",
|
||||||
|
projectWorkspaceKey: null,
|
||||||
|
assigneeAgentSlug: "ceo",
|
||||||
|
description: null,
|
||||||
|
recurring: false,
|
||||||
|
routine: null,
|
||||||
|
legacyRecurrence: null,
|
||||||
|
status: null,
|
||||||
|
priority: null,
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
envInputs: [],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": "# Source Co",
|
||||||
|
"README.md": "# Readme",
|
||||||
|
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||||
|
"images/company-logo.png": {
|
||||||
|
encoding: "base64",
|
||||||
|
data: "",
|
||||||
|
contentType: "image/png",
|
||||||
|
},
|
||||||
|
"projects/alpha/PROJECT.md": "# Alpha",
|
||||||
|
"projects/alpha/notes.md": "project notes",
|
||||||
|
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
|
||||||
|
"projects/alpha/issues/kickoff/details.md": "task details",
|
||||||
|
"agents/ceo/AGENT.md": "# CEO",
|
||||||
|
"agents/ceo/prompt.md": "prompt",
|
||||||
|
"skills/skill-a/SKILL.md": "# Skill A",
|
||||||
|
"skills/skill-a/helper.md": "helper",
|
||||||
|
},
|
||||||
|
envInputs: [],
|
||||||
|
warnings: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalog = buildImportSelectionCatalog(preview);
|
||||||
|
const state = buildDefaultImportSelectionState(catalog);
|
||||||
|
|
||||||
|
expect(state.company).toBe(true);
|
||||||
|
expect(state.projects.has("alpha")).toBe(true);
|
||||||
|
expect(state.issues.has("kickoff")).toBe(true);
|
||||||
|
expect(state.agents.has("ceo")).toBe(true);
|
||||||
|
expect(state.skills.has("skill-a")).toBe(true);
|
||||||
|
|
||||||
|
state.company = false;
|
||||||
|
state.issues.clear();
|
||||||
|
state.agents.clear();
|
||||||
|
state.skills.clear();
|
||||||
|
|
||||||
|
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||||
|
|
||||||
|
expect(selectedFiles).toContain(".paperclip.yaml");
|
||||||
|
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
|
||||||
|
expect(selectedFiles).toContain("projects/alpha/notes.md");
|
||||||
|
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
|
||||||
|
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("default adapter overrides", () => {
|
||||||
|
it("maps process-only imported agents to claude_local", () => {
|
||||||
|
const preview: CompanyPortabilityPreviewResult = {
|
||||||
|
include: {
|
||||||
|
company: false,
|
||||||
|
agents: true,
|
||||||
|
projects: false,
|
||||||
|
issues: false,
|
||||||
|
skills: false,
|
||||||
|
},
|
||||||
|
targetCompanyId: null,
|
||||||
|
targetCompanyName: null,
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
|
||||||
|
plan: {
|
||||||
|
companyAction: "none",
|
||||||
|
agentPlans: [],
|
||||||
|
projectPlans: [],
|
||||||
|
issuePlans: [],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: "2026-03-23T18:20:00.000Z",
|
||||||
|
source: null,
|
||||||
|
includes: {
|
||||||
|
company: false,
|
||||||
|
agents: true,
|
||||||
|
projects: false,
|
||||||
|
issues: false,
|
||||||
|
skills: false,
|
||||||
|
},
|
||||||
|
company: null,
|
||||||
|
sidebar: null,
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
slug: "legacy-agent",
|
||||||
|
name: "Legacy Agent",
|
||||||
|
path: "agents/legacy-agent/AGENT.md",
|
||||||
|
skills: [],
|
||||||
|
role: "agent",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
capabilities: null,
|
||||||
|
reportsToSlug: null,
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "explicit-agent",
|
||||||
|
name: "Explicit Agent",
|
||||||
|
path: "agents/explicit-agent/AGENT.md",
|
||||||
|
skills: [],
|
||||||
|
role: "agent",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
capabilities: null,
|
||||||
|
reportsToSlug: null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
projects: [],
|
||||||
|
issues: [],
|
||||||
|
envInputs: [],
|
||||||
|
},
|
||||||
|
files: {},
|
||||||
|
envInputs: [],
|
||||||
|
warnings: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
|
||||||
|
"legacy-agent": {
|
||||||
|
adapterType: "claude_local",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
87
cli/src/__tests__/helpers/zip.ts
Normal file
87
cli/src/__tests__/helpers/zip.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
function writeUint16(target: Uint8Array, offset: number, value: number) {
|
||||||
|
target[offset] = value & 0xff;
|
||||||
|
target[offset + 1] = (value >>> 8) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||||
|
target[offset] = value & 0xff;
|
||||||
|
target[offset + 1] = (value >>> 8) & 0xff;
|
||||||
|
target[offset + 2] = (value >>> 16) & 0xff;
|
||||||
|
target[offset + 3] = (value >>> 24) & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(bytes: Uint8Array) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of bytes) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (let bit = 0; bit < 8; bit += 1) {
|
||||||
|
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const localChunks: Uint8Array[] = [];
|
||||||
|
const centralChunks: Uint8Array[] = [];
|
||||||
|
let localOffset = 0;
|
||||||
|
let entryCount = 0;
|
||||||
|
|
||||||
|
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
|
||||||
|
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
|
||||||
|
const body = encoder.encode(content);
|
||||||
|
const checksum = crc32(body);
|
||||||
|
|
||||||
|
const localHeader = new Uint8Array(30 + fileName.length);
|
||||||
|
writeUint32(localHeader, 0, 0x04034b50);
|
||||||
|
writeUint16(localHeader, 4, 20);
|
||||||
|
writeUint16(localHeader, 6, 0x0800);
|
||||||
|
writeUint16(localHeader, 8, 0);
|
||||||
|
writeUint32(localHeader, 14, checksum);
|
||||||
|
writeUint32(localHeader, 18, body.length);
|
||||||
|
writeUint32(localHeader, 22, body.length);
|
||||||
|
writeUint16(localHeader, 26, fileName.length);
|
||||||
|
localHeader.set(fileName, 30);
|
||||||
|
|
||||||
|
const centralHeader = new Uint8Array(46 + fileName.length);
|
||||||
|
writeUint32(centralHeader, 0, 0x02014b50);
|
||||||
|
writeUint16(centralHeader, 4, 20);
|
||||||
|
writeUint16(centralHeader, 6, 20);
|
||||||
|
writeUint16(centralHeader, 8, 0x0800);
|
||||||
|
writeUint16(centralHeader, 10, 0);
|
||||||
|
writeUint32(centralHeader, 16, checksum);
|
||||||
|
writeUint32(centralHeader, 20, body.length);
|
||||||
|
writeUint32(centralHeader, 24, body.length);
|
||||||
|
writeUint16(centralHeader, 28, fileName.length);
|
||||||
|
writeUint32(centralHeader, 42, localOffset);
|
||||||
|
centralHeader.set(fileName, 46);
|
||||||
|
|
||||||
|
localChunks.push(localHeader, body);
|
||||||
|
centralChunks.push(centralHeader);
|
||||||
|
localOffset += localHeader.length + body.length;
|
||||||
|
entryCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const archive = new Uint8Array(
|
||||||
|
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
|
||||||
|
);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of localChunks) {
|
||||||
|
archive.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
const centralDirectoryOffset = offset;
|
||||||
|
for (const chunk of centralChunks) {
|
||||||
|
archive.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
writeUint32(archive, offset, 0x06054b50);
|
||||||
|
writeUint16(archive, offset + 8, entryCount);
|
||||||
|
writeUint16(archive, offset + 10, entryCount);
|
||||||
|
writeUint32(archive, offset + 12, centralDirectoryLength);
|
||||||
|
writeUint32(archive, offset + 16, centralDirectoryOffset);
|
||||||
|
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
@ -169,7 +169,7 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openUrl(url: string): boolean {
|
export function openUrl(url: string): boolean {
|
||||||
const platform = process.platform;
|
const platform = process.platform;
|
||||||
try {
|
try {
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
|
import pc from "picocolors";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
|
|
@ -11,6 +12,8 @@ import type {
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { ApiRequestError } from "../../client/http.js";
|
import { ApiRequestError } from "../../client/http.js";
|
||||||
|
import { openUrl } from "../../client/board-auth.js";
|
||||||
|
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
|
||||||
import {
|
import {
|
||||||
addCommonClientOptions,
|
addCommonClientOptions,
|
||||||
formatInlineRecord,
|
formatInlineRecord,
|
||||||
|
|
@ -49,16 +52,61 @@ interface CompanyImportOptions extends BaseClientOptions {
|
||||||
agents?: string;
|
agents?: string;
|
||||||
collision?: CompanyCollisionMode;
|
collision?: CompanyCollisionMode;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
|
paperclipUrl?: string;
|
||||||
|
yes?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const binaryContentTypeByExtension: Record<string, string> = {
|
const DEFAULT_EXPORT_INCLUDE: CompanyPortabilityInclude = {
|
||||||
".gif": "image/gif",
|
company: true,
|
||||||
".jpeg": "image/jpeg",
|
agents: true,
|
||||||
".jpg": "image/jpeg",
|
projects: false,
|
||||||
".png": "image/png",
|
issues: false,
|
||||||
".svg": "image/svg+xml",
|
skills: false,
|
||||||
".webp": "image/webp",
|
};
|
||||||
|
|
||||||
|
const DEFAULT_IMPORT_INCLUDE: CompanyPortabilityInclude = {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMPORT_INCLUDE_OPTIONS: Array<{
|
||||||
|
value: keyof CompanyPortabilityInclude;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "company", label: "Company", hint: "name, branding, and company settings" },
|
||||||
|
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
|
||||||
|
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
|
||||||
|
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
|
||||||
|
{ value: "skills", label: "Skills", hint: "company skill packages and references" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
|
||||||
|
|
||||||
|
type ImportSelectableGroup = "projects" | "issues" | "agents" | "skills";
|
||||||
|
|
||||||
|
type ImportSelectionCatalog = {
|
||||||
|
company: {
|
||||||
|
includedByDefault: boolean;
|
||||||
|
files: string[];
|
||||||
|
};
|
||||||
|
projects: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
issues: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
agents: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
skills: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
extensionPath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportSelectionState = {
|
||||||
|
company: boolean;
|
||||||
|
projects: Set<string>;
|
||||||
|
issues: Set<string>;
|
||||||
|
agents: Set<string>;
|
||||||
|
skills: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry {
|
function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry {
|
||||||
|
|
@ -84,8 +132,11 @@ function normalizeSelector(input: string): string {
|
||||||
return input.trim();
|
return input.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
|
function parseInclude(
|
||||||
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false, skills: false };
|
input: string | undefined,
|
||||||
|
fallback: CompanyPortabilityInclude = DEFAULT_EXPORT_INCLUDE,
|
||||||
|
): CompanyPortabilityInclude {
|
||||||
|
if (!input || !input.trim()) return { ...fallback };
|
||||||
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||||
const include = {
|
const include = {
|
||||||
company: values.includes("company"),
|
company: values.includes("company"),
|
||||||
|
|
@ -114,6 +165,554 @@ function parseCsvValues(input: string | undefined): string[] {
|
||||||
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
|
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInteractiveTerminal(): boolean {
|
||||||
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||||
|
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePortablePath(filePath: string): string {
|
||||||
|
return filePath.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIncludePortableFile(filePath: string): boolean {
|
||||||
|
const baseName = path.basename(filePath);
|
||||||
|
const isMarkdown = baseName.endsWith(".md");
|
||||||
|
const isPaperclipYaml = baseName === ".paperclip.yaml" || baseName === ".paperclip.yml";
|
||||||
|
const contentType = binaryContentTypeByExtension[path.extname(baseName).toLowerCase()];
|
||||||
|
return isMarkdown || isPaperclipYaml || Boolean(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPortableExtensionPath(files: Record<string, CompanyPortabilityFileEntry>): string | null {
|
||||||
|
if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml";
|
||||||
|
if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml";
|
||||||
|
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFilesUnderDirectory(
|
||||||
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
|
directory: string,
|
||||||
|
opts?: { excludePrefixes?: string[] },
|
||||||
|
): string[] {
|
||||||
|
const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, "");
|
||||||
|
if (!normalizedDirectory) return [];
|
||||||
|
const prefix = `${normalizedDirectory}/`;
|
||||||
|
const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean);
|
||||||
|
return Object.keys(files)
|
||||||
|
.map(normalizePortablePath)
|
||||||
|
.filter((filePath) => filePath.startsWith(prefix))
|
||||||
|
.filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`)))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEntityFiles(
|
||||||
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
|
entryPath: string,
|
||||||
|
opts?: { excludePrefixes?: string[] },
|
||||||
|
): string[] {
|
||||||
|
const normalizedPath = normalizePortablePath(entryPath);
|
||||||
|
const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : "";
|
||||||
|
const selected = new Set<string>([normalizedPath]);
|
||||||
|
if (directory) {
|
||||||
|
for (const filePath of collectFilesUnderDirectory(files, directory, opts)) {
|
||||||
|
selected.add(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog {
|
||||||
|
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
|
||||||
|
const companyFiles = new Set<string>();
|
||||||
|
const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null;
|
||||||
|
if (companyPath) {
|
||||||
|
companyFiles.add(companyPath);
|
||||||
|
}
|
||||||
|
const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md");
|
||||||
|
if (readmePath) {
|
||||||
|
companyFiles.add(normalizePortablePath(readmePath));
|
||||||
|
}
|
||||||
|
const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null;
|
||||||
|
if (logoPath && preview.files[logoPath] !== undefined) {
|
||||||
|
companyFiles.add(logoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
company: {
|
||||||
|
includedByDefault: preview.include.company && preview.manifest.company !== null,
|
||||||
|
files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)),
|
||||||
|
},
|
||||||
|
projects: preview.manifest.projects.map((project) => {
|
||||||
|
const projectPath = normalizePortablePath(project.path);
|
||||||
|
const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : "";
|
||||||
|
return {
|
||||||
|
key: project.slug,
|
||||||
|
label: project.name,
|
||||||
|
hint: project.slug,
|
||||||
|
files: collectEntityFiles(preview.files, projectPath, {
|
||||||
|
excludePrefixes: projectDir ? [`${projectDir}/issues`] : [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
issues: preview.manifest.issues.map((issue) => ({
|
||||||
|
key: issue.slug,
|
||||||
|
label: issue.title,
|
||||||
|
hint: issue.identifier ?? issue.slug,
|
||||||
|
files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)),
|
||||||
|
})),
|
||||||
|
agents: preview.manifest.agents
|
||||||
|
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
|
||||||
|
.map((agent) => ({
|
||||||
|
key: agent.slug,
|
||||||
|
label: agent.name,
|
||||||
|
hint: agent.slug,
|
||||||
|
files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)),
|
||||||
|
})),
|
||||||
|
skills: preview.manifest.skills.map((skill) => ({
|
||||||
|
key: skill.slug,
|
||||||
|
label: skill.name,
|
||||||
|
hint: skill.slug,
|
||||||
|
files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)),
|
||||||
|
})),
|
||||||
|
extensionPath: findPortableExtensionPath(preview.files),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKeySet(items: Array<{ key: string }>): Set<string> {
|
||||||
|
return new Set(items.map((item) => item.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState {
|
||||||
|
return {
|
||||||
|
company: catalog.company.includedByDefault,
|
||||||
|
projects: toKeySet(catalog.projects),
|
||||||
|
issues: toKeySet(catalog.issues),
|
||||||
|
agents: toKeySet(catalog.agents),
|
||||||
|
skills: toKeySet(catalog.skills),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number {
|
||||||
|
return state[group].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number {
|
||||||
|
return catalog[group].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string {
|
||||||
|
return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupLabel(group: ImportSelectableGroup): string {
|
||||||
|
switch (group) {
|
||||||
|
case "projects":
|
||||||
|
return "Projects";
|
||||||
|
case "issues":
|
||||||
|
return "Tasks";
|
||||||
|
case "agents":
|
||||||
|
return "Agents";
|
||||||
|
case "skills":
|
||||||
|
return "Skills";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectedFilesFromImportSelection(
|
||||||
|
catalog: ImportSelectionCatalog,
|
||||||
|
state: ImportSelectionState,
|
||||||
|
): string[] {
|
||||||
|
const selected = new Set<string>();
|
||||||
|
|
||||||
|
if (state.company) {
|
||||||
|
for (const filePath of catalog.company.files) {
|
||||||
|
selected.add(normalizePortablePath(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of ["projects", "issues", "agents", "skills"] as const) {
|
||||||
|
const selectedKeys = state[group];
|
||||||
|
for (const item of catalog[group]) {
|
||||||
|
if (!selectedKeys.has(item.key)) continue;
|
||||||
|
for (const filePath of item.files) {
|
||||||
|
selected.add(normalizePortablePath(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.size > 0 && catalog.extensionPath) {
|
||||||
|
selected.add(normalizePortablePath(catalog.extensionPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultImportAdapterOverrides(
|
||||||
|
preview: Pick<CompanyPortabilityPreviewResult, "manifest" | "selectedAgentSlugs">,
|
||||||
|
): Record<string, { adapterType: string }> | undefined {
|
||||||
|
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
|
||||||
|
const overrides = Object.fromEntries(
|
||||||
|
preview.manifest.agents
|
||||||
|
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
|
||||||
|
.filter((agent) => agent.adapterType === "process")
|
||||||
|
.map((agent) => [
|
||||||
|
agent.slug,
|
||||||
|
{
|
||||||
|
// TODO: replace this temporary claude_local fallback with adapter selection in the import TUI.
|
||||||
|
adapterType: "claude_local",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultImportAdapterMessages(
|
||||||
|
overrides: Record<string, { adapterType: string }> | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!overrides) return [];
|
||||||
|
const adapterTypes = Array.from(new Set(Object.values(overrides).map((override) => override.adapterType)))
|
||||||
|
.map((adapterType) => adapterType.replace(/_/g, "-"));
|
||||||
|
const agentCount = Object.keys(overrides).length;
|
||||||
|
return [
|
||||||
|
`Using ${adapterTypes.join(", ")} adapter${adapterTypes.length === 1 ? "" : "s"} for ${agentCount} imported ${pluralize(agentCount, "agent")} without an explicit adapter.`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise<string[]> {
|
||||||
|
const catalog = buildImportSelectionCatalog(preview);
|
||||||
|
const state = buildDefaultImportSelectionState(catalog);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const choice = await p.select<ImportSelectableGroup | "company" | "confirm">({
|
||||||
|
message: "Select what Paperclip should import",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "company",
|
||||||
|
label: state.company ? "Company: included" : "Company: skipped",
|
||||||
|
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "projects",
|
||||||
|
label: "Select Projects",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "projects"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "issues",
|
||||||
|
label: "Select Tasks",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "issues"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "agents",
|
||||||
|
label: "Select Agents",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "agents"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "skills",
|
||||||
|
label: "Select Skills",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "skills"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "confirm",
|
||||||
|
label: "Confirm",
|
||||||
|
hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "confirm",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(choice)) {
|
||||||
|
p.cancel("Import cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "confirm") {
|
||||||
|
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
p.note("Select at least one import target before confirming.", "Nothing selected");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return selectedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "company") {
|
||||||
|
if (catalog.company.files.length === 0) {
|
||||||
|
p.note("This package does not include company metadata to toggle.", "No company metadata");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
state.company = !state.company;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = choice;
|
||||||
|
const groupItems = catalog[group];
|
||||||
|
if (groupItems.length === 0) {
|
||||||
|
p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await p.multiselect<string>({
|
||||||
|
message: `${getGroupLabel(group)} to import. Space toggles, enter returns to the main menu.`,
|
||||||
|
options: groupItems.map((item) => ({
|
||||||
|
value: item.key,
|
||||||
|
label: item.label,
|
||||||
|
hint: item.hint,
|
||||||
|
})),
|
||||||
|
initialValues: Array.from(state[group]),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(selection)) {
|
||||||
|
p.cancel("Import cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
state[group] = new Set(selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeInclude(include: CompanyPortabilityInclude): string {
|
||||||
|
const labels = IMPORT_INCLUDE_OPTIONS
|
||||||
|
.filter((option) => include[option.value])
|
||||||
|
.map((option) => option.label.toLowerCase());
|
||||||
|
return labels.length > 0 ? labels.join(", ") : "nothing selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSourceLabel(source: { type: "inline"; rootPath?: string | null } | { type: "github"; url: string }): string {
|
||||||
|
if (source.type === "github") {
|
||||||
|
return `GitHub: ${source.url}`;
|
||||||
|
}
|
||||||
|
return `Local package: ${source.rootPath?.trim() || "(current folder)"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTargetLabel(
|
||||||
|
target: { mode: "existing_company"; companyId?: string | null } | { mode: "new_company"; newCompanyName?: string | null },
|
||||||
|
preview?: CompanyPortabilityPreviewResult,
|
||||||
|
): string {
|
||||||
|
if (target.mode === "existing_company") {
|
||||||
|
const targetName = preview?.targetCompanyName?.trim();
|
||||||
|
const targetId = preview?.targetCompanyId?.trim() || target.companyId?.trim() || "unknown-company";
|
||||||
|
return targetName ? `${targetName} (${targetId})` : targetId;
|
||||||
|
}
|
||||||
|
return target.newCompanyName?.trim() || preview?.manifest.company?.name || "new company";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
||||||
|
return count === 1 ? singular : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePlanCounts(
|
||||||
|
plans: Array<{ action: "create" | "update" | "skip" }>,
|
||||||
|
noun: string,
|
||||||
|
): string {
|
||||||
|
if (plans.length === 0) return `0 ${pluralize(0, noun)} selected`;
|
||||||
|
const createCount = plans.filter((plan) => plan.action === "create").length;
|
||||||
|
const updateCount = plans.filter((plan) => plan.action === "update").length;
|
||||||
|
const skipCount = plans.filter((plan) => plan.action === "skip").length;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (createCount > 0) parts.push(`${createCount} create`);
|
||||||
|
if (updateCount > 0) parts.push(`${updateCount} update`);
|
||||||
|
if (skipCount > 0) parts.push(`${skipCount} skip`);
|
||||||
|
return `${plans.length} ${pluralize(plans.length, noun)} total (${parts.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["agents"]): string {
|
||||||
|
if (agents.length === 0) return "0 agents changed";
|
||||||
|
const created = agents.filter((agent) => agent.action === "created").length;
|
||||||
|
const updated = agents.filter((agent) => agent.action === "updated").length;
|
||||||
|
const skipped = agents.filter((agent) => agent.action === "skipped").length;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (created > 0) parts.push(`${created} created`);
|
||||||
|
if (updated > 0) parts.push(`${updated} updated`);
|
||||||
|
if (skipped > 0) parts.push(`${skipped} skipped`);
|
||||||
|
return `${agents.length} ${pluralize(agents.length, "agent")} total (${parts.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeImportProjectResults(projects: CompanyPortabilityImportResult["projects"]): string {
|
||||||
|
if (projects.length === 0) return "0 projects changed";
|
||||||
|
const created = projects.filter((project) => project.action === "created").length;
|
||||||
|
const updated = projects.filter((project) => project.action === "updated").length;
|
||||||
|
const skipped = projects.filter((project) => project.action === "skipped").length;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (created > 0) parts.push(`${created} created`);
|
||||||
|
if (updated > 0) parts.push(`${updated} updated`);
|
||||||
|
if (skipped > 0) parts.push(`${skipped} skipped`);
|
||||||
|
return `${projects.length} ${pluralize(projects.length, "project")} total (${parts.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionChip(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case "create":
|
||||||
|
case "created":
|
||||||
|
return pc.green(action);
|
||||||
|
case "update":
|
||||||
|
case "updated":
|
||||||
|
return pc.yellow(action);
|
||||||
|
case "skip":
|
||||||
|
case "skipped":
|
||||||
|
case "none":
|
||||||
|
case "unchanged":
|
||||||
|
return pc.dim(action);
|
||||||
|
default:
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPreviewExamples(
|
||||||
|
lines: string[],
|
||||||
|
title: string,
|
||||||
|
entries: Array<{ action: string; label: string; reason?: string | null }>,
|
||||||
|
): void {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
lines.push("");
|
||||||
|
lines.push(pc.bold(title));
|
||||||
|
const shown = entries.slice(0, IMPORT_PREVIEW_SAMPLE_LIMIT);
|
||||||
|
for (const entry of shown) {
|
||||||
|
const reason = entry.reason?.trim() ? pc.dim(` (${entry.reason.trim()})`) : "";
|
||||||
|
lines.push(`- ${actionChip(entry.action)} ${entry.label}${reason}`);
|
||||||
|
}
|
||||||
|
if (entries.length > shown.length) {
|
||||||
|
lines.push(pc.dim(`- +${entries.length - shown.length} more`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessageBlock(lines: string[], title: string, messages: string[]): void {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
lines.push("");
|
||||||
|
lines.push(pc.bold(title));
|
||||||
|
for (const message of messages) {
|
||||||
|
lines.push(`- ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCompanyImportPreview(
|
||||||
|
preview: CompanyPortabilityPreviewResult,
|
||||||
|
meta: {
|
||||||
|
sourceLabel: string;
|
||||||
|
targetLabel: string;
|
||||||
|
infoMessages?: string[];
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
`${pc.bold("Source")} ${meta.sourceLabel}`,
|
||||||
|
`${pc.bold("Target")} ${meta.targetLabel}`,
|
||||||
|
`${pc.bold("Include")} ${summarizeInclude(preview.include)}`,
|
||||||
|
`${pc.bold("Mode")} ${preview.collisionStrategy} collisions`,
|
||||||
|
"",
|
||||||
|
pc.bold("Package"),
|
||||||
|
`- company: ${preview.manifest.company?.name ?? preview.manifest.source?.companyName ?? "not included"}`,
|
||||||
|
`- agents: ${preview.manifest.agents.length}`,
|
||||||
|
`- projects: ${preview.manifest.projects.length}`,
|
||||||
|
`- tasks: ${preview.manifest.issues.length}`,
|
||||||
|
`- skills: ${preview.manifest.skills.length}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (preview.envInputs.length > 0) {
|
||||||
|
const requiredCount = preview.envInputs.filter((item) => item.requirement === "required").length;
|
||||||
|
lines.push(`- env inputs: ${preview.envInputs.length} (${requiredCount} required)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push(pc.bold("Plan"));
|
||||||
|
lines.push(`- company: ${actionChip(preview.plan.companyAction === "none" ? "unchanged" : preview.plan.companyAction)}`);
|
||||||
|
lines.push(`- agents: ${summarizePlanCounts(preview.plan.agentPlans, "agent")}`);
|
||||||
|
lines.push(`- projects: ${summarizePlanCounts(preview.plan.projectPlans, "project")}`);
|
||||||
|
lines.push(`- tasks: ${summarizePlanCounts(preview.plan.issuePlans, "task")}`);
|
||||||
|
if (preview.include.skills) {
|
||||||
|
lines.push(`- skills: ${preview.manifest.skills.length} ${pluralize(preview.manifest.skills.length, "skill")} packaged`);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPreviewExamples(
|
||||||
|
lines,
|
||||||
|
"Agent examples",
|
||||||
|
preview.plan.agentPlans.map((plan) => ({
|
||||||
|
action: plan.action,
|
||||||
|
label: `${plan.slug} -> ${plan.plannedName}`,
|
||||||
|
reason: plan.reason,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
appendPreviewExamples(
|
||||||
|
lines,
|
||||||
|
"Project examples",
|
||||||
|
preview.plan.projectPlans.map((plan) => ({
|
||||||
|
action: plan.action,
|
||||||
|
label: `${plan.slug} -> ${plan.plannedName}`,
|
||||||
|
reason: plan.reason,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
appendPreviewExamples(
|
||||||
|
lines,
|
||||||
|
"Task examples",
|
||||||
|
preview.plan.issuePlans.map((plan) => ({
|
||||||
|
action: plan.action,
|
||||||
|
label: `${plan.slug} -> ${plan.plannedTitle}`,
|
||||||
|
reason: plan.reason,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []);
|
||||||
|
appendMessageBlock(lines, pc.yellow("Warnings"), preview.warnings);
|
||||||
|
appendMessageBlock(lines, pc.red("Errors"), preview.errors);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCompanyImportResult(
|
||||||
|
result: CompanyPortabilityImportResult,
|
||||||
|
meta: { targetLabel: string; companyUrl?: string; infoMessages?: string[] },
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [
|
||||||
|
`${pc.bold("Target")} ${meta.targetLabel}`,
|
||||||
|
`${pc.bold("Company")} ${result.company.name} (${actionChip(result.company.action)})`,
|
||||||
|
`${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`,
|
||||||
|
`${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (meta.companyUrl) {
|
||||||
|
lines.splice(1, 0, `${pc.bold("URL")} ${meta.companyUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPreviewExamples(
|
||||||
|
lines,
|
||||||
|
"Agent results",
|
||||||
|
result.agents.map((agent) => ({
|
||||||
|
action: agent.action,
|
||||||
|
label: `${agent.slug} -> ${agent.name}`,
|
||||||
|
reason: agent.reason,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
appendPreviewExamples(
|
||||||
|
lines,
|
||||||
|
"Project results",
|
||||||
|
result.projects.map((project) => ({
|
||||||
|
action: project.action,
|
||||||
|
label: `${project.slug} -> ${project.name}`,
|
||||||
|
reason: project.reason,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.envInputs.length > 0) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(pc.bold("Env inputs"));
|
||||||
|
lines.push(
|
||||||
|
`- ${result.envInputs.length} ${pluralize(result.envInputs.length, "input")} may need values after import`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []);
|
||||||
|
appendMessageBlock(lines, pc.yellow("Warnings"), result.warnings);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function printCompanyImportView(title: string, body: string, opts?: { interactive?: boolean }): void {
|
||||||
|
if (opts?.interactive) {
|
||||||
|
p.note(body, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(pc.bold(title));
|
||||||
|
console.log(body);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCompanyImportApiPath(input: {
|
export function resolveCompanyImportApiPath(input: {
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
targetMode: "new_company" | "existing_company";
|
targetMode: "new_company" | "existing_company";
|
||||||
|
|
@ -132,6 +731,36 @@ export function resolveCompanyImportApiPath(input: {
|
||||||
return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import";
|
return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCompanyDashboardUrl(apiBase: string, issuePrefix: string): string {
|
||||||
|
const url = new URL(apiBase);
|
||||||
|
const normalizedPrefix = issuePrefix.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
url.pathname = `${url.pathname.replace(/\/+$/, "")}/${normalizedPrefix}/dashboard`;
|
||||||
|
url.search = "";
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCompanyImportApplyConfirmationMode(input: {
|
||||||
|
yes?: boolean;
|
||||||
|
interactive: boolean;
|
||||||
|
json: boolean;
|
||||||
|
}): "skip" | "prompt" {
|
||||||
|
if (input.yes) {
|
||||||
|
return "skip";
|
||||||
|
}
|
||||||
|
if (input.json) {
|
||||||
|
throw new Error(
|
||||||
|
"Applying a company import with --json requires --yes. Use --dry-run first to inspect the preview.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!input.interactive) {
|
||||||
|
throw new Error(
|
||||||
|
"Applying a company import from a non-interactive terminal requires --yes. Use --dry-run first to inspect the preview.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "prompt";
|
||||||
|
}
|
||||||
|
|
||||||
export function isHttpUrl(input: string): boolean {
|
export function isHttpUrl(input: string): boolean {
|
||||||
return /^https?:\/\//i.test(input.trim());
|
return /^https?:\/\//i.test(input.trim());
|
||||||
}
|
}
|
||||||
|
|
@ -260,21 +889,29 @@ async function collectPackageFiles(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
const isMarkdown = entry.name.endsWith(".md");
|
|
||||||
const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml";
|
|
||||||
const contentType = binaryContentTypeByExtension[path.extname(entry.name).toLowerCase()];
|
|
||||||
if (!isMarkdown && !isPaperclipYaml && !contentType) continue;
|
|
||||||
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
||||||
|
if (!shouldIncludePortableFile(relativePath)) continue;
|
||||||
files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath));
|
files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
export async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
files: Record<string, CompanyPortabilityFileEntry>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
}> {
|
}> {
|
||||||
const resolved = path.resolve(inputPath);
|
const resolved = path.resolve(inputPath);
|
||||||
const resolvedStat = await stat(resolved);
|
const resolvedStat = await stat(resolved);
|
||||||
|
if (resolvedStat.isFile() && path.extname(resolved).toLowerCase() === ".zip") {
|
||||||
|
const archive = await readZipArchive(await readFile(resolved));
|
||||||
|
const filteredFiles = Object.fromEntries(
|
||||||
|
Object.entries(archive.files).filter(([relativePath]) => shouldIncludePortableFile(relativePath)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
rootPath: archive.rootPath ?? path.basename(resolved, ".zip"),
|
||||||
|
files: filteredFiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
|
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
|
||||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||||
await collectPackageFiles(rootDir, rootDir, files);
|
await collectPackageFiles(rootDir, rootDir, files);
|
||||||
|
|
@ -515,23 +1152,29 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
.command("import")
|
.command("import")
|
||||||
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
||||||
.argument("<fromPathOrUrl>", "Source path or URL")
|
.argument("<fromPathOrUrl>", "Source path or URL")
|
||||||
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
|
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills")
|
||||||
.option("--target <mode>", "Target mode: new | existing")
|
.option("--target <mode>", "Target mode: new | existing")
|
||||||
.option("-C, --company-id <id>", "Existing target company ID")
|
.option("-C, --company-id <id>", "Existing target company ID")
|
||||||
.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("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
|
||||||
|
.option("--paperclip-url <url>", "Alias for --api-base on this command")
|
||||||
|
.option("--yes", "Accept default selection and skip the pre-import confirmation prompt", false)
|
||||||
.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 {
|
||||||
|
if (!opts.apiBase?.trim() && opts.paperclipUrl?.trim()) {
|
||||||
|
opts.apiBase = opts.paperclipUrl.trim();
|
||||||
|
}
|
||||||
const ctx = resolveCommandContext(opts);
|
const ctx = resolveCommandContext(opts);
|
||||||
|
const interactiveView = isInteractiveTerminal() && !ctx.json;
|
||||||
const from = fromPathOrUrl.trim();
|
const from = fromPathOrUrl.trim();
|
||||||
if (!from) {
|
if (!from) {
|
||||||
throw new Error("Source path or URL is required.");
|
throw new Error("Source path or URL is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const include = parseInclude(opts.include);
|
const include = resolveImportInclude(opts.include);
|
||||||
const agents = parseAgents(opts.agents);
|
const agents = parseAgents(opts.agents);
|
||||||
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
||||||
if (!["rename", "skip", "replace"].includes(collision)) {
|
if (!["rename", "skip", "replace"].includes(collision)) {
|
||||||
|
|
@ -587,27 +1230,139 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const sourceLabel = formatSourceLabel(sourcePayload);
|
||||||
|
const targetLabel = formatTargetLabel(targetPayload);
|
||||||
|
const previewApiPath = resolveCompanyImportApiPath({
|
||||||
|
dryRun: true,
|
||||||
|
targetMode: targetPayload.mode,
|
||||||
|
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedFiles: string[] | undefined;
|
||||||
|
if (interactiveView && !opts.yes && !opts.include?.trim()) {
|
||||||
|
const initialPreview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
|
||||||
|
source: sourcePayload,
|
||||||
|
include,
|
||||||
|
target: targetPayload,
|
||||||
|
agents,
|
||||||
|
collisionStrategy: collision,
|
||||||
|
});
|
||||||
|
if (!initialPreview) {
|
||||||
|
throw new Error("Import preview returned no data.");
|
||||||
|
}
|
||||||
|
selectedFiles = await promptForImportSelection(initialPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewPayload = {
|
||||||
source: sourcePayload,
|
source: sourcePayload,
|
||||||
include,
|
include,
|
||||||
target: targetPayload,
|
target: targetPayload,
|
||||||
agents,
|
agents,
|
||||||
collisionStrategy: collision,
|
collisionStrategy: collision,
|
||||||
|
selectedFiles,
|
||||||
};
|
};
|
||||||
const importApiPath = resolveCompanyImportApiPath({
|
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, previewPayload);
|
||||||
dryRun: Boolean(opts.dryRun),
|
if (!preview) {
|
||||||
targetMode: targetPayload.mode,
|
throw new Error("Import preview returned no data.");
|
||||||
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
}
|
||||||
});
|
const adapterOverrides = buildDefaultImportAdapterOverrides(preview);
|
||||||
|
const adapterMessages = buildDefaultImportAdapterMessages(adapterOverrides);
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(importApiPath, payload);
|
if (ctx.json) {
|
||||||
printOutput(preview, { json: ctx.json });
|
printOutput(preview, { json: true });
|
||||||
|
} else {
|
||||||
|
printCompanyImportView(
|
||||||
|
"Import Preview",
|
||||||
|
renderCompanyImportPreview(preview, {
|
||||||
|
sourceLabel,
|
||||||
|
targetLabel: formatTargetLabel(targetPayload, preview),
|
||||||
|
infoMessages: adapterMessages,
|
||||||
|
}),
|
||||||
|
{ interactive: interactiveView },
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, payload);
|
if (!ctx.json) {
|
||||||
printOutput(imported, { json: ctx.json });
|
printCompanyImportView(
|
||||||
|
"Import Preview",
|
||||||
|
renderCompanyImportPreview(preview, {
|
||||||
|
sourceLabel,
|
||||||
|
targetLabel: formatTargetLabel(targetPayload, preview),
|
||||||
|
infoMessages: adapterMessages,
|
||||||
|
}),
|
||||||
|
{ interactive: interactiveView },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmationMode = resolveCompanyImportApplyConfirmationMode({
|
||||||
|
yes: opts.yes,
|
||||||
|
interactive: interactiveView,
|
||||||
|
json: ctx.json,
|
||||||
|
});
|
||||||
|
if (confirmationMode === "prompt") {
|
||||||
|
const confirmed = await p.confirm({
|
||||||
|
message: "Apply this import? (y/N)",
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (p.isCancel(confirmed) || !confirmed) {
|
||||||
|
p.log.warn("Import cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importApiPath = resolveCompanyImportApiPath({
|
||||||
|
dryRun: false,
|
||||||
|
targetMode: targetPayload.mode,
|
||||||
|
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
||||||
|
});
|
||||||
|
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, {
|
||||||
|
...previewPayload,
|
||||||
|
adapterOverrides,
|
||||||
|
});
|
||||||
|
if (!imported) {
|
||||||
|
throw new Error("Import request returned no data.");
|
||||||
|
}
|
||||||
|
let companyUrl: string | undefined;
|
||||||
|
if (!ctx.json) {
|
||||||
|
try {
|
||||||
|
const importedCompany = await ctx.api.get<Company>(`/api/companies/${imported.company.id}`);
|
||||||
|
const issuePrefix = importedCompany?.issuePrefix?.trim();
|
||||||
|
if (issuePrefix) {
|
||||||
|
companyUrl = buildCompanyDashboardUrl(ctx.api.apiBase, issuePrefix);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
companyUrl = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ctx.json) {
|
||||||
|
printOutput(imported, { json: true });
|
||||||
|
} else {
|
||||||
|
printCompanyImportView(
|
||||||
|
"Import Result",
|
||||||
|
renderCompanyImportResult(imported, {
|
||||||
|
targetLabel,
|
||||||
|
companyUrl,
|
||||||
|
infoMessages: adapterMessages,
|
||||||
|
}),
|
||||||
|
{ interactive: interactiveView },
|
||||||
|
);
|
||||||
|
if (interactiveView && companyUrl) {
|
||||||
|
const openImportedCompany = await p.confirm({
|
||||||
|
message: "Open the imported company in your browser?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (!p.isCancel(openImportedCompany) && openImportedCompany) {
|
||||||
|
if (openUrl(companyUrl)) {
|
||||||
|
p.log.info(`Opened ${companyUrl}`);
|
||||||
|
} else {
|
||||||
|
p.log.warn(`Could not open your browser automatically. Open this URL manually:\n${companyUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleCommandError(err);
|
handleCommandError(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
cli/src/commands/client/zip.ts
Normal file
129
cli/src/commands/client/zip.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { inflateRawSync } from "node:zlib";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
export const binaryContentTypeByExtension: Record<string, string> = {
|
||||||
|
".gif": "image/gif",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeArchivePath(pathValue: string) {
|
||||||
|
return pathValue
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
|
||||||
|
const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()];
|
||||||
|
if (!contentType) return textDecoder.decode(bytes);
|
||||||
|
return {
|
||||||
|
encoding: "base64",
|
||||||
|
data: Buffer.from(bytes).toString("base64"),
|
||||||
|
contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
|
||||||
|
if (compressionMethod === 0) return bytes;
|
||||||
|
if (compressionMethod !== 8) {
|
||||||
|
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
|
||||||
|
}
|
||||||
|
return new Uint8Array(inflateRawSync(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
|
||||||
|
rootPath: string | null;
|
||||||
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
|
}> {
|
||||||
|
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
||||||
|
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
|
||||||
|
const archivePath = normalizeArchivePath(rawArchivePath);
|
||||||
|
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
|
||||||
|
if (archivePath && !isDirectoryEntry) {
|
||||||
|
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
|
||||||
|
entries.push({
|
||||||
|
path: archivePath,
|
||||||
|
body: bytesToPortableFileEntry(archivePath, entryBytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
offset = bodyEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
|
||||||
|
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ These define the contract between server, CLI, and UI.
|
||||||
|
|
||||||
| File | What it defines |
|
| File | What it defines |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, companies. |
|
| `packages/shared/src/types/company-portability.ts` | TypeScript interfaces: `CompanyPortabilityManifest`, `CompanyPortabilityFileEntry`, `CompanyPortabilityEnvInput`, export/import/preview request and result types, manifest entry types for agents, skills, projects, issues, recurring routines, companies. |
|
||||||
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
|
| `packages/shared/src/validators/company-portability.ts` | Zod schemas for all portability request/response shapes — used by both server routes and CLI. |
|
||||||
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
|
| `packages/shared/src/types/index.ts` | Re-exports portability types. |
|
||||||
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
|
| `packages/shared/src/validators/index.ts` | Re-exports portability validators. |
|
||||||
|
|
@ -37,7 +37,8 @@ These define the contract between server, CLI, and UI.
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, task recurrence parsing, and package README generation. References `agentcompanies/v1` version string. |
|
| `server/src/services/company-portability.ts` | **Core portability service.** Export (manifest generation, markdown file emission, `.paperclip.yaml` sidecars), import (graph resolution, collision handling, entity creation), preview (planned-action summary). Handles skill key derivation, recurring task <-> routine mapping, legacy recurrence migration, and package README generation. References `agentcompanies/v1` version string. |
|
||||||
|
| `server/src/services/routines.ts` | Paperclip routine runtime service. Portability now exports routines as recurring `TASK.md` entries and imports recurring tasks back through this service. |
|
||||||
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
|
| `server/src/services/company-export-readme.ts` | Generates `README.md` and Mermaid org-chart for exported company packages. |
|
||||||
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
|
| `server/src/services/index.ts` | Re-exports `companyPortabilityService`. |
|
||||||
|
|
||||||
|
|
@ -106,7 +107,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)`
|
||||||
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
|
| `PROJECT.md` frontmatter & body | `company-portability.ts` |
|
||||||
| `TASK.md` frontmatter & body | `company-portability.ts` |
|
| `TASK.md` frontmatter & body | `company-portability.ts` |
|
||||||
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
|
| `SKILL.md` packages | `company-portability.ts`, `company-skills.ts` |
|
||||||
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
|
| `.paperclip.yaml` vendor sidecar | `company-portability.ts`, `routines.ts`, `CompanyExport.tsx`, `company.ts` (CLI) |
|
||||||
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
|
| `manifest.json` | `company-portability.ts` (generation), shared types (schema) |
|
||||||
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
|
| ZIP package format | `zip.ts` (UI), `company.ts` (CLI file I/O) |
|
||||||
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |
|
| Collision resolution | `company-portability.ts` (server), `CompanyImport.tsx` (UI) |
|
||||||
|
|
|
||||||
|
|
@ -860,11 +860,15 @@ Export/import behavior in V1:
|
||||||
|
|
||||||
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
|
- export emits a clean vendor-neutral markdown package plus `.paperclip.yaml`
|
||||||
- projects and starter tasks are opt-in export content rather than default package content
|
- projects and starter tasks are opt-in export content rather than default package content
|
||||||
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication)
|
- recurring `TASK.md` entries use `recurring: true` in the base package and Paperclip routine fidelity in `.paperclip.yaml`
|
||||||
|
- Paperclip imports recurring task packages as routines instead of downgrading them to one-time issues
|
||||||
|
- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable project repo/workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml`
|
||||||
- export never includes secret values; env inputs are reported as portable declarations instead
|
- export never includes secret values; env inputs are reported as portable declarations instead
|
||||||
- import supports target modes:
|
- import supports target modes:
|
||||||
- create a new company
|
- create a new company
|
||||||
- import into an existing company
|
- import into an existing company
|
||||||
|
- import recreates exported project workspaces and remaps portable workspace keys back to target-local workspace ids
|
||||||
|
- import forces imported agent timer heartbeats off so packages never start scheduled runs implicitly
|
||||||
- import supports collision strategies: `rename`, `skip`, `replace`
|
- import supports collision strategies: `rename`, `skip`, `replace`
|
||||||
- import supports preview (dry-run) before apply
|
- import supports preview (dry-run) before apply
|
||||||
- GitHub imports warn on unpinned refs instead of blocking
|
- GitHub imports warn on unpinned refs instead of blocking
|
||||||
|
|
|
||||||
|
|
@ -253,17 +253,7 @@ owner: cto
|
||||||
name: Monday Review
|
name: Monday Review
|
||||||
assignee: ceo
|
assignee: ceo
|
||||||
project: q2-launch
|
project: q2-launch
|
||||||
schedule:
|
recurring: true
|
||||||
timezone: America/Chicago
|
|
||||||
startsAt: 2026-03-16T09:00:00-05:00
|
|
||||||
recurrence:
|
|
||||||
frequency: weekly
|
|
||||||
interval: 1
|
|
||||||
weekdays:
|
|
||||||
- monday
|
|
||||||
time:
|
|
||||||
hour: 9
|
|
||||||
minute: 0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Semantics
|
### Semantics
|
||||||
|
|
@ -271,58 +261,30 @@ schedule:
|
||||||
- body content is the canonical markdown task description
|
- body content is the canonical markdown task description
|
||||||
- `assignee` should reference an agent slug inside the package
|
- `assignee` should reference an agent slug inside the package
|
||||||
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
|
- `project` should reference a project slug when the task belongs to a `PROJECT.md`
|
||||||
- tasks are intentionally basic seed work: title, markdown body, assignee, and optional recurrence
|
- `recurring: true` marks the task as ongoing recurring work instead of a one-time starter task
|
||||||
|
- tasks are intentionally basic seed work: title, markdown body, assignee, project linkage, and optional `recurring: true`
|
||||||
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
|
- tools may also support optional fields like `priority`, `labels`, or `metadata`, but they should not require them in the base package
|
||||||
|
|
||||||
### Scheduling
|
### Recurring Tasks
|
||||||
|
|
||||||
The scheduling model is intentionally lightweight. It should cover common recurring patterns such as:
|
- the base package only needs to say whether a task is recurring
|
||||||
|
- vendors may attach the actual schedule / trigger / runtime fidelity in a vendor extension such as `.paperclip.yaml`
|
||||||
|
- this keeps `TASK.md` portable while still allowing richer runtime systems to round-trip their own automation details
|
||||||
|
- legacy packages may still use `schedule.recurrence` during transition, but exporters should prefer `recurring: true`
|
||||||
|
|
||||||
- every 6 hours
|
Example Paperclip extension:
|
||||||
- every weekday at 9:00
|
|
||||||
- every Monday morning
|
|
||||||
- every month on the 1st
|
|
||||||
- every first Monday of the month
|
|
||||||
- every year on January 1
|
|
||||||
|
|
||||||
Suggested shape:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
schedule:
|
routines:
|
||||||
timezone: America/Chicago
|
monday-review:
|
||||||
startsAt: 2026-03-14T09:00:00-05:00
|
triggers:
|
||||||
recurrence:
|
- kind: schedule
|
||||||
frequency: hourly | daily | weekly | monthly | yearly
|
cronExpression: "0 9 * * 1"
|
||||||
interval: 1
|
timezone: America/Chicago
|
||||||
weekdays:
|
|
||||||
- monday
|
|
||||||
- wednesday
|
|
||||||
monthDays:
|
|
||||||
- 1
|
|
||||||
- 15
|
|
||||||
ordinalWeekdays:
|
|
||||||
- weekday: monday
|
|
||||||
ordinal: 1
|
|
||||||
months:
|
|
||||||
- 1
|
|
||||||
- 6
|
|
||||||
time:
|
|
||||||
hour: 9
|
|
||||||
minute: 0
|
|
||||||
until: 2026-12-31T23:59:59-06:00
|
|
||||||
count: 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
- vendors should ignore unknown recurring-task extensions they do not understand
|
||||||
|
- vendors importing legacy `schedule.recurrence` data may translate it into their own runtime trigger model, but new exports should prefer the simpler `recurring: true` base field
|
||||||
- `timezone` should use an IANA timezone like `America/Chicago`
|
|
||||||
- `startsAt` anchors the first occurrence
|
|
||||||
- `frequency` and `interval` are the only required recurrence fields
|
|
||||||
- `weekdays`, `monthDays`, `ordinalWeekdays`, and `months` are optional narrowing rules
|
|
||||||
- `ordinalWeekdays` uses `ordinal` values like `1`, `2`, `3`, `4`, or `-1` for “last”
|
|
||||||
- `time.hour` and `time.minute` keep common “morning / 9:00 / end of day” scheduling human-readable
|
|
||||||
- `until` and `count` are optional recurrence end bounds
|
|
||||||
- tools may accept richer calendar syntaxes such as RFC5545 `RRULE`, but exporters should prefer the structured form above
|
|
||||||
|
|
||||||
## 11. SKILL.md Compatibility
|
## 11. SKILL.md Compatibility
|
||||||
|
|
||||||
|
|
@ -449,7 +411,7 @@ Suggested import UI behavior:
|
||||||
- selecting an agent auto-selects required docs and referenced skills
|
- selecting an agent auto-selects required docs and referenced skills
|
||||||
- selecting a team auto-selects its subtree
|
- selecting a team auto-selects its subtree
|
||||||
- selecting a project auto-selects its included tasks
|
- selecting a project auto-selects its included tasks
|
||||||
- selecting a recurring task should surface its schedule before import
|
- selecting a recurring task should make it clear that the import target is a routine / automation, not a one-time task
|
||||||
- selecting referenced third-party content shows attribution, license, and fetch policy
|
- selecting referenced third-party content shows attribution, license, and fetch policy
|
||||||
|
|
||||||
## 15. Vendor Extensions
|
## 15. Vendor Extensions
|
||||||
|
|
@ -502,6 +464,12 @@ agents:
|
||||||
kind: plain
|
kind: plain
|
||||||
requirement: optional
|
requirement: optional
|
||||||
default: claude
|
default: claude
|
||||||
|
routines:
|
||||||
|
monday-review:
|
||||||
|
triggers:
|
||||||
|
- kind: schedule
|
||||||
|
cronExpression: "0 9 * * 1"
|
||||||
|
timezone: America/Chicago
|
||||||
```
|
```
|
||||||
|
|
||||||
Additional rules for Paperclip exporters:
|
Additional rules for Paperclip exporters:
|
||||||
|
|
@ -520,7 +488,7 @@ A compliant exporter should:
|
||||||
- omit machine-local ids and timestamps
|
- omit machine-local ids and timestamps
|
||||||
- omit secret values
|
- omit secret values
|
||||||
- omit machine-specific paths
|
- omit machine-specific paths
|
||||||
- preserve task descriptions and recurrence definitions when exporting tasks
|
- preserve task descriptions and recurring-task declarations when exporting tasks
|
||||||
- omit empty/default fields
|
- omit empty/default fields
|
||||||
- default to the vendor-neutral base package
|
- default to the vendor-neutral base package
|
||||||
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
|
- Paperclip exporters should emit `.paperclip.yaml` as a sidecar by default
|
||||||
|
|
@ -569,11 +537,11 @@ Paperclip can map this spec to its runtime model like this:
|
||||||
- `TEAM.md` -> importable org subtree
|
- `TEAM.md` -> importable org subtree
|
||||||
- `AGENTS.md` -> agent identity and instructions
|
- `AGENTS.md` -> agent identity and instructions
|
||||||
- `PROJECT.md` -> starter project definition
|
- `PROJECT.md` -> starter project definition
|
||||||
- `TASK.md` -> starter issue/task definition, or automation template when recurrence is present
|
- `TASK.md` -> starter issue/task definition, or recurring task template when `recurring: true`
|
||||||
- `SKILL.md` -> imported skill package
|
- `SKILL.md` -> imported skill package
|
||||||
- `sources[]` -> provenance and pinned upstream refs
|
- `sources[]` -> provenance and pinned upstream refs
|
||||||
- Paperclip extension:
|
- Paperclip extension:
|
||||||
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, and other Paperclip-specific fidelity
|
- `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, routine triggers, and other Paperclip-specific fidelity
|
||||||
|
|
||||||
Inline Paperclip-only metadata that must live inside a shared markdown file should use:
|
Inline Paperclip-only metadata that must live inside a shared markdown file should use:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,8 @@
|
||||||
"guides/board-operator/managing-tasks",
|
"guides/board-operator/managing-tasks",
|
||||||
"guides/board-operator/approvals",
|
"guides/board-operator/approvals",
|
||||||
"guides/board-operator/costs-and-budgets",
|
"guides/board-operator/costs-and-budgets",
|
||||||
"guides/board-operator/activity-log"
|
"guides/board-operator/activity-log",
|
||||||
|
"guides/board-operator/importing-and-exporting"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
203
docs/guides/board-operator/importing-and-exporting.md
Normal file
203
docs/guides/board-operator/importing-and-exporting.md
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
---
|
||||||
|
title: Importing & Exporting Companies
|
||||||
|
summary: Export companies to portable packages and import them from local paths or GitHub
|
||||||
|
---
|
||||||
|
|
||||||
|
Paperclip companies can be exported to portable markdown packages and imported from local directories or GitHub repositories. This lets you share company configurations, duplicate setups, and version-control your agent teams.
|
||||||
|
|
||||||
|
## Package Format
|
||||||
|
|
||||||
|
Exported packages follow the [Agent Companies specification](/companies/companies-spec) and use a markdown-first structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-company/
|
||||||
|
├── COMPANY.md # Company metadata
|
||||||
|
├── agents/
|
||||||
|
│ ├── ceo/AGENT.md # Agent instructions + frontmatter
|
||||||
|
│ └── cto/AGENT.md
|
||||||
|
├── projects/
|
||||||
|
│ └── main/PROJECT.md
|
||||||
|
├── skills/
|
||||||
|
│ └── review/SKILL.md
|
||||||
|
├── tasks/
|
||||||
|
│ └── onboarding/TASK.md
|
||||||
|
└── .paperclip.yaml # Adapter config, env inputs, routines
|
||||||
|
```
|
||||||
|
|
||||||
|
- **COMPANY.md** defines company name, description, and metadata.
|
||||||
|
- **AGENT.md** files contain agent identity, role, and instructions.
|
||||||
|
- **SKILL.md** files are compatible with the Agent Skills ecosystem.
|
||||||
|
- **.paperclip.yaml** holds Paperclip-specific config (adapter types, env inputs, budgets) as an optional sidecar.
|
||||||
|
|
||||||
|
## Exporting a Company
|
||||||
|
|
||||||
|
Export a company into a portable folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paperclipai company export <company-id> --out ./my-export
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `--out <path>` | Output directory (required) | — |
|
||||||
|
| `--include <values>` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | `company,agents` |
|
||||||
|
| `--skills <values>` | Export only specific skill slugs | all |
|
||||||
|
| `--projects <values>` | Export only specific project shortnames or IDs | all |
|
||||||
|
| `--issues <values>` | Export specific issue identifiers or IDs | none |
|
||||||
|
| `--project-issues <values>` | Export issues belonging to specific projects | none |
|
||||||
|
| `--expand-referenced-skills` | Vendor skill file contents instead of keeping upstream references | `false` |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Export company with agents and projects
|
||||||
|
paperclipai company export abc123 --out ./backup --include company,agents,projects
|
||||||
|
|
||||||
|
# Export everything including tasks and skills
|
||||||
|
paperclipai company export abc123 --out ./full-export --include company,agents,projects,tasks,skills
|
||||||
|
|
||||||
|
# Export only specific skills
|
||||||
|
paperclipai company export abc123 --out ./skills-only --include skills --skills review,deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Gets Exported
|
||||||
|
|
||||||
|
- Company name, description, and metadata
|
||||||
|
- Agent names, roles, reporting structure, and instructions
|
||||||
|
- Project definitions and workspace config
|
||||||
|
- Task/issue descriptions (when included)
|
||||||
|
- Skill packages (as references or vendored content)
|
||||||
|
- Adapter type and env input declarations in `.paperclip.yaml`
|
||||||
|
|
||||||
|
Secret values, machine-local paths, and database IDs are **never** exported.
|
||||||
|
|
||||||
|
## Importing a Company
|
||||||
|
|
||||||
|
Import from a local directory, GitHub URL, or GitHub shorthand:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# From a local folder
|
||||||
|
paperclipai company import ./my-export
|
||||||
|
|
||||||
|
# From a GitHub URL
|
||||||
|
paperclipai company import https://github.com/org/repo
|
||||||
|
|
||||||
|
# From a GitHub subfolder
|
||||||
|
paperclipai company import https://github.com/org/repo/tree/main/companies/acme
|
||||||
|
|
||||||
|
# From GitHub shorthand
|
||||||
|
paperclipai company import org/repo
|
||||||
|
paperclipai company import org/repo/companies/acme
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `--target <mode>` | `new` (create a new company) or `existing` (merge into existing) | inferred from context |
|
||||||
|
| `--company-id <id>` | Target company ID for `--target existing` | current context |
|
||||||
|
| `--new-company-name <name>` | Override company name for `--target new` | from package |
|
||||||
|
| `--include <values>` | Comma-separated set: `company`, `agents`, `projects`, `issues`, `tasks`, `skills` | auto-detected |
|
||||||
|
| `--agents <list>` | Comma-separated agent slugs to import, or `all` | `all` |
|
||||||
|
| `--collision <mode>` | How to handle name conflicts: `rename`, `skip`, or `replace` | `rename` |
|
||||||
|
| `--ref <value>` | Git ref for GitHub imports (branch, tag, or commit) | default branch |
|
||||||
|
| `--dry-run` | Preview what would be imported without applying | `false` |
|
||||||
|
| `--yes` | Skip the interactive confirmation prompt | `false` |
|
||||||
|
| `--json` | Output result as JSON | `false` |
|
||||||
|
|
||||||
|
### Target Modes
|
||||||
|
|
||||||
|
- **`new`** — Creates a fresh company from the package. Good for duplicating a company template.
|
||||||
|
- **`existing`** — Merges the package into an existing company. Use `--company-id` to specify the target.
|
||||||
|
|
||||||
|
If `--target` is not specified, Paperclip infers it: if a `--company-id` is provided (or one exists in context), it defaults to `existing`; otherwise `new`.
|
||||||
|
|
||||||
|
### Collision Strategies
|
||||||
|
|
||||||
|
When importing into an existing company, agent or project names may conflict with existing ones:
|
||||||
|
|
||||||
|
- **`rename`** (default) — Appends a suffix to avoid conflicts (e.g., `ceo` becomes `ceo-2`).
|
||||||
|
- **`skip`** — Skips entities that already exist.
|
||||||
|
- **`replace`** — Overwrites existing entities. Only available for non-safe imports (not available through the CEO API).
|
||||||
|
|
||||||
|
### Interactive Selection
|
||||||
|
|
||||||
|
When running interactively (no `--yes` or `--json` flags), the import command shows a selection picker before applying. You can choose exactly which agents, projects, skills, and tasks to import using a checkbox interface.
|
||||||
|
|
||||||
|
### Preview Before Applying
|
||||||
|
|
||||||
|
Always preview first with `--dry-run`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paperclipai company import org/repo --target existing --company-id abc123 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
The preview shows:
|
||||||
|
- **Package contents** — How many agents, projects, tasks, and skills are in the source
|
||||||
|
- **Import plan** — What will be created, renamed, skipped, or replaced
|
||||||
|
- **Env inputs** — Environment variables that may need values after import
|
||||||
|
- **Warnings** — Potential issues like missing skills or unresolved references
|
||||||
|
|
||||||
|
Imported agents always land with timer heartbeats disabled. Assignment/on-demand wake behavior from the package is preserved, but scheduled runs stay off until a board operator re-enables them.
|
||||||
|
|
||||||
|
### Common Workflows
|
||||||
|
|
||||||
|
**Clone a company template from GitHub:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paperclipai company import org/company-templates/engineering-team \
|
||||||
|
--target new \
|
||||||
|
--new-company-name "My Engineering Team"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add agents from a package into your existing company:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paperclipai company import ./shared-agents \
|
||||||
|
--target existing \
|
||||||
|
--company-id abc123 \
|
||||||
|
--include agents \
|
||||||
|
--collision rename
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import a specific branch or tag:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paperclipai company import org/repo --ref v2.0.0 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Non-interactive import (CI/scripts):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
paperclipai company import ./package \
|
||||||
|
--target new \
|
||||||
|
--yes \
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The CLI commands use these API endpoints under the hood:
|
||||||
|
|
||||||
|
| Action | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| Export company | `POST /api/companies/{companyId}/export` |
|
||||||
|
| Preview import (existing company) | `POST /api/companies/{companyId}/imports/preview` |
|
||||||
|
| Apply import (existing company) | `POST /api/companies/{companyId}/imports/apply` |
|
||||||
|
| Preview import (new company) | `POST /api/companies/import/preview` |
|
||||||
|
| Apply import (new company) | `POST /api/companies/import` |
|
||||||
|
|
||||||
|
CEO agents can also use the safe import routes (`/imports/preview` and `/imports/apply`) which enforce non-destructive rules: `replace` is rejected, collisions resolve with `rename` or `skip`, and issues are always created as new.
|
||||||
|
|
||||||
|
## GitHub Sources
|
||||||
|
|
||||||
|
Paperclip supports several GitHub URL formats:
|
||||||
|
|
||||||
|
- Full URL: `https://github.com/org/repo`
|
||||||
|
- Subfolder URL: `https://github.com/org/repo/tree/main/path/to/company`
|
||||||
|
- Shorthand: `org/repo`
|
||||||
|
- Shorthand with path: `org/repo/path/to/company`
|
||||||
|
|
||||||
|
Use `--ref` to pin to a specific branch, tag, or commit hash when importing from GitHub.
|
||||||
|
|
@ -253,9 +253,13 @@ export type {
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
|
CompanyPortabilityProjectWorkspaceManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
|
|
@ -484,6 +488,7 @@ export {
|
||||||
portabilityIncludeSchema,
|
portabilityIncludeSchema,
|
||||||
portabilityEnvInputSchema,
|
portabilityEnvInputSchema,
|
||||||
portabilityCompanyManifestEntrySchema,
|
portabilityCompanyManifestEntrySchema,
|
||||||
|
portabilitySidebarOrderSchema,
|
||||||
portabilityAgentManifestEntrySchema,
|
portabilityAgentManifestEntrySchema,
|
||||||
portabilityManifestSchema,
|
portabilityManifestSchema,
|
||||||
portabilitySourceSchema,
|
portabilitySourceSchema,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ export interface CompanyPortabilityCompanyManifestEntry {
|
||||||
requireBoardApprovalForNewAgents: boolean;
|
requireBoardApprovalForNewAgents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilitySidebarOrder {
|
||||||
|
agents: string[];
|
||||||
|
projects: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityProjectManifestEntry {
|
export interface CompanyPortabilityProjectManifestEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -44,18 +49,52 @@ export interface CompanyPortabilityProjectManifestEntry {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||||
|
workspaces: CompanyPortabilityProjectWorkspaceManifestEntry[];
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityProjectWorkspaceManifestEntry {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
sourceType: string | null;
|
||||||
|
repoUrl: string | null;
|
||||||
|
repoRef: string | null;
|
||||||
|
defaultRef: string | null;
|
||||||
|
visibility: string | null;
|
||||||
|
setupCommand: string | null;
|
||||||
|
cleanupCommand: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityIssueRoutineTriggerManifestEntry {
|
||||||
|
kind: string;
|
||||||
|
label: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
cronExpression: string | null;
|
||||||
|
timezone: string | null;
|
||||||
|
signingMode: string | null;
|
||||||
|
replayWindowSec: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyPortabilityIssueRoutineManifestEntry {
|
||||||
|
concurrencyPolicy: string | null;
|
||||||
|
catchUpPolicy: string | null;
|
||||||
|
triggers: CompanyPortabilityIssueRoutineTriggerManifestEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityIssueManifestEntry {
|
export interface CompanyPortabilityIssueManifestEntry {
|
||||||
slug: string;
|
slug: string;
|
||||||
identifier: string | null;
|
identifier: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
projectSlug: string | null;
|
projectSlug: string | null;
|
||||||
|
projectWorkspaceKey: string | null;
|
||||||
assigneeAgentSlug: string | null;
|
assigneeAgentSlug: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
recurrence: Record<string, unknown> | null;
|
recurring: boolean;
|
||||||
|
routine: CompanyPortabilityIssueRoutineManifestEntry | null;
|
||||||
|
legacyRecurrence: Record<string, unknown> | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
|
|
@ -110,6 +149,7 @@ export interface CompanyPortabilityManifest {
|
||||||
} | null;
|
} | null;
|
||||||
includes: CompanyPortabilityInclude;
|
includes: CompanyPortabilityInclude;
|
||||||
company: CompanyPortabilityCompanyManifestEntry | null;
|
company: CompanyPortabilityCompanyManifestEntry | null;
|
||||||
|
sidebar: CompanyPortabilitySidebarOrder | null;
|
||||||
agents: CompanyPortabilityAgentManifestEntry[];
|
agents: CompanyPortabilityAgentManifestEntry[];
|
||||||
skills: CompanyPortabilitySkillManifestEntry[];
|
skills: CompanyPortabilitySkillManifestEntry[];
|
||||||
projects: CompanyPortabilityProjectManifestEntry[];
|
projects: CompanyPortabilityProjectManifestEntry[];
|
||||||
|
|
@ -245,6 +285,13 @@ export interface CompanyPortabilityImportResult {
|
||||||
name: string;
|
name: string;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
projects: {
|
||||||
|
slug: string;
|
||||||
|
id: string | null;
|
||||||
|
action: "created" | "updated" | "skipped";
|
||||||
|
name: string;
|
||||||
|
reason: string | null;
|
||||||
|
}[];
|
||||||
envInputs: CompanyPortabilityEnvInput[];
|
envInputs: CompanyPortabilityEnvInput[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
@ -258,4 +305,5 @@ export interface CompanyPortabilityExportRequest {
|
||||||
projectIssues?: string[];
|
projectIssues?: string[];
|
||||||
selectedFiles?: string[];
|
selectedFiles?: string[];
|
||||||
expandReferencedSkills?: boolean;
|
expandReferencedSkills?: boolean;
|
||||||
|
sidebarOrder?: Partial<CompanyPortabilitySidebarOrder>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,13 @@ export type {
|
||||||
CompanyPortabilityEnvInput,
|
CompanyPortabilityEnvInput,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityCompanyManifestEntry,
|
CompanyPortabilityCompanyManifestEntry,
|
||||||
|
CompanyPortabilitySidebarOrder,
|
||||||
CompanyPortabilityAgentManifestEntry,
|
CompanyPortabilityAgentManifestEntry,
|
||||||
CompanyPortabilitySkillManifestEntry,
|
CompanyPortabilitySkillManifestEntry,
|
||||||
CompanyPortabilityProjectManifestEntry,
|
CompanyPortabilityProjectManifestEntry,
|
||||||
|
CompanyPortabilityProjectWorkspaceManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineTriggerManifestEntry,
|
||||||
|
CompanyPortabilityIssueRoutineManifestEntry,
|
||||||
CompanyPortabilityIssueManifestEntry,
|
CompanyPortabilityIssueManifestEntry,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ export const portabilityCompanyManifestEntrySchema = z.object({
|
||||||
requireBoardApprovalForNewAgents: z.boolean(),
|
requireBoardApprovalForNewAgents: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const portabilitySidebarOrderSchema = z.object({
|
||||||
|
agents: z.array(z.string().min(1)).default([]),
|
||||||
|
projects: z.array(z.string().min(1)).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export const portabilityAgentManifestEntrySchema = z.object({
|
export const portabilityAgentManifestEntrySchema = z.object({
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|
@ -85,18 +90,50 @@ export const portabilityProjectManifestEntrySchema = z.object({
|
||||||
color: z.string().nullable(),
|
color: z.string().nullable(),
|
||||||
status: z.string().nullable(),
|
status: z.string().nullable(),
|
||||||
executionWorkspacePolicy: z.record(z.unknown()).nullable(),
|
executionWorkspacePolicy: z.record(z.unknown()).nullable(),
|
||||||
|
workspaces: z.array(z.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
sourceType: z.string().nullable(),
|
||||||
|
repoUrl: z.string().nullable(),
|
||||||
|
repoRef: z.string().nullable(),
|
||||||
|
defaultRef: z.string().nullable(),
|
||||||
|
visibility: z.string().nullable(),
|
||||||
|
setupCommand: z.string().nullable(),
|
||||||
|
cleanupCommand: z.string().nullable(),
|
||||||
|
metadata: z.record(z.unknown()).nullable(),
|
||||||
|
isPrimary: z.boolean(),
|
||||||
|
})).default([]),
|
||||||
metadata: z.record(z.unknown()).nullable(),
|
metadata: z.record(z.unknown()).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
|
||||||
|
kind: z.string().min(1),
|
||||||
|
label: z.string().nullable(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
cronExpression: z.string().nullable(),
|
||||||
|
timezone: z.string().nullable(),
|
||||||
|
signingMode: z.string().nullable(),
|
||||||
|
replayWindowSec: z.number().int().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portabilityIssueRoutineManifestEntrySchema = z.object({
|
||||||
|
concurrencyPolicy: z.string().nullable(),
|
||||||
|
catchUpPolicy: z.string().nullable(),
|
||||||
|
triggers: z.array(portabilityIssueRoutineTriggerManifestEntrySchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export const portabilityIssueManifestEntrySchema = z.object({
|
export const portabilityIssueManifestEntrySchema = z.object({
|
||||||
slug: z.string().min(1),
|
slug: z.string().min(1),
|
||||||
identifier: z.string().min(1).nullable(),
|
identifier: z.string().min(1).nullable(),
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
path: z.string().min(1),
|
path: z.string().min(1),
|
||||||
projectSlug: z.string().min(1).nullable(),
|
projectSlug: z.string().min(1).nullable(),
|
||||||
|
projectWorkspaceKey: z.string().min(1).nullable(),
|
||||||
assigneeAgentSlug: z.string().min(1).nullable(),
|
assigneeAgentSlug: z.string().min(1).nullable(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
recurrence: z.record(z.unknown()).nullable(),
|
recurring: z.boolean().default(false),
|
||||||
|
routine: portabilityIssueRoutineManifestEntrySchema.nullable(),
|
||||||
|
legacyRecurrence: z.record(z.unknown()).nullable(),
|
||||||
status: z.string().nullable(),
|
status: z.string().nullable(),
|
||||||
priority: z.string().nullable(),
|
priority: z.string().nullable(),
|
||||||
labelIds: z.array(z.string().min(1)).default([]),
|
labelIds: z.array(z.string().min(1)).default([]),
|
||||||
|
|
@ -123,6 +160,7 @@ export const portabilityManifestSchema = z.object({
|
||||||
skills: z.boolean(),
|
skills: z.boolean(),
|
||||||
}),
|
}),
|
||||||
company: portabilityCompanyManifestEntrySchema.nullable(),
|
company: portabilityCompanyManifestEntrySchema.nullable(),
|
||||||
|
sidebar: portabilitySidebarOrderSchema.nullable(),
|
||||||
agents: z.array(portabilityAgentManifestEntrySchema),
|
agents: z.array(portabilityAgentManifestEntrySchema),
|
||||||
skills: z.array(portabilitySkillManifestEntrySchema).default([]),
|
skills: z.array(portabilitySkillManifestEntrySchema).default([]),
|
||||||
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
|
projects: z.array(portabilityProjectManifestEntrySchema).default([]),
|
||||||
|
|
@ -169,6 +207,7 @@ export const companyPortabilityExportSchema = z.object({
|
||||||
projectIssues: z.array(z.string().min(1)).optional(),
|
projectIssues: z.array(z.string().min(1)).optional(),
|
||||||
selectedFiles: z.array(z.string().min(1)).optional(),
|
selectedFiles: z.array(z.string().min(1)).optional(),
|
||||||
expandReferencedSkills: z.boolean().optional(),
|
expandReferencedSkills: z.boolean().optional(),
|
||||||
|
sidebarOrder: portabilitySidebarOrderSchema.partial().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
|
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export {
|
||||||
portabilityIncludeSchema,
|
portabilityIncludeSchema,
|
||||||
portabilityEnvInputSchema,
|
portabilityEnvInputSchema,
|
||||||
portabilityCompanyManifestEntrySchema,
|
portabilityCompanyManifestEntrySchema,
|
||||||
|
portabilitySidebarOrderSchema,
|
||||||
portabilityAgentManifestEntrySchema,
|
portabilityAgentManifestEntrySchema,
|
||||||
portabilitySkillManifestEntrySchema,
|
portabilitySkillManifestEntrySchema,
|
||||||
portabilityManifestSchema,
|
portabilityManifestSchema,
|
||||||
|
|
|
||||||
364
scripts/generate-company-assets.ts
Normal file
364
scripts/generate-company-assets.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Generate org chart images and READMEs for agent company packages.
|
||||||
|
*
|
||||||
|
* Reads company packages from a directory, builds manifest-like data,
|
||||||
|
* then uses the existing server-side SVG renderer (sharp, no browser)
|
||||||
|
* and README generator.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/generate-company-assets.ts /path/to/companies-repo
|
||||||
|
*
|
||||||
|
* Processes each subdirectory that contains a COMPANY.md file.
|
||||||
|
*/
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import { renderOrgChartPng, type OrgNode, type OrgChartOverlay } from "../server/src/routes/org-chart-svg.js";
|
||||||
|
import { generateReadme } from "../server/src/services/company-export-readme.js";
|
||||||
|
import type { CompanyPortabilityManifest } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
// ── YAML frontmatter parser (minimal, no deps) ──────────────────
|
||||||
|
|
||||||
|
function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
|
||||||
|
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!match) return { data: {}, body: content };
|
||||||
|
const yamlStr = match[1];
|
||||||
|
const body = match[2];
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
let currentValue: string | string[] | null = null;
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
for (const line of yamlStr.split("\n")) {
|
||||||
|
// List item
|
||||||
|
if (inList && /^\s+-\s+/.test(line)) {
|
||||||
|
const val = line.replace(/^\s+-\s+/, "").trim();
|
||||||
|
(currentValue as string[]).push(val);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save previous key
|
||||||
|
if (currentKey !== null && currentValue !== null) {
|
||||||
|
data[currentKey] = currentValue;
|
||||||
|
}
|
||||||
|
inList = false;
|
||||||
|
|
||||||
|
// Key: value line
|
||||||
|
const kvMatch = line.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
||||||
|
if (kvMatch) {
|
||||||
|
currentKey = kvMatch[1];
|
||||||
|
let val = kvMatch[2].trim();
|
||||||
|
|
||||||
|
if (val === "" || val === ">") {
|
||||||
|
// Could be a multi-line value or list — peek ahead handled by next iterations
|
||||||
|
currentValue = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val === "null" || val === "~") {
|
||||||
|
currentValue = null;
|
||||||
|
data[currentKey] = null;
|
||||||
|
currentKey = null;
|
||||||
|
currentValue = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove surrounding quotes
|
||||||
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||||
|
val = val.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValue = val;
|
||||||
|
} else if (currentKey !== null && line.match(/^\s+-\s+/)) {
|
||||||
|
// Start of list
|
||||||
|
inList = true;
|
||||||
|
currentValue = [];
|
||||||
|
const val = line.replace(/^\s+-\s+/, "").trim();
|
||||||
|
(currentValue as string[]).push(val);
|
||||||
|
} else if (currentKey !== null && line.match(/^\s+\S/)) {
|
||||||
|
// Continuation of multi-line scalar
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (typeof currentValue === "string") {
|
||||||
|
currentValue = currentValue ? `${currentValue} ${trimmed}` : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save last key
|
||||||
|
if (currentKey !== null && currentValue !== null) {
|
||||||
|
data[currentKey] = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slug to role mapping ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const SLUG_TO_ROLE: Record<string, string> = {
|
||||||
|
ceo: "ceo",
|
||||||
|
cto: "cto",
|
||||||
|
cmo: "cmo",
|
||||||
|
cfo: "cfo",
|
||||||
|
coo: "coo",
|
||||||
|
};
|
||||||
|
|
||||||
|
function inferRole(slug: string, title: string | null): string {
|
||||||
|
// Check direct slug match first
|
||||||
|
if (SLUG_TO_ROLE[slug]) return SLUG_TO_ROLE[slug];
|
||||||
|
|
||||||
|
// Check title for C-suite
|
||||||
|
const t = (title || "").toLowerCase();
|
||||||
|
if (t.includes("chief executive")) return "ceo";
|
||||||
|
if (t.includes("chief technology")) return "cto";
|
||||||
|
if (t.includes("chief marketing")) return "cmo";
|
||||||
|
if (t.includes("chief financial")) return "cfo";
|
||||||
|
if (t.includes("chief operating")) return "coo";
|
||||||
|
if (t.includes("vp") || t.includes("vice president")) return "vp";
|
||||||
|
if (t.includes("manager")) return "manager";
|
||||||
|
if (t.includes("qa") || t.includes("quality")) return "engineer";
|
||||||
|
|
||||||
|
// Default to engineer
|
||||||
|
return "engineer";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse a company package directory ────────────────────────────
|
||||||
|
|
||||||
|
interface CompanyPackage {
|
||||||
|
dir: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
slug: string;
|
||||||
|
agents: CompanyPortabilityManifest["agents"];
|
||||||
|
skills: CompanyPortabilityManifest["skills"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCompanyPackage(companyDir: string): CompanyPackage | null {
|
||||||
|
const companyMdPath = path.join(companyDir, "COMPANY.md");
|
||||||
|
if (!fs.existsSync(companyMdPath)) return null;
|
||||||
|
|
||||||
|
const companyMd = fs.readFileSync(companyMdPath, "utf-8");
|
||||||
|
const { data: companyData } = parseFrontmatter(companyMd);
|
||||||
|
|
||||||
|
const name = (companyData.name as string) || path.basename(companyDir);
|
||||||
|
const description = (companyData.description as string) || null;
|
||||||
|
const slug = (companyData.slug as string) || path.basename(companyDir);
|
||||||
|
|
||||||
|
// Parse agents
|
||||||
|
const agentsDir = path.join(companyDir, "agents");
|
||||||
|
const agents: CompanyPortabilityManifest["agents"] = [];
|
||||||
|
if (fs.existsSync(agentsDir)) {
|
||||||
|
for (const agentSlug of fs.readdirSync(agentsDir)) {
|
||||||
|
const agentMdName = fs.existsSync(path.join(agentsDir, agentSlug, "AGENT.md"))
|
||||||
|
? "AGENT.md"
|
||||||
|
: fs.existsSync(path.join(agentsDir, agentSlug, "AGENTS.md"))
|
||||||
|
? "AGENTS.md"
|
||||||
|
: null;
|
||||||
|
if (!agentMdName) continue;
|
||||||
|
const agentMdPath = path.join(agentsDir, agentSlug, agentMdName);
|
||||||
|
|
||||||
|
const agentMd = fs.readFileSync(agentMdPath, "utf-8");
|
||||||
|
const { data: agentData } = parseFrontmatter(agentMd);
|
||||||
|
|
||||||
|
const agentName = (agentData.name as string) || agentSlug;
|
||||||
|
const title = (agentData.title as string) || null;
|
||||||
|
const reportsTo = agentData.reportsTo as string | null;
|
||||||
|
const skills = (agentData.skills as string[]) || [];
|
||||||
|
const role = inferRole(agentSlug, title);
|
||||||
|
|
||||||
|
agents.push({
|
||||||
|
slug: agentSlug,
|
||||||
|
name: agentName,
|
||||||
|
path: `agents/${agentSlug}/${agentMdName}`,
|
||||||
|
skills,
|
||||||
|
role,
|
||||||
|
title,
|
||||||
|
icon: null,
|
||||||
|
capabilities: null,
|
||||||
|
reportsToSlug: reportsTo || null,
|
||||||
|
adapterType: "claude_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
metadata: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse skills
|
||||||
|
const skillsDir = path.join(companyDir, "skills");
|
||||||
|
const skills: CompanyPortabilityManifest["skills"] = [];
|
||||||
|
if (fs.existsSync(skillsDir)) {
|
||||||
|
for (const skillSlug of fs.readdirSync(skillsDir)) {
|
||||||
|
const skillMdPath = path.join(skillsDir, skillSlug, "SKILL.md");
|
||||||
|
if (!fs.existsSync(skillMdPath)) continue;
|
||||||
|
|
||||||
|
const skillMd = fs.readFileSync(skillMdPath, "utf-8");
|
||||||
|
const { data: skillData } = parseFrontmatter(skillMd);
|
||||||
|
|
||||||
|
const skillName = (skillData.name as string) || skillSlug;
|
||||||
|
const skillDesc = (skillData.description as string) || null;
|
||||||
|
|
||||||
|
// Extract source info from metadata
|
||||||
|
let sourceType = "local";
|
||||||
|
let sourceLocator: string | null = null;
|
||||||
|
const metadata = skillData.metadata as Record<string, unknown> | undefined;
|
||||||
|
if (metadata) {
|
||||||
|
// metadata.sources is parsed as a nested structure, but our simple parser
|
||||||
|
// doesn't handle it well. Check for github repo in the raw SKILL.md instead.
|
||||||
|
const repoMatch = skillMd.match(/repo:\s*(.+)/);
|
||||||
|
const pathMatch = skillMd.match(/path:\s*(.+)/);
|
||||||
|
if (repoMatch) {
|
||||||
|
sourceType = "github";
|
||||||
|
const repo = repoMatch[1].trim();
|
||||||
|
const filePath = pathMatch ? pathMatch[1].trim() : "";
|
||||||
|
sourceLocator = `https://github.com/${repo}/blob/main/${filePath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skills.push({
|
||||||
|
key: skillSlug,
|
||||||
|
slug: skillSlug,
|
||||||
|
name: skillName,
|
||||||
|
path: `skills/${skillSlug}/SKILL.md`,
|
||||||
|
description: skillDesc,
|
||||||
|
sourceType,
|
||||||
|
sourceLocator,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: null,
|
||||||
|
compatibility: null,
|
||||||
|
metadata: null,
|
||||||
|
fileInventory: [{ path: `skills/${skillSlug}/SKILL.md`, kind: "skill" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dir: companyDir, name, description, slug, agents, skills };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build OrgNode tree from agents ───────────────────────────────
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
ceo: "Chief Executive",
|
||||||
|
cto: "Technology",
|
||||||
|
cmo: "Marketing",
|
||||||
|
cfo: "Finance",
|
||||||
|
coo: "Operations",
|
||||||
|
vp: "VP",
|
||||||
|
manager: "Manager",
|
||||||
|
engineer: "Engineer",
|
||||||
|
agent: "Agent",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildOrgTree(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
|
||||||
|
const bySlug = new Map(agents.map((a) => [a.slug, a]));
|
||||||
|
const childrenOf = new Map<string | null, typeof agents>();
|
||||||
|
for (const a of agents) {
|
||||||
|
const parent = a.reportsToSlug ?? null;
|
||||||
|
const list = childrenOf.get(parent) ?? [];
|
||||||
|
list.push(a);
|
||||||
|
childrenOf.set(parent, list);
|
||||||
|
}
|
||||||
|
const build = (parentSlug: string | null): OrgNode[] => {
|
||||||
|
const members = childrenOf.get(parentSlug) ?? [];
|
||||||
|
return members.map((m) => ({
|
||||||
|
id: m.slug,
|
||||||
|
name: m.name,
|
||||||
|
role: ROLE_LABELS[m.role] ?? m.role,
|
||||||
|
status: "active",
|
||||||
|
reports: build(m.slug),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
const roots = agents.filter((a) => !a.reportsToSlug || !bySlug.has(a.reportsToSlug));
|
||||||
|
const tree = build(null);
|
||||||
|
for (const root of roots) {
|
||||||
|
if (root.reportsToSlug && !bySlug.has(root.reportsToSlug)) {
|
||||||
|
tree.push({
|
||||||
|
id: root.slug,
|
||||||
|
name: root.name,
|
||||||
|
role: ROLE_LABELS[root.role] ?? root.role,
|
||||||
|
status: "active",
|
||||||
|
reports: build(root.slug),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const companiesDir = process.argv[2];
|
||||||
|
if (!companiesDir) {
|
||||||
|
console.error("Usage: npx tsx scripts/generate-company-assets.ts <companies-dir>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDir = path.resolve(companiesDir);
|
||||||
|
if (!fs.existsSync(resolvedDir)) {
|
||||||
|
console.error(`Directory not found: ${resolvedDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
||||||
|
let processed = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const companyDir = path.join(resolvedDir, entry.name);
|
||||||
|
const pkg = parseCompanyPackage(companyDir);
|
||||||
|
if (!pkg) continue;
|
||||||
|
|
||||||
|
console.log(`\n── ${pkg.name} (${pkg.slug}) ──`);
|
||||||
|
console.log(` ${pkg.agents.length} agents, ${pkg.skills.length} skills`);
|
||||||
|
|
||||||
|
// Generate org chart PNG
|
||||||
|
if (pkg.agents.length > 0) {
|
||||||
|
const orgTree = buildOrgTree(pkg.agents);
|
||||||
|
console.log(` Org tree roots: ${orgTree.map((n) => n.name).join(", ")}`);
|
||||||
|
|
||||||
|
const overlay: OrgChartOverlay = {
|
||||||
|
companyName: pkg.name,
|
||||||
|
stats: `Agents: ${pkg.agents.length}, Skills: ${pkg.skills.length}`,
|
||||||
|
};
|
||||||
|
const pngBuffer = await renderOrgChartPng(orgTree, "warmth", overlay);
|
||||||
|
const imagesDir = path.join(companyDir, "images");
|
||||||
|
fs.mkdirSync(imagesDir, { recursive: true });
|
||||||
|
const pngPath = path.join(imagesDir, "org-chart.png");
|
||||||
|
fs.writeFileSync(pngPath, pngBuffer);
|
||||||
|
console.log(` ✓ ${path.relative(resolvedDir, pngPath)} (${(pngBuffer.length / 1024).toFixed(1)}kb)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate README
|
||||||
|
const manifest: CompanyPortabilityManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
source: null,
|
||||||
|
includes: { company: true, agents: true, projects: false, issues: false, skills: true },
|
||||||
|
company: null,
|
||||||
|
agents: pkg.agents,
|
||||||
|
skills: pkg.skills,
|
||||||
|
projects: [],
|
||||||
|
issues: [],
|
||||||
|
envInputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const readme = generateReadme(manifest, {
|
||||||
|
companyName: pkg.name,
|
||||||
|
companyDescription: pkg.description,
|
||||||
|
});
|
||||||
|
const readmePath = path.join(companyDir, "README.md");
|
||||||
|
fs.writeFileSync(readmePath, readme);
|
||||||
|
console.log(` ✓ ${path.relative(resolvedDir, readmePath)}`);
|
||||||
|
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✓ Processed ${processed} companies.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
|
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
|
||||||
|
|
@ -25,6 +29,8 @@ const projectSvc = {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
createWorkspace: vi.fn(),
|
||||||
|
listWorkspaces: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const issueSvc = {
|
const issueSvc = {
|
||||||
|
|
@ -34,6 +40,13 @@ const issueSvc = {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const routineSvc = {
|
||||||
|
list: vi.fn(),
|
||||||
|
getDetail: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
createTrigger: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const companySkillSvc = {
|
const companySkillSvc = {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
listFull: vi.fn(),
|
listFull: vi.fn(),
|
||||||
|
|
@ -71,6 +84,10 @@ vi.mock("../services/issues.js", () => ({
|
||||||
issueService: () => issueSvc,
|
issueService: () => issueSvc,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/routines.js", () => ({
|
||||||
|
routineService: () => routineSvc,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../services/company-skills.js", () => ({
|
vi.mock("../services/company-skills.js", () => ({
|
||||||
companySkillService: () => companySkillSvc,
|
companySkillService: () => companySkillSvc,
|
||||||
}));
|
}));
|
||||||
|
|
@ -184,9 +201,62 @@ describe("company portability", () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
projectSvc.list.mockResolvedValue([]);
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.createWorkspace.mockResolvedValue(null);
|
||||||
|
projectSvc.listWorkspaces.mockResolvedValue([]);
|
||||||
issueSvc.list.mockResolvedValue([]);
|
issueSvc.list.mockResolvedValue([]);
|
||||||
issueSvc.getById.mockResolvedValue(null);
|
issueSvc.getById.mockResolvedValue(null);
|
||||||
issueSvc.getByIdentifier.mockResolvedValue(null);
|
issueSvc.getByIdentifier.mockResolvedValue(null);
|
||||||
|
routineSvc.list.mockResolvedValue([]);
|
||||||
|
routineSvc.getDetail.mockImplementation(async (id: string) => {
|
||||||
|
const rows = await routineSvc.list();
|
||||||
|
return rows.find((row: { id: string }) => row.id === id) ?? null;
|
||||||
|
});
|
||||||
|
routineSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||||
|
id: "routine-created",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: input.projectId,
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description ?? null,
|
||||||
|
assigneeAgentId: input.assigneeAgentId,
|
||||||
|
priority: input.priority ?? "medium",
|
||||||
|
status: input.status ?? "active",
|
||||||
|
concurrencyPolicy: input.concurrencyPolicy ?? "coalesce_if_active",
|
||||||
|
catchUpPolicy: input.catchUpPolicy ?? "skip_missed",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: null,
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
routineSvc.createTrigger.mockImplementation(async (_routineId: string, input: Record<string, unknown>) => ({
|
||||||
|
id: "trigger-created",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-created",
|
||||||
|
kind: input.kind,
|
||||||
|
label: input.label ?? null,
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
cronExpression: input.kind === "schedule" ? input.cronExpression ?? null : null,
|
||||||
|
timezone: input.kind === "schedule" ? input.timezone ?? null : null,
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: null,
|
||||||
|
secretId: null,
|
||||||
|
signingMode: input.kind === "webhook" ? input.signingMode ?? "bearer" : null,
|
||||||
|
replayWindowSec: input.kind === "webhook" ? input.replayWindowSec ?? 300 : null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
const companySkills = [
|
const companySkills = [
|
||||||
{
|
{
|
||||||
id: "skill-1",
|
id: "skill-1",
|
||||||
|
|
@ -370,6 +440,64 @@ describe("company portability", () => {
|
||||||
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
expect(exported.warnings).toContain("Agent claudecoder PATH override was omitted from export because it is system-dependent.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports default sidebar order into the Paperclip extension and manifest", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Zulu",
|
||||||
|
urlKey: "zulu",
|
||||||
|
description: null,
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
archivedAt: null,
|
||||||
|
workspaces: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Alpha",
|
||||||
|
urlKey: "alpha",
|
||||||
|
description: null,
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
archivedAt: null,
|
||||||
|
workspaces: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain([
|
||||||
|
"sidebar:",
|
||||||
|
" agents:",
|
||||||
|
' - "claudecoder"',
|
||||||
|
' - "cmo"',
|
||||||
|
" projects:",
|
||||||
|
' - "alpha"',
|
||||||
|
' - "zulu"',
|
||||||
|
].join("\n"));
|
||||||
|
expect(exported.manifest.sidebar).toEqual({
|
||||||
|
agents: ["claudecoder", "cmo"],
|
||||||
|
projects: ["alpha", "zulu"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("expands referenced skills when requested", async () => {
|
it("expands referenced skills when requested", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
@ -599,6 +727,388 @@ describe("company portability", () => {
|
||||||
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
|
expect(preview.fileInventory.some((entry) => entry.path.startsWith("tasks/"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports portable project workspace metadata and remaps it on import", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: "agent-1",
|
||||||
|
targetDate: "2026-03-31",
|
||||||
|
color: "#123456",
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: "workspace-1",
|
||||||
|
workspaceStrategy: {
|
||||||
|
type: "project_primary",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Main Repo",
|
||||||
|
sourceType: "git_repo",
|
||||||
|
cwd: "/Users/dotta/paperclip",
|
||||||
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||||
|
repoRef: "main",
|
||||||
|
defaultRef: "main",
|
||||||
|
visibility: "default",
|
||||||
|
setupCommand: "pnpm install",
|
||||||
|
cleanupCommand: "rm -rf .paperclip-tmp",
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: {
|
||||||
|
language: "typescript",
|
||||||
|
},
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "workspace-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Local Scratch",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: "/tmp/paperclip-local",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
visibility: "advanced",
|
||||||
|
setupCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: null,
|
||||||
|
isPrimary: false,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
issueSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1",
|
||||||
|
title: "Write launch task",
|
||||||
|
description: "Task body",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: {
|
||||||
|
mode: "shared_workspace",
|
||||||
|
},
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain("workspaces:");
|
||||||
|
expect(extension).toContain("main-repo:");
|
||||||
|
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||||
|
expect(extension).toContain('defaultProjectWorkspaceKey: "main-repo"');
|
||||||
|
expect(extension).toContain('projectWorkspaceKey: "main-repo"');
|
||||||
|
expect(extension).not.toContain("/Users/dotta/paperclip");
|
||||||
|
expect(extension).not.toContain("workspace-1");
|
||||||
|
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-imported",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
projectSvc.update.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
|
||||||
|
id: projectId,
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
projectSvc.createWorkspace.mockImplementation(async (projectId: string, data: Record<string, unknown>) => ({
|
||||||
|
id: "workspace-imported",
|
||||||
|
companyId: "company-imported",
|
||||||
|
projectId,
|
||||||
|
name: `${data.name ?? "Workspace"}`,
|
||||||
|
sourceType: `${data.sourceType ?? "git_repo"}`,
|
||||||
|
cwd: null,
|
||||||
|
repoUrl: typeof data.repoUrl === "string" ? data.repoUrl : null,
|
||||||
|
repoRef: typeof data.repoRef === "string" ? data.repoRef : null,
|
||||||
|
defaultRef: typeof data.defaultRef === "string" ? data.defaultRef : null,
|
||||||
|
visibility: `${data.visibility ?? "default"}`,
|
||||||
|
setupCommand: typeof data.setupCommand === "string" ? data.setupCommand : null,
|
||||||
|
cleanupCommand: typeof data.cleanupCommand === "string" ? data.cleanupCommand : null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
|
||||||
|
isPrimary: Boolean(data.isPrimary),
|
||||||
|
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||||
|
}));
|
||||||
|
issueSvc.create.mockResolvedValue({
|
||||||
|
id: "issue-imported",
|
||||||
|
title: "Write launch task",
|
||||||
|
});
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: exported.rootPath,
|
||||||
|
files: exported.files,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
mode: "new_company",
|
||||||
|
newCompanyName: "Imported Paperclip",
|
||||||
|
},
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(projectSvc.createWorkspace).toHaveBeenCalledWith("project-imported", expect.objectContaining({
|
||||||
|
name: "Main Repo",
|
||||||
|
sourceType: "git_repo",
|
||||||
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
||||||
|
repoRef: "main",
|
||||||
|
defaultRef: "main",
|
||||||
|
visibility: "default",
|
||||||
|
}));
|
||||||
|
expect(projectSvc.update).toHaveBeenCalledWith("project-imported", expect.objectContaining({
|
||||||
|
executionWorkspacePolicy: expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: "workspace-imported",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
projectId: "project-imported",
|
||||||
|
projectWorkspaceId: "workspace-imported",
|
||||||
|
title: "Write launch task",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
|
||||||
|
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["checkout", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/paperclipai/paperclip.git"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Paperclip App",
|
||||||
|
urlKey: "paperclip-app",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
defaultProjectWorkspaceId: "workspace-1",
|
||||||
|
},
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "paperclip",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: repoDir,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
visibility: "default",
|
||||||
|
setupCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: null,
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
issueSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1",
|
||||||
|
title: "Task one",
|
||||||
|
description: "Task body",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: false,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||||
|
expect(extension).toContain('projectWorkspaceKey: "paperclip"');
|
||||||
|
expect(exported.warnings).not.toContainEqual(expect.stringContaining("does not have a portable repoUrl"));
|
||||||
|
expect(exported.warnings).not.toContainEqual(expect.stringContaining("reference workspace workspace-1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses repeated task workspace warnings into one summary per missing workspace", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: "workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Local Scratch",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: "/tmp/local-only",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
visibility: "default",
|
||||||
|
setupCommand: null,
|
||||||
|
cleanupCommand: null,
|
||||||
|
remoteProvider: null,
|
||||||
|
remoteWorkspaceRef: null,
|
||||||
|
sharedWorkspaceKey: null,
|
||||||
|
metadata: null,
|
||||||
|
isPrimary: true,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
issueSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1",
|
||||||
|
title: "Task one",
|
||||||
|
description: null,
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-2",
|
||||||
|
identifier: "PAP-2",
|
||||||
|
title: "Task two",
|
||||||
|
description: null,
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue-3",
|
||||||
|
identifier: "PAP-3",
|
||||||
|
title: "Task three",
|
||||||
|
description: null,
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: false,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl.");
|
||||||
|
expect(exported.warnings).toContain("Tasks pap-1, pap-2, pap-3 reference workspace workspace-1, but that workspace could not be exported portably.");
|
||||||
|
expect(exported.warnings.filter((warning) => warning.includes("workspace reference workspace-1 was omitted from export"))).toHaveLength(0);
|
||||||
|
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
@ -654,6 +1164,360 @@ describe("company portability", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: "agent-1",
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
routineSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "routine-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
parentIssueId: null,
|
||||||
|
title: "Monday Review",
|
||||||
|
description: "Review pipeline health",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
priority: "high",
|
||||||
|
status: "paused",
|
||||||
|
concurrencyPolicy: "always_enqueue",
|
||||||
|
catchUpPolicy: "enqueue_missed_with_cap",
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
lastTriggeredAt: null,
|
||||||
|
lastEnqueuedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
triggers: [
|
||||||
|
{
|
||||||
|
id: "trigger-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-1",
|
||||||
|
kind: "schedule",
|
||||||
|
label: "Weekly cadence",
|
||||||
|
enabled: true,
|
||||||
|
cronExpression: "0 9 * * 1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: "public-1",
|
||||||
|
secretId: "secret-1",
|
||||||
|
signingMode: null,
|
||||||
|
replayWindowSec: null,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trigger-2",
|
||||||
|
companyId: "company-1",
|
||||||
|
routineId: "routine-1",
|
||||||
|
kind: "webhook",
|
||||||
|
label: "External nudge",
|
||||||
|
enabled: false,
|
||||||
|
cronExpression: null,
|
||||||
|
timezone: null,
|
||||||
|
nextRunAt: null,
|
||||||
|
lastFiredAt: null,
|
||||||
|
publicId: "public-2",
|
||||||
|
secretId: "secret-2",
|
||||||
|
signingMode: "hmac_sha256",
|
||||||
|
replayWindowSec: 120,
|
||||||
|
lastRotatedAt: null,
|
||||||
|
lastResult: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
updatedByAgentId: null,
|
||||||
|
updatedByUserId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastRun: null,
|
||||||
|
activeIssue: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(asTextFile(exported.files["tasks/monday-review/TASK.md"])).toContain('recurring: true');
|
||||||
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain("routines:");
|
||||||
|
expect(extension).toContain("monday-review:");
|
||||||
|
expect(extension).toContain('cronExpression: "0 9 * * 1"');
|
||||||
|
expect(extension).toContain('signingMode: "hmac_sha256"');
|
||||||
|
expect(extension).not.toContain("secretId");
|
||||||
|
expect(extension).not.toContain("publicId");
|
||||||
|
expect(exported.manifest.issues).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "monday-review",
|
||||||
|
recurring: true,
|
||||||
|
status: "paused",
|
||||||
|
priority: "high",
|
||||||
|
routine: expect.objectContaining({
|
||||||
|
concurrencyPolicy: "always_enqueue",
|
||||||
|
catchUpPolicy: "enqueue_missed_with_cap",
|
||||||
|
triggers: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "schedule", cronExpression: "0 9 * * 1", timezone: "America/Chicago" }),
|
||||||
|
expect.objectContaining({ kind: "webhook", enabled: false, signingMode: "hmac_sha256", replayWindowSec: 120 }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports recurring task packages as routines instead of one-time issues", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.create.mockResolvedValue({
|
||||||
|
id: "agent-created",
|
||||||
|
name: "ClaudeCoder",
|
||||||
|
});
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-created",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
"COMPANY.md": [
|
||||||
|
"---",
|
||||||
|
'schema: "agentcompanies/v1"',
|
||||||
|
'name: "Imported Paperclip"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"agents/claudecoder/AGENTS.md": [
|
||||||
|
"---",
|
||||||
|
'name: "ClaudeCoder"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"You write code.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"projects/launch/PROJECT.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Launch"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"tasks/monday-review/TASK.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Monday Review"',
|
||||||
|
'project: "launch"',
|
||||||
|
'assignee: "claudecoder"',
|
||||||
|
"recurring: true",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"Review pipeline health.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
".paperclip.yaml": [
|
||||||
|
'schema: "paperclip/v1"',
|
||||||
|
"routines:",
|
||||||
|
" monday-review:",
|
||||||
|
' status: "paused"',
|
||||||
|
' priority: "high"',
|
||||||
|
' concurrencyPolicy: "always_enqueue"',
|
||||||
|
' catchUpPolicy: "enqueue_missed_with_cap"',
|
||||||
|
" triggers:",
|
||||||
|
" - kind: schedule",
|
||||||
|
' cronExpression: "0 9 * * 1"',
|
||||||
|
' timezone: "America/Chicago"',
|
||||||
|
' - kind: webhook',
|
||||||
|
' enabled: false',
|
||||||
|
' signingMode: "hmac_sha256"',
|
||||||
|
' replayWindowSec: 120',
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toEqual([]);
|
||||||
|
expect(preview.plan.issuePlans).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
slug: "monday-review",
|
||||||
|
reason: "Recurring task will be imported as a routine.",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
projectId: "project-created",
|
||||||
|
title: "Monday Review",
|
||||||
|
assigneeAgentId: "agent-created",
|
||||||
|
priority: "high",
|
||||||
|
status: "paused",
|
||||||
|
concurrencyPolicy: "always_enqueue",
|
||||||
|
catchUpPolicy: "enqueue_missed_with_cap",
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2);
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||||
|
kind: "schedule",
|
||||||
|
cronExpression: "0 9 * * 1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||||
|
kind: "webhook",
|
||||||
|
enabled: false,
|
||||||
|
signingMode: "hmac_sha256",
|
||||||
|
replayWindowSec: 120,
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(issueSvc.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates legacy schedule.recurrence imports into routine triggers", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.create.mockResolvedValue({
|
||||||
|
id: "agent-created",
|
||||||
|
name: "ClaudeCoder",
|
||||||
|
});
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-created",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
|
||||||
|
"agents/claudecoder/AGENTS.md": ['---', 'name: "ClaudeCoder"', "---", "", "You write code.", ""].join("\n"),
|
||||||
|
"projects/launch/PROJECT.md": ['---', 'name: "Launch"', "---", ""].join("\n"),
|
||||||
|
"tasks/monday-review/TASK.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Monday Review"',
|
||||||
|
'project: "launch"',
|
||||||
|
'assignee: "claudecoder"',
|
||||||
|
"schedule:",
|
||||||
|
' timezone: "America/Chicago"',
|
||||||
|
' startsAt: "2026-03-16T09:00:00-05:00"',
|
||||||
|
" recurrence:",
|
||||||
|
' frequency: "weekly"',
|
||||||
|
" interval: 1",
|
||||||
|
" weekdays:",
|
||||||
|
' - "monday"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"Review pipeline health.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toEqual([]);
|
||||||
|
expect(preview.manifest.issues[0]).toEqual(expect.objectContaining({
|
||||||
|
recurring: true,
|
||||||
|
legacyRecurrence: expect.objectContaining({ frequency: "weekly" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: true, projects: true, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({
|
||||||
|
kind: "schedule",
|
||||||
|
cronExpression: "0 9 * * 1",
|
||||||
|
timezone: "America/Chicago",
|
||||||
|
}), expect.any(Object));
|
||||||
|
expect(issueSvc.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags recurring task imports that are missing routine-required fields", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: "paperclip-demo",
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": ['---', 'schema: "agentcompanies/v1"', 'name: "Imported Paperclip"', "---", ""].join("\n"),
|
||||||
|
"tasks/monday-review/TASK.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Monday Review"',
|
||||||
|
"recurring: true",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"Review pipeline health.",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { company: true, agents: false, projects: false, issues: true, skills: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toContain("Recurring task monday-review must declare a project to import as a routine.");
|
||||||
|
expect(preview.errors).toContain("Recurring task monday-review must declare an assignee to import as a routine.");
|
||||||
|
});
|
||||||
|
|
||||||
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
|
it("imports a vendor-neutral package without .paperclip.yaml", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
@ -1026,6 +1890,61 @@ describe("company portability", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables timer heartbeats on imported agents", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||||
|
id: `agent-${String(input.name).toLowerCase()}`,
|
||||||
|
name: input.name,
|
||||||
|
adapterConfig: input.adapterConfig,
|
||||||
|
runtimeConfig: input.runtimeConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: false,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: exported.rootPath,
|
||||||
|
files: exported.files,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: false,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
mode: "new_company",
|
||||||
|
newCompanyName: "Imported Paperclip",
|
||||||
|
},
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
const createdClaude = agentSvc.create.mock.calls.find(([, input]) => input.name === "ClaudeCoder");
|
||||||
|
expect(createdClaude?.[1]).toMatchObject({
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("imports only selected files and leaves unchecked company metadata alone", async () => {
|
it("imports only selected files and leaves unchecked company metadata alone", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
@ -1096,6 +2015,11 @@ describe("company portability", () => {
|
||||||
expect(agentSvc.create).toHaveBeenCalledTimes(1);
|
expect(agentSvc.create).toHaveBeenCalledTimes(1);
|
||||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||||
name: "CMO",
|
name: "CMO",
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
expect(result.company.action).toBe("unchanged");
|
expect(result.company.action).toBe("unchanged");
|
||||||
expect(result.agents).toEqual([
|
expect(result.agents).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
discoverProjectWorkspaceSkillDirectories,
|
discoverProjectWorkspaceSkillDirectories,
|
||||||
findMissingLocalSkillIds,
|
findMissingLocalSkillIds,
|
||||||
|
normalizeGitHubSkillDirectory,
|
||||||
parseSkillImportSourceInput,
|
parseSkillImportSourceInput,
|
||||||
readLocalSkillImportFromDirectory,
|
readLocalSkillImportFromDirectory,
|
||||||
} from "../services/company-skills.js";
|
} from "../services/company-skills.js";
|
||||||
|
|
@ -86,6 +87,13 @@ describe("company skill import source parsing", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("project workspace skill discovery", () => {
|
describe("project workspace skill discovery", () => {
|
||||||
|
it("normalizes GitHub skill directories for blob imports and legacy metadata", () => {
|
||||||
|
expect(normalizeGitHubSkillDirectory("retro/.", "retro")).toBe("retro");
|
||||||
|
expect(normalizeGitHubSkillDirectory("retro/SKILL.md", "retro")).toBe("retro");
|
||||||
|
expect(normalizeGitHubSkillDirectory("SKILL.md", "root-skill")).toBe("");
|
||||||
|
expect(normalizeGitHubSkillDirectory("", "fallback-skill")).toBe("fallback-skill");
|
||||||
|
});
|
||||||
|
|
||||||
it("finds bounded skill roots under supported workspace paths", async () => {
|
it("finds bounded skill roots under supported workspace paths", async () => {
|
||||||
const workspace = await makeTempDir("paperclip-skill-workspace-");
|
const workspace = await makeTempDir("paperclip-skill-workspace-");
|
||||||
await writeSkillDir(workspace, "Workspace Root");
|
await writeSkillDir(workspace, "Workspace Root");
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ export async function createApp(
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json({
|
app.use(express.json({
|
||||||
|
// Company import/export payloads can inline full portable packages.
|
||||||
|
limit: "10mb",
|
||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
(req as unknown as { rawBody: Buffer }).rawBody = buf;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export interface OrgNode {
|
||||||
role: string;
|
role: string;
|
||||||
status: string;
|
status: string;
|
||||||
reports: OrgNode[];
|
reports: OrgNode[];
|
||||||
|
/** Populated by collapseTree: the flattened list of hidden descendants for avatar grid rendering. */
|
||||||
|
collapsedReports?: OrgNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic";
|
export type OrgChartStyle = "monochrome" | "nebula" | "circuit" | "warmth" | "schematic";
|
||||||
|
|
@ -321,6 +323,12 @@ const CARD_PAD_X = 22;
|
||||||
const AVATAR_SIZE = 34;
|
const AVATAR_SIZE = 34;
|
||||||
const GAP_X = 24;
|
const GAP_X = 24;
|
||||||
const GAP_Y = 56;
|
const GAP_Y = 56;
|
||||||
|
|
||||||
|
// ── Collapsed avatar grid constants ─────────────────────────────
|
||||||
|
const MINI_AVATAR_SIZE = 14;
|
||||||
|
const MINI_AVATAR_GAP = 6;
|
||||||
|
const MINI_AVATAR_PADDING = 10;
|
||||||
|
const MINI_AVATAR_MAX_COLS = 8; // max avatars per row in the grid
|
||||||
const PADDING = 48;
|
const PADDING = 48;
|
||||||
const LOGO_PADDING = 16;
|
const LOGO_PADDING = 16;
|
||||||
|
|
||||||
|
|
@ -330,11 +338,42 @@ function measureText(text: string, fontSize: number): number {
|
||||||
return text.length * fontSize * 0.58;
|
return text.length * fontSize * 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Calculate how many rows the avatar grid needs. */
|
||||||
|
function avatarGridRows(count: number): number {
|
||||||
|
return Math.ceil(count / MINI_AVATAR_MAX_COLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Width needed for the avatar grid. */
|
||||||
|
function avatarGridWidth(count: number): number {
|
||||||
|
const cols = Math.min(count, MINI_AVATAR_MAX_COLS);
|
||||||
|
return cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of the avatar grid area. */
|
||||||
|
function avatarGridHeight(count: number): number {
|
||||||
|
if (count === 0) return 0;
|
||||||
|
const rows = avatarGridRows(count);
|
||||||
|
return rows * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP + MINI_AVATAR_PADDING * 2;
|
||||||
|
}
|
||||||
|
|
||||||
function cardWidth(node: OrgNode): number {
|
function cardWidth(node: OrgNode): number {
|
||||||
const { roleLabel } = getRoleInfo(node);
|
const { roleLabel: defaultRoleLabel } = getRoleInfo(node);
|
||||||
|
const roleLabel = node.role.startsWith("×") ? node.role : defaultRoleLabel;
|
||||||
const nameW = measureText(node.name, 14) + CARD_PAD_X * 2;
|
const nameW = measureText(node.name, 14) + CARD_PAD_X * 2;
|
||||||
const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2;
|
const roleW = measureText(roleLabel, 11) + CARD_PAD_X * 2;
|
||||||
return Math.max(CARD_MIN_W, Math.max(nameW, roleW));
|
let w = Math.max(CARD_MIN_W, Math.max(nameW, roleW));
|
||||||
|
// Widen for avatar grid if needed
|
||||||
|
if (node.collapsedReports && node.collapsedReports.length > 0) {
|
||||||
|
w = Math.max(w, avatarGridWidth(node.collapsedReports.length));
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHeight(node: OrgNode): number {
|
||||||
|
if (node.collapsedReports && node.collapsedReports.length > 0) {
|
||||||
|
return CARD_H + avatarGridHeight(node.collapsedReports.length);
|
||||||
|
}
|
||||||
|
return CARD_H;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tree layout (top-down, centered) ─────────────────────────────
|
// ── Tree layout (top-down, centered) ─────────────────────────────
|
||||||
|
|
@ -354,18 +393,19 @@ function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
|
||||||
const sw = subtreeWidth(node);
|
const sw = subtreeWidth(node);
|
||||||
const cardX = x + (sw - w) / 2;
|
const cardX = x + (sw - w) / 2;
|
||||||
|
|
||||||
|
const h = cardHeight(node);
|
||||||
const layoutNode: LayoutNode = {
|
const layoutNode: LayoutNode = {
|
||||||
node,
|
node,
|
||||||
x: cardX,
|
x: cardX,
|
||||||
y,
|
y,
|
||||||
width: w,
|
width: w,
|
||||||
height: CARD_H,
|
height: h,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (node.reports && node.reports.length > 0) {
|
if (node.reports && node.reports.length > 0) {
|
||||||
let childX = x;
|
let childX = x;
|
||||||
const childY = y + CARD_H + GAP_Y;
|
const childY = y + h + GAP_Y;
|
||||||
for (let i = 0; i < node.reports.length; i++) {
|
for (let i = 0; i < node.reports.length; i++) {
|
||||||
const child = node.reports[i];
|
const child = node.reports[i];
|
||||||
const childSW = subtreeWidth(child);
|
const childSW = subtreeWidth(child);
|
||||||
|
|
@ -394,7 +434,19 @@ function renderEmojiAvatar(cx: number, cy: number, radius: number, bgFill: strin
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
|
function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
|
||||||
const { roleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
|
// Overflow placeholder card: just shows "+N more" text, no avatar
|
||||||
|
if (ln.node.role === "overflow") {
|
||||||
|
const cx = ln.x + ln.width / 2;
|
||||||
|
const cy = ln.y + ln.height / 2;
|
||||||
|
return `<g>
|
||||||
|
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.bgColor}" stroke="${theme.cardBorder}" stroke-width="1" stroke-dasharray="4,3"/>
|
||||||
|
<text x="${cx}" y="${cy + 5}" text-anchor="middle" font-family="${theme.font}" font-size="13" font-weight="600" fill="${theme.roleColor}">${escapeXml(ln.node.name)}</text>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roleLabel: defaultRoleLabel, bg, emojiSvg } = getRoleInfo(ln.node);
|
||||||
|
// Use node.role directly when it's a collapse badge (e.g. "×15 reports")
|
||||||
|
const roleLabel = ln.node.role.startsWith("×") ? ln.node.role : defaultRoleLabel;
|
||||||
const cx = ln.x + ln.width / 2;
|
const cx = ln.x + ln.width / 2;
|
||||||
|
|
||||||
const avatarCY = ln.y + 27;
|
const avatarCY = ln.y + 27;
|
||||||
|
|
@ -417,12 +469,33 @@ function defaultRenderCard(ln: LayoutNode, theme: StyleTheme): string {
|
||||||
const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)";
|
const avatarBg = isLight ? bg : "rgba(255,255,255,0.06)";
|
||||||
const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)";
|
const avatarStroke = isLight ? undefined : "rgba(255,255,255,0.08)";
|
||||||
|
|
||||||
|
// Render collapsed avatar grid if this node has hidden reports
|
||||||
|
let avatarGridSvg = "";
|
||||||
|
const collapsed = ln.node.collapsedReports;
|
||||||
|
if (collapsed && collapsed.length > 0) {
|
||||||
|
const gridTop = ln.y + CARD_H + MINI_AVATAR_PADDING;
|
||||||
|
const cols = Math.min(collapsed.length, MINI_AVATAR_MAX_COLS);
|
||||||
|
const gridTotalW = cols * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) - MINI_AVATAR_GAP;
|
||||||
|
const gridStartX = ln.x + (ln.width - gridTotalW) / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < collapsed.length; i++) {
|
||||||
|
const col = i % MINI_AVATAR_MAX_COLS;
|
||||||
|
const row = Math.floor(i / MINI_AVATAR_MAX_COLS);
|
||||||
|
const dotCx = gridStartX + col * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
|
||||||
|
const dotCy = gridTop + row * (MINI_AVATAR_SIZE + MINI_AVATAR_GAP) + MINI_AVATAR_SIZE / 2;
|
||||||
|
const { bg: dotBg } = getRoleInfo(collapsed[i]);
|
||||||
|
const dotFill = isLight ? dotBg : "rgba(255,255,255,0.1)";
|
||||||
|
avatarGridSvg += `<circle cx="${dotCx}" cy="${dotCy}" r="${MINI_AVATAR_SIZE / 2}" fill="${dotFill}" stroke="${theme.cardBorder}" stroke-width="0.5"/>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return `<g>
|
return `<g>
|
||||||
${shadowDef}
|
${shadowDef}
|
||||||
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.cardBg}" stroke="${theme.cardBorder}" stroke-width="1" ${shadowFilter}/>
|
<rect x="${ln.x}" y="${ln.y}" width="${ln.width}" height="${ln.height}" rx="${theme.cardRadius}" fill="${theme.cardBg}" stroke="${theme.cardBorder}" stroke-width="1" ${shadowFilter}/>
|
||||||
${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)}
|
${renderEmojiAvatar(cx, avatarCY, AVATAR_SIZE / 2, avatarBg, emojiSvg, avatarStroke)}
|
||||||
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="14" font-weight="600" fill="${theme.nameColor}">${escapeXml(ln.node.name)}</text>
|
<text x="${cx}" y="${nameY}" text-anchor="middle" font-family="${theme.font}" font-size="14" font-weight="600" fill="${theme.nameColor}">${escapeXml(ln.node.name)}</text>
|
||||||
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="11" font-weight="500" fill="${theme.roleColor}">${escapeXml(roleLabel)}</text>
|
<text x="${cx}" y="${roleY}" text-anchor="middle" font-family="${theme.font}" font-size="11" font-weight="500" fill="${theme.roleColor}">${escapeXml(roleLabel)}</text>
|
||||||
|
${avatarGridSvg}
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,19 +569,154 @@ const PAPERCLIP_LOGO_SVG = `<g>
|
||||||
const TARGET_W = 1280;
|
const TARGET_W = 1280;
|
||||||
const TARGET_H = 640;
|
const TARGET_H = 640;
|
||||||
|
|
||||||
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): string {
|
export interface OrgChartOverlay {
|
||||||
|
/** Company name displayed top-left */
|
||||||
|
companyName?: string;
|
||||||
|
/** Summary stats displayed bottom-right, e.g. "Agents: 5, Skills: 8" */
|
||||||
|
stats?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count total nodes in a tree. */
|
||||||
|
function countNodes(nodes: OrgNode[]): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const n of nodes) {
|
||||||
|
count += 1 + countNodes(n.reports ?? []);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Threshold: auto-collapse orgs larger than this. */
|
||||||
|
const COLLAPSE_THRESHOLD = 20;
|
||||||
|
/** Max cards that can fit across the 1280px image. */
|
||||||
|
const MAX_LEVEL_WIDTH = 8;
|
||||||
|
/** Max children shown per parent before truncation with "and N more". */
|
||||||
|
const MAX_CHILDREN_SHOWN = 6;
|
||||||
|
|
||||||
|
/** Flatten all descendants of a node into a single list. */
|
||||||
|
function flattenDescendants(nodes: OrgNode[]): OrgNode[] {
|
||||||
|
const result: OrgNode[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
result.push(n);
|
||||||
|
result.push(...flattenDescendants(n.reports ?? []));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect all nodes at a given depth in the tree. */
|
||||||
|
function nodesAtDepth(nodes: OrgNode[], depth: number): OrgNode[] {
|
||||||
|
if (depth === 0) return nodes;
|
||||||
|
const result: OrgNode[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
result.push(...nodesAtDepth(n.reports ?? [], depth - 1));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate how many cards would be shown at the next level if we expand,
|
||||||
|
* considering truncation (each parent shows at most MAX_CHILDREN_SHOWN + 1 placeholder).
|
||||||
|
*/
|
||||||
|
function estimateNextLevelWidth(parentNodes: OrgNode[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const p of parentNodes) {
|
||||||
|
const childCount = (p.reports ?? []).length;
|
||||||
|
if (childCount === 0) continue;
|
||||||
|
total += Math.min(childCount, MAX_CHILDREN_SHOWN + 1); // +1 for "and N more" placeholder
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a node's children to avatar dots (for wide levels that can't expand).
|
||||||
|
*/
|
||||||
|
function collapseToAvatars(node: OrgNode): OrgNode {
|
||||||
|
const childCount = countNodes(node.reports ?? []);
|
||||||
|
if (childCount === 0) return node;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
role: `×${childCount} reports`,
|
||||||
|
collapsedReports: flattenDescendants(node.reports ?? []),
|
||||||
|
reports: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate a node's children: keep first MAX_CHILDREN_SHOWN, replace rest with
|
||||||
|
* a summary "and N more" placeholder node (rendered as a count card).
|
||||||
|
*/
|
||||||
|
function truncateChildren(node: OrgNode): OrgNode {
|
||||||
|
const children = node.reports ?? [];
|
||||||
|
if (children.length <= MAX_CHILDREN_SHOWN) return node;
|
||||||
|
const kept = children.slice(0, MAX_CHILDREN_SHOWN);
|
||||||
|
const hiddenCount = children.length - MAX_CHILDREN_SHOWN;
|
||||||
|
const placeholder: OrgNode = {
|
||||||
|
id: `${node.id}-more`,
|
||||||
|
name: `+${hiddenCount} more`,
|
||||||
|
role: "overflow",
|
||||||
|
status: "active",
|
||||||
|
reports: [],
|
||||||
|
};
|
||||||
|
return { ...node, reports: [...kept, placeholder] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptive collapse: expands levels as long as they fit, truncates or collapses
|
||||||
|
* when a level is too wide.
|
||||||
|
*/
|
||||||
|
function smartCollapseTree(roots: OrgNode[]): OrgNode[] {
|
||||||
|
// Deep clone so we can mutate
|
||||||
|
const clone = (nodes: OrgNode[]): OrgNode[] =>
|
||||||
|
nodes.map((n) => ({ ...n, reports: clone(n.reports ?? []) }));
|
||||||
|
const tree = clone(roots);
|
||||||
|
|
||||||
|
// Walk levels from root down
|
||||||
|
for (let depth = 0; depth < 10; depth++) {
|
||||||
|
const parents = nodesAtDepth(tree, depth);
|
||||||
|
const parentsWithChildren = parents.filter((p) => (p.reports ?? []).length > 0);
|
||||||
|
if (parentsWithChildren.length === 0) break;
|
||||||
|
|
||||||
|
const nextWidth = estimateNextLevelWidth(parentsWithChildren);
|
||||||
|
if (nextWidth <= MAX_LEVEL_WIDTH) {
|
||||||
|
// Next level fits with truncation — truncate oversized parents, then continue deeper
|
||||||
|
for (const p of parentsWithChildren) {
|
||||||
|
if ((p.reports ?? []).length > MAX_CHILDREN_SHOWN) {
|
||||||
|
const truncated = truncateChildren(p);
|
||||||
|
p.reports = truncated.reports;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next level is too wide — collapse all children at this level to avatars
|
||||||
|
for (const p of parentsWithChildren) {
|
||||||
|
const collapsed = collapseToAvatars(p);
|
||||||
|
p.role = collapsed.role;
|
||||||
|
p.collapsedReports = collapsed.collapsedReports;
|
||||||
|
p.reports = [];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): string {
|
||||||
const theme = THEMES[style] || THEMES.warmth;
|
const theme = THEMES[style] || THEMES.warmth;
|
||||||
|
|
||||||
|
// Auto-collapse large orgs to keep the chart readable
|
||||||
|
const totalNodes = countNodes(orgTree);
|
||||||
|
const effectiveTree = totalNodes > COLLAPSE_THRESHOLD ? smartCollapseTree(orgTree) : orgTree;
|
||||||
|
|
||||||
let root: OrgNode;
|
let root: OrgNode;
|
||||||
if (orgTree.length === 1) {
|
if (effectiveTree.length === 1) {
|
||||||
root = orgTree[0];
|
root = effectiveTree[0];
|
||||||
} else {
|
} else {
|
||||||
root = {
|
root = {
|
||||||
id: "virtual-root",
|
id: "virtual-root",
|
||||||
name: "Organization",
|
name: "Organization",
|
||||||
role: "Root",
|
role: "Root",
|
||||||
status: "active",
|
status: "active",
|
||||||
reports: orgTree,
|
reports: effectiveTree,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -529,6 +737,14 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
|
||||||
const logoX = TARGET_W - 110 - LOGO_PADDING;
|
const logoX = TARGET_W - 110 - LOGO_PADDING;
|
||||||
const logoY = LOGO_PADDING;
|
const logoY = LOGO_PADDING;
|
||||||
|
|
||||||
|
// Optional overlay elements
|
||||||
|
const overlayNameSvg = overlay?.companyName
|
||||||
|
? `<text x="${LOGO_PADDING}" y="${LOGO_PADDING + 16}" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="22" font-weight="700" fill="${theme.nameColor}">${svgEscape(overlay.companyName)}</text>`
|
||||||
|
: "";
|
||||||
|
const overlayStatsSvg = overlay?.stats
|
||||||
|
? `<text x="${TARGET_W - LOGO_PADDING}" y="${TARGET_H - LOGO_PADDING}" text-anchor="end" font-family="'Inter', -apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="500" fill="${theme.roleColor}">${svgEscape(overlay.stats)}</text>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${TARGET_W}" height="${TARGET_H}" viewBox="0 0 ${TARGET_W} ${TARGET_H}">
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${TARGET_W}" height="${TARGET_H}" viewBox="0 0 ${TARGET_W} ${TARGET_H}">
|
||||||
<defs>${theme.defs(TARGET_W, TARGET_H)}</defs>
|
<defs>${theme.defs(TARGET_W, TARGET_H)}</defs>
|
||||||
<rect width="100%" height="100%" fill="${theme.bgColor}" rx="6"/>
|
<rect width="100%" height="100%" fill="${theme.bgColor}" rx="6"/>
|
||||||
|
|
@ -536,6 +752,8 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
|
||||||
<g transform="translate(${logoX}, ${logoY})" color="${theme.watermarkColor}">
|
<g transform="translate(${logoX}, ${logoY})" color="${theme.watermarkColor}">
|
||||||
${PAPERCLIP_LOGO_SVG}
|
${PAPERCLIP_LOGO_SVG}
|
||||||
</g>
|
</g>
|
||||||
|
${overlayNameSvg}
|
||||||
|
${overlayStatsSvg}
|
||||||
<g transform="translate(${offsetX}, ${offsetY}) scale(${scale})">
|
<g transform="translate(${offsetX}, ${offsetY}) scale(${scale})">
|
||||||
${renderConnectors(layout, theme)}
|
${renderConnectors(layout, theme)}
|
||||||
${renderCards(layout, theme)}
|
${renderCards(layout, theme)}
|
||||||
|
|
@ -543,8 +761,12 @@ export function renderOrgChartSvg(orgTree: OrgNode[], style: OrgChartStyle = "wa
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth"): Promise<Buffer> {
|
function svgEscape(s: string): string {
|
||||||
const svg = renderOrgChartSvg(orgTree, style);
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderOrgChartPng(orgTree: OrgNode[], style: OrgChartStyle = "warmth", overlay?: OrgChartOverlay): Promise<Buffer> {
|
||||||
|
const svg = renderOrgChartSvg(orgTree, style, overlay);
|
||||||
const sharpModule = await import("sharp");
|
const sharpModule = await import("sharp");
|
||||||
const sharp = sharpModule.default;
|
const sharp = sharpModule.default;
|
||||||
// Render at 2x density for retina quality, resize to exact target dimensions
|
// Render at 2x density for retina quality, resize to exact target dimensions
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -99,6 +99,8 @@ type RuntimeSkillEntryOptions = {
|
||||||
materializeMissing?: boolean;
|
materializeMissing?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const skillInventoryRefreshPromises = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
const PROJECT_SCAN_DIRECTORY_ROOTS = [
|
const PROJECT_SCAN_DIRECTORY_ROOTS = [
|
||||||
"skills",
|
"skills",
|
||||||
"skills/.curated",
|
"skills/.curated",
|
||||||
|
|
@ -188,6 +190,18 @@ function normalizeSkillKey(value: string | null | undefined) {
|
||||||
return segments.length > 0 ? segments.join("/") : null;
|
return segments.length > 0 ? segments.join("/") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeGitHubSkillDirectory(
|
||||||
|
value: string | null | undefined,
|
||||||
|
fallback: string,
|
||||||
|
) {
|
||||||
|
const normalized = normalizePortablePath(value ?? "");
|
||||||
|
if (!normalized) return normalizePortablePath(fallback);
|
||||||
|
if (path.posix.basename(normalized).toLowerCase() === "skill.md") {
|
||||||
|
return normalizePortablePath(path.posix.dirname(normalized));
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function hashSkillValue(value: string) {
|
function hashSkillValue(value: string) {
|
||||||
return createHash("sha256").update(value).digest("hex").slice(0, 10);
|
return createHash("sha256").update(value).digest("hex").slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
@ -1017,7 +1031,10 @@ async function readUrlSkillImports(
|
||||||
repo: parsed.repo,
|
repo: parsed.repo,
|
||||||
ref: ref,
|
ref: ref,
|
||||||
trackingRef,
|
trackingRef,
|
||||||
repoSkillDir: basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
repoSkillDir: normalizeGitHubSkillDirectory(
|
||||||
|
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
|
||||||
|
slug,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const inventory = filteredPaths
|
const inventory = filteredPaths
|
||||||
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
|
||||||
|
|
@ -1474,8 +1491,25 @@ export function companySkillService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSkillInventoryCurrent(companyId: string) {
|
async function ensureSkillInventoryCurrent(companyId: string) {
|
||||||
await ensureBundledSkills(companyId);
|
const existingRefresh = skillInventoryRefreshPromises.get(companyId);
|
||||||
await pruneMissingLocalPathSkills(companyId);
|
if (existingRefresh) {
|
||||||
|
await existingRefresh;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshPromise = (async () => {
|
||||||
|
await ensureBundledSkills(companyId);
|
||||||
|
await pruneMissingLocalPathSkills(companyId);
|
||||||
|
})();
|
||||||
|
|
||||||
|
skillInventoryRefreshPromises.set(companyId, refreshPromise);
|
||||||
|
try {
|
||||||
|
await refreshPromise;
|
||||||
|
} finally {
|
||||||
|
if (skillInventoryRefreshPromises.get(companyId) === refreshPromise) {
|
||||||
|
skillInventoryRefreshPromises.delete(companyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function list(companyId: string): Promise<CompanySkillListItem[]> {
|
async function list(companyId: string): Promise<CompanySkillListItem[]> {
|
||||||
|
|
@ -1646,7 +1680,7 @@ export function companySkillService(db: Db) {
|
||||||
const owner = asString(metadata.owner);
|
const owner = asString(metadata.owner);
|
||||||
const repo = asString(metadata.repo);
|
const repo = asString(metadata.repo);
|
||||||
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
|
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
|
||||||
const repoSkillDir = normalizePortablePath(asString(metadata.repoSkillDir) ?? skill.slug);
|
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
throw unprocessable("Skill source metadata is incomplete.");
|
throw unprocessable("Skill source metadata is incomplete.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
|
CompanyPortabilityExportRequest,
|
||||||
CompanyPortabilityExportPreviewResult,
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityImportRequest,
|
CompanyPortabilityImportRequest,
|
||||||
|
|
@ -37,41 +38,17 @@ export const companiesApi = {
|
||||||
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
remove: (companyId: string) => api.delete<{ ok: true }>(`/companies/${companyId}`),
|
||||||
exportBundle: (
|
exportBundle: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: CompanyPortabilityExportRequest,
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
|
||||||
agents?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
projects?: string[];
|
|
||||||
issues?: string[];
|
|
||||||
projectIssues?: string[];
|
|
||||||
selectedFiles?: string[];
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
|
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
|
||||||
exportPreview: (
|
exportPreview: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: CompanyPortabilityExportRequest,
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
|
||||||
agents?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
projects?: string[];
|
|
||||||
issues?: string[];
|
|
||||||
projectIssues?: string[];
|
|
||||||
selectedFiles?: string[];
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
|
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
|
||||||
exportPackage: (
|
exportPackage: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
data: {
|
data: CompanyPortabilityExportRequest,
|
||||||
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
|
|
||||||
agents?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
projects?: string[];
|
|
||||||
issues?: string[];
|
|
||||||
projectIssues?: string[];
|
|
||||||
selectedFiles?: string[];
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
|
||||||
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
importPreview: (data: CompanyPortabilityPreviewRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ export const FRONTMATTER_FIELD_LABELS: Record<string, string> = {
|
||||||
priority: "Priority",
|
priority: "Priority",
|
||||||
assignee: "Assignee",
|
assignee: "Assignee",
|
||||||
project: "Project",
|
project: "Project",
|
||||||
|
recurring: "Recurring",
|
||||||
targetDate: "Target date",
|
targetDate: "Target date",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,11 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
import { cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||||
|
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,28 +19,6 @@ import {
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
|
||||||
/** BFS sort: roots first (no reportsTo), then their direct reports, etc. */
|
|
||||||
function sortByHierarchy(agents: Agent[]): Agent[] {
|
|
||||||
const byId = new Map(agents.map((a) => [a.id, a]));
|
|
||||||
const childrenOf = new Map<string | null, Agent[]>();
|
|
||||||
for (const a of agents) {
|
|
||||||
const parent = a.reportsTo && byId.has(a.reportsTo) ? a.reportsTo : null;
|
|
||||||
const list = childrenOf.get(parent) ?? [];
|
|
||||||
list.push(a);
|
|
||||||
childrenOf.set(parent, list);
|
|
||||||
}
|
|
||||||
const sorted: Agent[] = [];
|
|
||||||
const queue = childrenOf.get(null) ?? [];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const agent = queue.shift()!;
|
|
||||||
sorted.push(agent);
|
|
||||||
const children = childrenOf.get(agent.id);
|
|
||||||
if (children) queue.push(...children);
|
|
||||||
}
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarAgents() {
|
export function SidebarAgents() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
|
@ -51,6 +31,10 @@ export function SidebarAgents() {
|
||||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
|
||||||
const { data: liveRuns } = useQuery({
|
const { data: liveRuns } = useQuery({
|
||||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
|
|
@ -71,8 +55,14 @@ export function SidebarAgents() {
|
||||||
const filtered = (agents ?? []).filter(
|
const filtered = (agents ?? []).filter(
|
||||||
(a: Agent) => a.status !== "terminated"
|
(a: Agent) => a.status !== "terminated"
|
||||||
);
|
);
|
||||||
return sortByHierarchy(filtered);
|
return filtered;
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const { orderedAgents } = useAgentOrder({
|
||||||
|
agents: visibleAgents,
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
|
||||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||||
const activeAgentId = agentMatch?.[1] ?? null;
|
const activeAgentId = agentMatch?.[1] ?? null;
|
||||||
|
|
@ -109,7 +99,7 @@ export function SidebarAgents() {
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||||
{visibleAgents.map((agent: Agent) => {
|
{orderedAgents.map((agent: Agent) => {
|
||||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
|
||||||
104
ui/src/hooks/useAgentOrder.ts
Normal file
104
ui/src/hooks/useAgentOrder.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
AGENT_ORDER_UPDATED_EVENT,
|
||||||
|
getAgentOrderStorageKey,
|
||||||
|
readAgentOrder,
|
||||||
|
sortAgentsByStoredOrder,
|
||||||
|
writeAgentOrder,
|
||||||
|
} from "../lib/agent-order";
|
||||||
|
|
||||||
|
type UseAgentOrderParams = {
|
||||||
|
agents: Agent[];
|
||||||
|
companyId: string | null | undefined;
|
||||||
|
userId: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AgentOrderUpdatedDetail = {
|
||||||
|
storageKey: string;
|
||||||
|
orderedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function areEqual(a: string[], b: string[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderIds(agents: Agent[], orderedIds: string[]) {
|
||||||
|
return sortAgentsByStoredOrder(agents, orderedIds).map((agent) => agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentOrder({ agents, companyId, userId }: UseAgentOrderParams) {
|
||||||
|
const storageKey = useMemo(() => {
|
||||||
|
if (!companyId) return null;
|
||||||
|
return getAgentOrderStorageKey(companyId, userId);
|
||||||
|
}, [companyId, userId]);
|
||||||
|
|
||||||
|
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||||
|
if (!storageKey) return agents.map((agent) => agent.id);
|
||||||
|
return buildOrderIds(agents, readAgentOrder(storageKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextIds = storageKey
|
||||||
|
? buildOrderIds(agents, readAgentOrder(storageKey))
|
||||||
|
: agents.map((agent) => agent.id);
|
||||||
|
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||||
|
}, [agents, storageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
const syncFromIds = (ids: string[]) => {
|
||||||
|
const nextIds = buildOrderIds(agents, ids);
|
||||||
|
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== storageKey) return;
|
||||||
|
syncFromIds(readAgentOrder(storageKey));
|
||||||
|
};
|
||||||
|
const onCustomEvent = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<AgentOrderUpdatedDetail>).detail;
|
||||||
|
if (!detail || detail.storageKey !== storageKey) return;
|
||||||
|
syncFromIds(detail.orderedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||||
|
};
|
||||||
|
}, [agents, storageKey]);
|
||||||
|
|
||||||
|
const orderedAgents = useMemo(
|
||||||
|
() => sortAgentsByStoredOrder(agents, orderedIds),
|
||||||
|
[agents, orderedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistOrder = useCallback(
|
||||||
|
(ids: string[]) => {
|
||||||
|
const idSet = new Set(agents.map((agent) => agent.id));
|
||||||
|
const filtered = ids.filter((id) => idSet.has(id));
|
||||||
|
for (const agent of sortAgentsByStoredOrder(agents, [])) {
|
||||||
|
if (!filtered.includes(agent.id)) filtered.push(agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
|
||||||
|
if (storageKey) {
|
||||||
|
writeAgentOrder(storageKey, filtered);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[agents, storageKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderedAgents,
|
||||||
|
orderedIds,
|
||||||
|
persistOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
106
ui/src/lib/agent-order.ts
Normal file
106
ui/src/lib/agent-order.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export const AGENT_ORDER_UPDATED_EVENT = "paperclip:agent-order-updated";
|
||||||
|
const AGENT_ORDER_STORAGE_PREFIX = "paperclip.agentOrder";
|
||||||
|
const ANONYMOUS_USER_ID = "anonymous";
|
||||||
|
|
||||||
|
type AgentOrderUpdatedDetail = {
|
||||||
|
storageKey: string;
|
||||||
|
orderedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeIdList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserId(userId: string | null | undefined): string {
|
||||||
|
if (!userId) return ANONYMOUS_USER_ID;
|
||||||
|
const trimmed = userId.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentOrderStorageKey(companyId: string, userId: string | null | undefined): string {
|
||||||
|
return `${AGENT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAgentOrder(storageKey: string): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (!raw) return [];
|
||||||
|
return normalizeIdList(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAgentOrder(storageKey: string, orderedIds: string[]) {
|
||||||
|
const normalized = normalizeIdList(orderedIds);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage write failures in restricted browser contexts.
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<AgentOrderUpdatedDetail>(AGENT_ORDER_UPDATED_EVENT, {
|
||||||
|
detail: { storageKey, orderedIds: normalized },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortAgentsByDefaultSidebarOrder(agents: Agent[]): Agent[] {
|
||||||
|
if (agents.length === 0) return [];
|
||||||
|
|
||||||
|
const byId = new Map(agents.map((agent) => [agent.id, agent]));
|
||||||
|
const childrenOf = new Map<string | null, Agent[]>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
const parentId = agent.reportsTo && byId.has(agent.reportsTo) ? agent.reportsTo : null;
|
||||||
|
const siblings = childrenOf.get(parentId) ?? [];
|
||||||
|
siblings.push(agent);
|
||||||
|
childrenOf.set(parentId, siblings);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const siblings of childrenOf.values()) {
|
||||||
|
siblings.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted: Agent[] = [];
|
||||||
|
const queue = [...(childrenOf.get(null) ?? [])];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const agent = queue.shift();
|
||||||
|
if (!agent) continue;
|
||||||
|
sorted.push(agent);
|
||||||
|
const children = childrenOf.get(agent.id);
|
||||||
|
if (children) queue.push(...children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortAgentsByStoredOrder(agents: Agent[], orderedIds: string[]): Agent[] {
|
||||||
|
if (agents.length === 0) return [];
|
||||||
|
|
||||||
|
const defaultSorted = sortAgentsByDefaultSidebarOrder(agents);
|
||||||
|
if (orderedIds.length === 0) return defaultSorted;
|
||||||
|
|
||||||
|
const byId = new Map(defaultSorted.map((agent) => [agent.id, agent]));
|
||||||
|
const sorted: Agent[] = [];
|
||||||
|
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
const agent = byId.get(id);
|
||||||
|
if (!agent) continue;
|
||||||
|
sorted.push(agent);
|
||||||
|
byId.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of defaultSorted) {
|
||||||
|
if (byId.has(agent.id)) {
|
||||||
|
sorted.push(agent);
|
||||||
|
byId.delete(agent.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
41
ui/src/lib/company-export-selection.test.ts
Normal file
41
ui/src/lib/company-export-selection.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildInitialExportCheckedFiles } from "./company-export-selection";
|
||||||
|
|
||||||
|
describe("buildInitialExportCheckedFiles", () => {
|
||||||
|
it("checks non-task files and recurring task packages by default", () => {
|
||||||
|
const checked = buildInitialExportCheckedFiles(
|
||||||
|
[
|
||||||
|
"README.md",
|
||||||
|
".paperclip.yaml",
|
||||||
|
"tasks/one-off/TASK.md",
|
||||||
|
"tasks/recurring/TASK.md",
|
||||||
|
"tasks/recurring/notes.md",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ path: "tasks/one-off/TASK.md", recurring: false },
|
||||||
|
{ path: "tasks/recurring/TASK.md", recurring: true },
|
||||||
|
],
|
||||||
|
new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Array.from(checked).sort()).toEqual([
|
||||||
|
".paperclip.yaml",
|
||||||
|
"README.md",
|
||||||
|
"tasks/recurring/TASK.md",
|
||||||
|
"tasks/recurring/notes.md",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves previous manual selections for one-time tasks", () => {
|
||||||
|
const checked = buildInitialExportCheckedFiles(
|
||||||
|
["README.md", "tasks/one-off/TASK.md"],
|
||||||
|
[{ path: "tasks/one-off/TASK.md", recurring: false }],
|
||||||
|
new Set(["tasks/one-off/TASK.md"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Array.from(checked).sort()).toEqual([
|
||||||
|
"README.md",
|
||||||
|
"tasks/one-off/TASK.md",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
ui/src/lib/company-export-selection.ts
Normal file
56
ui/src/lib/company-export-selection.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { CompanyPortabilityIssueManifestEntry } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
function isTaskPath(filePath: string): boolean {
|
||||||
|
return /(?:^|\/)tasks\//.test(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecurringTaskPrefixes(
|
||||||
|
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
|
||||||
|
): Set<string> {
|
||||||
|
const prefixes = new Set<string>();
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (!issue.recurring) continue;
|
||||||
|
|
||||||
|
const filePath = issue.path.trim();
|
||||||
|
if (!filePath) continue;
|
||||||
|
|
||||||
|
prefixes.add(filePath);
|
||||||
|
|
||||||
|
const lastSlash = filePath.lastIndexOf("/");
|
||||||
|
if (lastSlash >= 0) {
|
||||||
|
prefixes.add(`${filePath.slice(0, lastSlash + 1)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefixes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecurringTaskFile(filePath: string, recurringTaskPrefixes: Set<string>): boolean {
|
||||||
|
for (const prefix of recurringTaskPrefixes) {
|
||||||
|
if (filePath === prefix || filePath.startsWith(prefix)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInitialExportCheckedFiles(
|
||||||
|
filePaths: string[],
|
||||||
|
issues: Array<Pick<CompanyPortabilityIssueManifestEntry, "path" | "recurring">>,
|
||||||
|
previousCheckedFiles: Set<string>,
|
||||||
|
): Set<string> {
|
||||||
|
const next = new Set<string>();
|
||||||
|
const recurringTaskPrefixes = buildRecurringTaskPrefixes(issues);
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (previousCheckedFiles.has(filePath)) {
|
||||||
|
next.add(filePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTaskPath(filePath) || isRecurringTaskFile(filePath, recurringTaskPrefixes)) {
|
||||||
|
next.add(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
100
ui/src/lib/company-portability-sidebar.test.ts
Normal file
100
ui/src/lib/company-portability-sidebar.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Agent, Project } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
buildPortableAgentSlugMap,
|
||||||
|
buildPortableProjectSlugMap,
|
||||||
|
buildPortableSidebarOrder,
|
||||||
|
} from "./company-portability-sidebar";
|
||||||
|
|
||||||
|
function makeAgent(id: string, name: string): Agent {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "company-1",
|
||||||
|
name,
|
||||||
|
role: "engineer",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
status: "idle",
|
||||||
|
reportsTo: null,
|
||||||
|
capabilities: null,
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
spentMonthlyCents: 0,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
permissions: { canCreateAgents: false },
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
urlKey: name.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProject(id: string, name: string): Project {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
companyId: "company-1",
|
||||||
|
goalId: null,
|
||||||
|
urlKey: name.toLowerCase(),
|
||||||
|
name,
|
||||||
|
description: null,
|
||||||
|
status: "planned",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
archivedAt: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
workspaces: [],
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/managed",
|
||||||
|
effectiveLocalFolder: "/tmp/managed",
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("company portability sidebar order", () => {
|
||||||
|
it("uses the same unique slug allocation as export and preserves the requested order", () => {
|
||||||
|
const alphaOne = makeAgent("agent-1", "Alpha");
|
||||||
|
const alphaTwo = makeAgent("agent-2", "Alpha");
|
||||||
|
const beta = makeAgent("agent-3", "Beta");
|
||||||
|
const launch = makeProject("project-1", "Launch");
|
||||||
|
const launchTwo = makeProject("project-2", "Launch");
|
||||||
|
|
||||||
|
expect(Array.from(buildPortableAgentSlugMap([alphaOne, alphaTwo, beta]).entries())).toEqual([
|
||||||
|
["agent-1", "alpha"],
|
||||||
|
["agent-2", "alpha-2"],
|
||||||
|
["agent-3", "beta"],
|
||||||
|
]);
|
||||||
|
expect(Array.from(buildPortableProjectSlugMap([launch, launchTwo]).entries())).toEqual([
|
||||||
|
["project-1", "launch"],
|
||||||
|
["project-2", "launch-2"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(buildPortableSidebarOrder({
|
||||||
|
agents: [alphaOne, alphaTwo, beta],
|
||||||
|
orderedAgents: [beta, alphaTwo, alphaOne],
|
||||||
|
projects: [launch, launchTwo],
|
||||||
|
orderedProjects: [launchTwo, launch],
|
||||||
|
})).toEqual({
|
||||||
|
agents: ["beta", "alpha-2", "alpha"],
|
||||||
|
projects: ["launch-2", "launch"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
61
ui/src/lib/company-portability-sidebar.ts
Normal file
61
ui/src/lib/company-portability-sidebar.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { Agent, CompanyPortabilitySidebarOrder, Project } from "@paperclipai/shared";
|
||||||
|
import { deriveProjectUrlKey, normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
function uniqueSlug(base: string, used: Set<string>) {
|
||||||
|
if (!used.has(base)) {
|
||||||
|
used.add(base);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 2;
|
||||||
|
while (true) {
|
||||||
|
const candidate = `${base}-${index}`;
|
||||||
|
if (!used.has(candidate)) {
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPortableAgentSlugMap(agents: Agent[]): Map<string, string> {
|
||||||
|
const usedSlugs = new Set<string>();
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
const sortedAgents = [...agents].sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
for (const agent of sortedAgents) {
|
||||||
|
const baseSlug = normalizeAgentUrlKey(agent.name) ?? "agent";
|
||||||
|
byId.set(agent.id, uniqueSlug(baseSlug, usedSlugs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPortableProjectSlugMap(projects: Project[]): Map<string, string> {
|
||||||
|
const usedSlugs = new Set<string>();
|
||||||
|
const byId = new Map<string, string>();
|
||||||
|
const sortedProjects = [...projects].sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
|
for (const project of sortedProjects) {
|
||||||
|
const baseSlug = deriveProjectUrlKey(project.name, project.name);
|
||||||
|
byId.set(project.id, uniqueSlug(baseSlug, usedSlugs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return byId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPortableSidebarOrder(input: {
|
||||||
|
agents: Agent[];
|
||||||
|
orderedAgents: Agent[];
|
||||||
|
projects: Project[];
|
||||||
|
orderedProjects: Project[];
|
||||||
|
}): CompanyPortabilitySidebarOrder | undefined {
|
||||||
|
const agentSlugById = buildPortableAgentSlugMap(input.agents);
|
||||||
|
const projectSlugById = buildPortableProjectSlugMap(input.projects);
|
||||||
|
const sidebar = {
|
||||||
|
agents: input.orderedAgents.map((agent) => agentSlugById.get(agent.id)).filter((slug): slug is string => Boolean(slug)),
|
||||||
|
projects: input.orderedProjects.map((project) => projectSlugById.get(project.id)).filter((slug): slug is string => Boolean(slug)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return sidebar.agents.length > 0 || sidebar.projects.length > 0 ? sidebar : undefined;
|
||||||
|
}
|
||||||
|
|
@ -40,10 +40,10 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CompanyProvider>
|
<BrowserRouter>
|
||||||
<ToastProvider>
|
<CompanyProvider>
|
||||||
<LiveUpdatesProvider>
|
<ToastProvider>
|
||||||
<BrowserRouter>
|
<LiveUpdatesProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<BreadcrumbProvider>
|
<BreadcrumbProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
|
@ -57,10 +57,10 @@ createRoot(document.getElementById("root")!).render(
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</BreadcrumbProvider>
|
</BreadcrumbProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</BrowserRouter>
|
</LiveUpdatesProvider>
|
||||||
</LiveUpdatesProvider>
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</CompanyProvider>
|
||||||
</CompanyProvider>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,32 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
|
Agent,
|
||||||
CompanyPortabilityFileEntry,
|
CompanyPortabilityFileEntry,
|
||||||
CompanyPortabilityExportPreviewResult,
|
CompanyPortabilityExportPreviewResult,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityManifest,
|
CompanyPortabilityManifest,
|
||||||
|
Project,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { useNavigate, useLocation } from "@/lib/router";
|
import { useNavigate, useLocation } from "@/lib/router";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
|
import { projectsApi } from "../api/projects";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { createZipArchive } from "../lib/zip";
|
import { createZipArchive } from "../lib/zip";
|
||||||
|
import { buildInitialExportCheckedFiles } from "../lib/company-export-selection";
|
||||||
|
import { useAgentOrder } from "../hooks/useAgentOrder";
|
||||||
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
|
import { buildPortableSidebarOrder } from "../lib/company-portability-sidebar";
|
||||||
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
|
|
@ -34,11 +44,6 @@ import {
|
||||||
PackageFileTree,
|
PackageFileTree,
|
||||||
} from "../components/PackageFileTree";
|
} from "../components/PackageFileTree";
|
||||||
|
|
||||||
/** Returns true if the path looks like a task file (e.g. tasks/slug/TASK.md or projects/x/tasks/slug/TASK.md) */
|
|
||||||
function isTaskPath(filePath: string): boolean {
|
|
||||||
return /(?:^|\/)tasks\//.test(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the set of agent/project/task slugs that are "checked" based on
|
* Extract the set of agent/project/task slugs that are "checked" based on
|
||||||
* which file paths are in the checked set.
|
* which file paths are in the checked set.
|
||||||
|
|
@ -50,6 +55,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
|
||||||
agents: Set<string>;
|
agents: Set<string>;
|
||||||
projects: Set<string>;
|
projects: Set<string>;
|
||||||
tasks: Set<string>;
|
tasks: Set<string>;
|
||||||
|
routines: Set<string>;
|
||||||
} {
|
} {
|
||||||
const agents = new Set<string>();
|
const agents = new Set<string>();
|
||||||
const projects = new Set<string>();
|
const projects = new Set<string>();
|
||||||
|
|
@ -62,7 +68,7 @@ function checkedSlugs(checkedFiles: Set<string>): {
|
||||||
const taskMatch = p.match(/^tasks\/([^/]+)\//);
|
const taskMatch = p.match(/^tasks\/([^/]+)\//);
|
||||||
if (taskMatch) tasks.add(taskMatch[1]);
|
if (taskMatch) tasks.add(taskMatch[1]);
|
||||||
}
|
}
|
||||||
return { agents, projects, tasks };
|
return { agents, projects, tasks, routines: new Set(tasks) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -77,16 +83,30 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
|
|
||||||
// Sections whose entries are slug-keyed and should be filtered
|
// Sections whose entries are slug-keyed and should be filtered
|
||||||
const filterableSections = new Set(["agents", "projects", "tasks"]);
|
const filterableSections = new Set(["agents", "projects", "tasks", "routines"]);
|
||||||
|
const sidebarSections = new Set(["agents", "projects"]);
|
||||||
|
|
||||||
let currentSection: string | null = null; // top-level key (e.g. "agents")
|
let currentSection: string | null = null; // top-level key (e.g. "agents")
|
||||||
let currentEntry: string | null = null; // slug under that section
|
let currentEntry: string | null = null; // slug under that section
|
||||||
let includeEntry = true;
|
let includeEntry = true;
|
||||||
|
let currentSidebarList: string | null = null;
|
||||||
|
let currentSidebarHeaderLine: string | null = null;
|
||||||
|
let currentSidebarBuffer: string[] = [];
|
||||||
// Collect entries per section so we can omit empty section headers
|
// Collect entries per section so we can omit empty section headers
|
||||||
let sectionHeaderLine: string | null = null;
|
let sectionHeaderLine: string | null = null;
|
||||||
let sectionBuffer: string[] = [];
|
let sectionBuffer: string[] = [];
|
||||||
|
|
||||||
|
function flushSidebarSection() {
|
||||||
|
if (currentSidebarHeaderLine !== null && currentSidebarBuffer.length > 0) {
|
||||||
|
sectionBuffer.push(currentSidebarHeaderLine);
|
||||||
|
sectionBuffer.push(...currentSidebarBuffer);
|
||||||
|
}
|
||||||
|
currentSidebarHeaderLine = null;
|
||||||
|
currentSidebarBuffer = [];
|
||||||
|
}
|
||||||
|
|
||||||
function flushSection() {
|
function flushSection() {
|
||||||
|
flushSidebarSection();
|
||||||
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
|
if (sectionHeaderLine !== null && sectionBuffer.length > 0) {
|
||||||
out.push(sectionHeaderLine);
|
out.push(sectionHeaderLine);
|
||||||
out.push(...sectionBuffer);
|
out.push(...sectionBuffer);
|
||||||
|
|
@ -109,6 +129,11 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
||||||
currentSection = key;
|
currentSection = key;
|
||||||
sectionHeaderLine = line;
|
sectionHeaderLine = line;
|
||||||
continue;
|
continue;
|
||||||
|
} else if (key === "sidebar") {
|
||||||
|
currentSection = key;
|
||||||
|
currentSidebarList = null;
|
||||||
|
sectionHeaderLine = line;
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
currentSection = null;
|
currentSection = null;
|
||||||
out.push(line);
|
out.push(line);
|
||||||
|
|
@ -116,6 +141,32 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentSection === "sidebar") {
|
||||||
|
const sidebarMatch = line.match(/^ ([\w-]+):\s*$/);
|
||||||
|
if (sidebarMatch && !line.startsWith(" ")) {
|
||||||
|
flushSidebarSection();
|
||||||
|
const sidebarKey = sidebarMatch[1];
|
||||||
|
currentSidebarList = sidebarKey && sidebarSections.has(sidebarKey) ? sidebarKey : null;
|
||||||
|
currentSidebarHeaderLine = currentSidebarList ? line : null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarEntryMatch = line.match(/^ - ["']?([^"'\n]+)["']?\s*$/);
|
||||||
|
if (sidebarEntryMatch && currentSidebarList) {
|
||||||
|
const slug = sidebarEntryMatch[1];
|
||||||
|
const sectionSlugs = slugs[currentSidebarList as keyof typeof slugs];
|
||||||
|
if (slug && sectionSlugs.has(slug)) {
|
||||||
|
currentSidebarBuffer.push(line);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSidebarList) {
|
||||||
|
currentSidebarBuffer.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inside a filterable section
|
// Inside a filterable section
|
||||||
if (currentSection && filterableSections.has(currentSection)) {
|
if (currentSection && filterableSections.has(currentSection)) {
|
||||||
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
|
// 2-space indented key = entry slug (slugs may start with digits/hyphens)
|
||||||
|
|
@ -532,6 +583,20 @@ export function CompanyExport() {
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { data: session, isFetched: isSessionFetched } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const { data: agents = [], isFetched: areAgentsFetched } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
const { data: projects = [], isFetched: areProjectsFetched } = useQuery({
|
||||||
|
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||||
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
|
|
@ -541,6 +606,38 @@ export function CompanyExport() {
|
||||||
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
|
||||||
const savedExpandedRef = useRef<Set<string> | null>(null);
|
const savedExpandedRef = useRef<Set<string> | null>(null);
|
||||||
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const visibleAgents = useMemo(
|
||||||
|
() => agents.filter((agent: Agent) => agent.status !== "terminated"),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const visibleProjects = useMemo(
|
||||||
|
() => projects.filter((project: Project) => !project.archivedAt),
|
||||||
|
[projects],
|
||||||
|
);
|
||||||
|
const { orderedAgents } = useAgentOrder({
|
||||||
|
agents: visibleAgents,
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
const { orderedProjects } = useProjectOrder({
|
||||||
|
projects: visibleProjects,
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
const sidebarOrder = useMemo(
|
||||||
|
() => buildPortableSidebarOrder({
|
||||||
|
agents: visibleAgents,
|
||||||
|
orderedAgents,
|
||||||
|
projects: visibleProjects,
|
||||||
|
orderedProjects,
|
||||||
|
}),
|
||||||
|
[orderedAgents, orderedProjects, visibleAgents, visibleProjects],
|
||||||
|
);
|
||||||
|
const sidebarOrderKey = useMemo(
|
||||||
|
() => JSON.stringify(sidebarOrder ?? null),
|
||||||
|
[sidebarOrder],
|
||||||
|
);
|
||||||
|
|
||||||
// Navigate-aware file selection: updates state + URL without page reload.
|
// Navigate-aware file selection: updates state + URL without page reload.
|
||||||
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
// `replace` = true skips history entry (used for initial load); false = pushes (used for clicks).
|
||||||
|
|
@ -584,17 +681,17 @@ export function CompanyExport() {
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
companiesApi.exportPreview(selectedCompanyId!, {
|
companiesApi.exportPreview(selectedCompanyId!, {
|
||||||
include: { company: true, agents: true, projects: true, issues: true },
|
include: { company: true, agents: true, projects: true, issues: true },
|
||||||
|
sidebarOrder,
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setExportData(result);
|
setExportData(result);
|
||||||
setCheckedFiles((prev) => {
|
setCheckedFiles((prev) =>
|
||||||
const next = new Set<string>();
|
buildInitialExportCheckedFiles(
|
||||||
for (const filePath of Object.keys(result.files)) {
|
Object.keys(result.files),
|
||||||
if (prev.has(filePath)) next.add(filePath);
|
result.manifest.issues,
|
||||||
else if (!isTaskPath(filePath)) next.add(filePath);
|
prev,
|
||||||
}
|
),
|
||||||
return next;
|
);
|
||||||
});
|
|
||||||
// Expand top-level dirs (except tasks — collapsed by default)
|
// Expand top-level dirs (except tasks — collapsed by default)
|
||||||
const tree = buildFileTree(result.files);
|
const tree = buildFileTree(result.files);
|
||||||
const topDirs = new Set<string>();
|
const topDirs = new Set<string>();
|
||||||
|
|
@ -633,6 +730,7 @@ export function CompanyExport() {
|
||||||
companiesApi.exportPackage(selectedCompanyId!, {
|
companiesApi.exportPackage(selectedCompanyId!, {
|
||||||
include: { company: true, agents: true, projects: true, issues: true },
|
include: { company: true, agents: true, projects: true, issues: true },
|
||||||
selectedFiles: Array.from(checkedFiles).sort(),
|
selectedFiles: Array.from(checkedFiles).sort(),
|
||||||
|
sidebarOrder,
|
||||||
}),
|
}),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
const resultCheckedFiles = new Set(Object.keys(result.files));
|
const resultCheckedFiles = new Set(Object.keys(result.files));
|
||||||
|
|
@ -654,10 +752,11 @@ export function CompanyExport() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
|
||||||
|
if (!isSessionFetched || !areAgentsFetched || !areProjectsFetched) return;
|
||||||
setExportData(null);
|
setExportData(null);
|
||||||
exportPreviewMutation.mutate();
|
exportPreviewMutation.mutate();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedCompanyId]);
|
}, [selectedCompanyId, isSessionFetched, areAgentsFetched, areProjectsFetched, sidebarOrderKey]);
|
||||||
|
|
||||||
const tree = useMemo(
|
const tree = useMemo(
|
||||||
() => (exportData ? buildFileTree(exportData.files) : []),
|
() => (exportData ? buildFileTree(exportData.files) : []),
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import type {
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
|
||||||
|
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
|
@ -342,6 +345,45 @@ function prefixedName(prefix: string | null, originalName: string): string {
|
||||||
return `${prefix}-${originalName}`;
|
return `${prefix}-${originalName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyImportedSidebarOrder(
|
||||||
|
preview: CompanyPortabilityPreviewResult | null,
|
||||||
|
result: {
|
||||||
|
company: { id: string };
|
||||||
|
agents: Array<{ slug: string; id: string | null }>;
|
||||||
|
projects: Array<{ slug: string; id: string | null }>;
|
||||||
|
},
|
||||||
|
userId: string | null | undefined,
|
||||||
|
) {
|
||||||
|
const sidebar = preview?.manifest.sidebar;
|
||||||
|
if (!sidebar) return;
|
||||||
|
if (!userId?.trim()) return;
|
||||||
|
|
||||||
|
const agentIdBySlug = new Map(
|
||||||
|
result.agents
|
||||||
|
.filter((agent): agent is { slug: string; id: string } => typeof agent.id === "string" && agent.id.length > 0)
|
||||||
|
.map((agent) => [agent.slug, agent.id]),
|
||||||
|
);
|
||||||
|
const projectIdBySlug = new Map(
|
||||||
|
result.projects
|
||||||
|
.filter((project): project is { slug: string; id: string } => typeof project.id === "string" && project.id.length > 0)
|
||||||
|
.map((project) => [project.slug, project.id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedAgentIds = sidebar.agents
|
||||||
|
.map((slug) => agentIdBySlug.get(slug))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
const orderedProjectIds = sidebar.projects
|
||||||
|
.map((slug) => projectIdBySlug.get(slug))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
|
if (orderedAgentIds.length > 0) {
|
||||||
|
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
|
||||||
|
}
|
||||||
|
if (orderedProjectIds.length > 0) {
|
||||||
|
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Conflict resolution UI ───────────────────────────────────────────
|
// ── Conflict resolution UI ───────────────────────────────────────────
|
||||||
|
|
||||||
function ConflictResolutionList({
|
function ConflictResolutionList({
|
||||||
|
|
@ -611,6 +653,11 @@ export function CompanyImport() {
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
|
||||||
// Source state
|
// Source state
|
||||||
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
||||||
|
|
@ -800,6 +847,18 @@ export function CompanyImport() {
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
const importedCompany = await companiesApi.get(result.company.id);
|
const importedCompany = await companiesApi.get(result.company.id);
|
||||||
|
const refreshedSession = currentUserId
|
||||||
|
? null
|
||||||
|
: await queryClient.fetchQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const sidebarOrderUserId =
|
||||||
|
currentUserId
|
||||||
|
?? refreshedSession?.user?.id
|
||||||
|
?? refreshedSession?.session?.userId
|
||||||
|
?? null;
|
||||||
|
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
|
||||||
setSelectedCompanyId(importedCompany.id);
|
setSelectedCompanyId(importedCompany.id);
|
||||||
pushToast({
|
pushToast({
|
||||||
tone: "success",
|
tone: "success",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue