2026-03-19 07:24:04 -05:00
import { Readable } from "node:stream" ;
2026-03-14 09:46:16 -05:00
import { beforeEach , describe , expect , it , vi } from "vitest" ;
2026-03-19 07:28:26 -05:00
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared" ;
2026-03-14 09:46:16 -05:00
const companySvc = {
getById : vi.fn ( ) ,
create : vi.fn ( ) ,
update : vi.fn ( ) ,
} ;
const agentSvc = {
list : vi.fn ( ) ,
create : vi.fn ( ) ,
update : vi.fn ( ) ,
} ;
const accessSvc = {
ensureMembership : vi.fn ( ) ,
2026-03-18 21:54:10 -05:00
listActiveUserMemberships : vi.fn ( ) ,
copyActiveUserMemberships : vi.fn ( ) ,
2026-03-20 07:01:42 -05:00
setPrincipalPermission : vi.fn ( ) ,
2026-03-14 09:46:16 -05:00
} ;
const projectSvc = {
list : vi.fn ( ) ,
create : vi.fn ( ) ,
update : vi.fn ( ) ,
2026-03-23 11:14:01 -05:00
createWorkspace : vi.fn ( ) ,
listWorkspaces : vi.fn ( ) ,
2026-03-14 09:46:16 -05:00
} ;
const issueSvc = {
list : vi.fn ( ) ,
getById : vi.fn ( ) ,
getByIdentifier : vi.fn ( ) ,
create : vi.fn ( ) ,
} ;
2026-03-23 11:14:01 -05:00
const routineSvc = {
list : vi.fn ( ) ,
getDetail : vi.fn ( ) ,
create : vi.fn ( ) ,
createTrigger : vi.fn ( ) ,
} ;
2026-03-14 18:59:26 -05:00
const companySkillSvc = {
list : vi.fn ( ) ,
2026-03-16 17:45:28 -05:00
listFull : vi.fn ( ) ,
2026-03-14 18:59:26 -05:00
readFile : vi.fn ( ) ,
importPackageFiles : vi.fn ( ) ,
} ;
2026-03-19 07:24:04 -05:00
const assetSvc = {
getById : vi.fn ( ) ,
create : vi.fn ( ) ,
} ;
2026-03-17 13:42:00 -05:00
const agentInstructionsSvc = {
exportFiles : vi.fn ( ) ,
materializeManagedBundle : vi.fn ( ) ,
} ;
2026-03-14 09:46:16 -05:00
vi . mock ( "../services/companies.js" , ( ) = > ( {
companyService : ( ) = > companySvc ,
} ) ) ;
vi . mock ( "../services/agents.js" , ( ) = > ( {
agentService : ( ) = > agentSvc ,
} ) ) ;
vi . mock ( "../services/access.js" , ( ) = > ( {
accessService : ( ) = > accessSvc ,
} ) ) ;
vi . mock ( "../services/projects.js" , ( ) = > ( {
projectService : ( ) = > projectSvc ,
} ) ) ;
vi . mock ( "../services/issues.js" , ( ) = > ( {
issueService : ( ) = > issueSvc ,
} ) ) ;
2026-03-23 11:14:01 -05:00
vi . mock ( "../services/routines.js" , ( ) = > ( {
routineService : ( ) = > routineSvc ,
} ) ) ;
2026-03-14 18:59:26 -05:00
vi . mock ( "../services/company-skills.js" , ( ) = > ( {
companySkillService : ( ) = > companySkillSvc ,
} ) ) ;
2026-03-19 07:24:04 -05:00
vi . mock ( "../services/assets.js" , ( ) = > ( {
assetService : ( ) = > assetSvc ,
} ) ) ;
2026-03-17 13:42:00 -05:00
vi . mock ( "../services/agent-instructions.js" , ( ) = > ( {
agentInstructionsService : ( ) = > agentInstructionsSvc ,
} ) ) ;
2026-03-20 15:04:55 -05:00
vi . mock ( "../routes/org-chart-svg.js" , ( ) = > ( {
renderOrgChartPng : vi.fn ( async ( ) = > Buffer . from ( "png" ) ) ,
} ) ) ;
2026-03-23 06:47:32 -05:00
const { companyPortabilityService , parseGitHubSourceUrl } = await import ( "../services/company-portability.js" ) ;
2026-03-14 09:46:16 -05:00
2026-03-19 07:28:26 -05:00
function asTextFile ( entry : CompanyPortabilityFileEntry | undefined ) {
expect ( typeof entry ) . toBe ( "string" ) ;
return typeof entry === "string" ? entry : "" ;
}
2026-03-14 09:46:16 -05:00
describe ( "company portability" , ( ) = > {
2026-03-16 18:27:20 -05:00
const paperclipKey = "paperclipai/paperclip/paperclip" ;
const companyPlaybookKey = "company/company-1/company-playbook" ;
2026-03-14 09:46:16 -05:00
beforeEach ( ( ) = > {
vi . clearAllMocks ( ) ;
companySvc . getById . mockResolvedValue ( {
id : "company-1" ,
name : "Paperclip" ,
description : null ,
2026-03-18 16:54:25 -05:00
issuePrefix : "PAP" ,
2026-03-14 09:46:16 -05:00
brandColor : "#5c5fff" ,
2026-03-19 07:24:04 -05:00
logoAssetId : null ,
logoUrl : null ,
2026-03-14 09:46:16 -05:00
requireBoardApprovalForNewAgents : true ,
} ) ;
agentSvc . list . mockResolvedValue ( [
{
id : "agent-1" ,
name : "ClaudeCoder" ,
status : "idle" ,
role : "engineer" ,
title : "Software Engineer" ,
icon : "code" ,
reportsTo : null ,
capabilities : "Writes code" ,
adapterType : "claude_local" ,
adapterConfig : {
promptTemplate : "You are ClaudeCoder." ,
2026-03-14 18:59:26 -05:00
paperclipSkillSync : {
2026-03-16 18:27:20 -05:00
desiredSkills : [ paperclipKey ] ,
2026-03-14 18:59:26 -05:00
} ,
2026-03-14 09:46:16 -05:00
instructionsFilePath : "/tmp/ignored.md" ,
cwd : "/tmp/ignored" ,
command : "/Users/dotta/.local/bin/claude" ,
model : "claude-opus-4-6" ,
env : {
ANTHROPIC_API_KEY : {
type : "secret_ref" ,
secretId : "secret-1" ,
version : "latest" ,
} ,
GH_TOKEN : {
type : "secret_ref" ,
secretId : "secret-2" ,
version : "latest" ,
} ,
PATH : {
type : "plain" ,
value : "/usr/bin:/bin" ,
} ,
} ,
} ,
runtimeConfig : {
heartbeat : {
intervalSec : 3600 ,
} ,
} ,
budgetMonthlyCents : 0 ,
permissions : {
canCreateAgents : false ,
} ,
metadata : null ,
} ,
2026-03-14 18:59:26 -05:00
{
id : "agent-2" ,
name : "CMO" ,
status : "idle" ,
role : "cmo" ,
title : "Chief Marketing Officer" ,
icon : "globe" ,
reportsTo : null ,
capabilities : "Owns marketing" ,
adapterType : "claude_local" ,
adapterConfig : {
promptTemplate : "You are CMO." ,
} ,
runtimeConfig : {
heartbeat : {
intervalSec : 3600 ,
} ,
} ,
budgetMonthlyCents : 0 ,
permissions : {
canCreateAgents : false ,
} ,
metadata : null ,
} ,
2026-03-14 09:46:16 -05:00
] ) ;
projectSvc . list . mockResolvedValue ( [ ] ) ;
2026-03-23 11:14:01 -05:00
projectSvc . createWorkspace . mockResolvedValue ( null ) ;
projectSvc . listWorkspaces . mockResolvedValue ( [ ] ) ;
2026-03-14 09:46:16 -05:00
issueSvc . list . mockResolvedValue ( [ ] ) ;
issueSvc . getById . mockResolvedValue ( null ) ;
issueSvc . getByIdentifier . mockResolvedValue ( null ) ;
2026-03-23 11:14:01 -05:00
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 ( ) ,
} ) ) ;
2026-03-16 17:45:28 -05:00
const companySkills = [
2026-03-14 18:59:26 -05:00
{
id : "skill-1" ,
companyId : "company-1" ,
2026-03-16 18:27:20 -05:00
key : paperclipKey ,
2026-03-14 18:59:26 -05:00
slug : "paperclip" ,
name : "paperclip" ,
description : "Paperclip coordination skill" ,
markdown : "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n" ,
sourceType : "github" ,
sourceLocator : "https://github.com/paperclipai/paperclip/tree/master/skills/paperclip" ,
sourceRef : "0123456789abcdef0123456789abcdef01234567" ,
trustLevel : "markdown_only" ,
compatibility : "compatible" ,
fileInventory : [
{ path : "SKILL.md" , kind : "skill" } ,
{ path : "references/api.md" , kind : "reference" } ,
] ,
metadata : {
sourceKind : "github" ,
owner : "paperclipai" ,
repo : "paperclip" ,
ref : "0123456789abcdef0123456789abcdef01234567" ,
trackingRef : "master" ,
repoSkillDir : "skills/paperclip" ,
} ,
} ,
{
id : "skill-2" ,
companyId : "company-1" ,
2026-03-16 18:27:20 -05:00
key : companyPlaybookKey ,
2026-03-14 18:59:26 -05:00
slug : "company-playbook" ,
name : "company-playbook" ,
description : "Internal company skill" ,
markdown : "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n" ,
sourceType : "local_path" ,
sourceLocator : "/tmp/company-playbook" ,
sourceRef : null ,
trustLevel : "markdown_only" ,
compatibility : "compatible" ,
fileInventory : [
{ path : "SKILL.md" , kind : "skill" } ,
{ path : "references/checklist.md" , kind : "reference" } ,
] ,
metadata : {
sourceKind : "local_path" ,
} ,
} ,
2026-03-16 17:45:28 -05:00
] ;
companySkillSvc . list . mockResolvedValue ( companySkills ) ;
companySkillSvc . listFull . mockResolvedValue ( companySkills ) ;
2026-03-14 18:59:26 -05:00
companySkillSvc . readFile . mockImplementation ( async ( _companyId : string , skillId : string , relativePath : string ) = > {
if ( skillId === "skill-2" ) {
return {
skillId ,
path : relativePath ,
kind : relativePath === "SKILL.md" ? "skill" : "reference" ,
content : relativePath === "SKILL.md"
? "---\nname: company-playbook\ndescription: Internal company skill\n---\n\n# Company Playbook\n"
: "# Checklist\n" ,
language : "markdown" ,
markdown : true ,
editable : true ,
} ;
}
return {
skillId ,
path : relativePath ,
kind : relativePath === "SKILL.md" ? "skill" : "reference" ,
content : relativePath === "SKILL.md"
? "---\nname: paperclip\ndescription: Paperclip coordination skill\n---\n\n# Paperclip\n"
: "# API\n" ,
language : "markdown" ,
markdown : true ,
editable : false ,
} ;
} ) ;
companySkillSvc . importPackageFiles . mockResolvedValue ( [ ] ) ;
2026-03-19 07:24:04 -05:00
assetSvc . getById . mockReset ( ) ;
assetSvc . getById . mockResolvedValue ( null ) ;
assetSvc . create . mockReset ( ) ;
2026-03-20 07:01:42 -05:00
accessSvc . setPrincipalPermission . mockResolvedValue ( undefined ) ;
2026-03-19 07:24:04 -05:00
assetSvc . create . mockResolvedValue ( {
id : "asset-created" ,
} ) ;
2026-03-18 21:54:10 -05:00
accessSvc . listActiveUserMemberships . mockResolvedValue ( [
{
id : "membership-1" ,
companyId : "company-1" ,
principalType : "user" ,
principalId : "user-1" ,
membershipRole : "owner" ,
status : "active" ,
} ,
] ) ;
accessSvc . copyActiveUserMemberships . mockResolvedValue ( [ ] ) ;
2026-03-17 13:42:00 -05:00
agentInstructionsSvc . exportFiles . mockImplementation ( async ( agent : { name : string } ) = > ( {
files : { "AGENTS.md" : agent . name === "CMO" ? "You are CMO." : "You are ClaudeCoder." } ,
entryFile : "AGENTS.md" ,
warnings : [ ] ,
} ) ) ;
agentInstructionsSvc . materializeManagedBundle . mockImplementation ( async ( agent : { adapterConfig : Record < string , unknown > } ) = > ( {
bundle : null ,
adapterConfig : {
. . . agent . adapterConfig ,
instructionsBundleMode : "managed" ,
instructionsRootPath : ` /tmp/ ${ agent . id } ` ,
instructionsEntryFile : "AGENTS.md" ,
instructionsFilePath : ` /tmp/ ${ agent . id } /AGENTS.md ` ,
} ,
} ) ) ;
2026-03-14 09:46:16 -05:00
} ) ;
2026-03-23 06:47:32 -05:00
it ( "parses canonical GitHub import URLs with explicit ref and package path" , ( ) = > {
expect (
parseGitHubSourceUrl ( "https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack" ) ,
) . toEqual ( {
owner : "paperclipai" ,
repo : "companies" ,
ref : "feature/demo" ,
basePath : "gstack" ,
companyPath : "gstack/COMPANY.md" ,
} ) ;
} ) ;
it ( "parses canonical GitHub import URLs with explicit companyPath" , ( ) = > {
expect (
parseGitHubSourceUrl (
"https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md" ,
) ,
) . toEqual ( {
owner : "paperclipai" ,
repo : "companies" ,
ref : "abc123" ,
basePath : "gstack" ,
companyPath : "gstack/COMPANY.md" ,
} ) ;
} ) ;
2026-03-14 18:59:26 -05:00
it ( "exports referenced skills as stubs by default with sanitized Paperclip extension data" , async ( ) = > {
2026-03-14 09:46:16 -05:00
const portability = companyPortabilityService ( { } as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
} ) ;
2026-03-19 07:28:26 -05:00
expect ( asTextFile ( exported . files [ "COMPANY.md" ] ) ) . toContain ( 'name: "Paperclip"' ) ;
expect ( asTextFile ( exported . files [ "COMPANY.md" ] ) ) . toContain ( 'schema: "agentcompanies/v1"' ) ;
expect ( asTextFile ( exported . files [ "agents/claudecoder/AGENTS.md" ] ) ) . toContain ( "You are ClaudeCoder." ) ;
expect ( asTextFile ( exported . files [ "agents/claudecoder/AGENTS.md" ] ) ) . toContain ( "skills:" ) ;
expect ( asTextFile ( exported . files [ "agents/claudecoder/AGENTS.md" ] ) ) . toContain ( ` - " ${ paperclipKey } " ` ) ;
expect ( asTextFile ( exported . files [ "agents/cmo/AGENTS.md" ] ) ) . not . toContain ( "skills:" ) ;
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/paperclip/SKILL.md" ] ) ) . toContain ( "metadata:" ) ;
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/paperclip/SKILL.md" ] ) ) . toContain ( 'kind: "github-dir"' ) ;
2026-03-18 16:54:25 -05:00
expect ( exported . files [ "skills/paperclipai/paperclip/paperclip/references/api.md" ] ) . toBeUndefined ( ) ;
2026-03-19 07:28:26 -05:00
expect ( asTextFile ( exported . files [ "skills/company/PAP/company-playbook/SKILL.md" ] ) ) . toContain ( "# Company Playbook" ) ;
expect ( asTextFile ( exported . files [ "skills/company/PAP/company-playbook/references/checklist.md" ] ) ) . toContain ( "# Checklist" ) ;
2026-03-14 09:46:16 -05:00
2026-03-19 07:28:26 -05:00
const extension = asTextFile ( exported . files [ ".paperclip.yaml" ] ) ;
2026-03-14 09:46:16 -05:00
expect ( extension ) . toContain ( 'schema: "paperclip/v1"' ) ;
expect ( extension ) . not . toContain ( "promptTemplate" ) ;
expect ( extension ) . not . toContain ( "instructionsFilePath" ) ;
expect ( extension ) . not . toContain ( "command:" ) ;
expect ( extension ) . not . toContain ( "secretId" ) ;
expect ( extension ) . not . toContain ( 'type: "secret_ref"' ) ;
expect ( extension ) . toContain ( "inputs:" ) ;
expect ( extension ) . toContain ( "ANTHROPIC_API_KEY:" ) ;
expect ( extension ) . toContain ( 'requirement: "optional"' ) ;
expect ( extension ) . toContain ( 'default: ""' ) ;
2026-03-16 08:55:37 -05:00
expect ( extension ) . not . toContain ( "paperclipSkillSync" ) ;
2026-03-14 09:46:16 -05:00
expect ( extension ) . not . toContain ( "PATH:" ) ;
expect ( extension ) . not . toContain ( "requireBoardApprovalForNewAgents: true" ) ;
expect ( extension ) . not . toContain ( "budgetMonthlyCents: 0" ) ;
expect ( exported . warnings ) . toContain ( "Agent claudecoder command /Users/dotta/.local/bin/claude 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." ) ;
} ) ;
2026-03-14 18:59:26 -05:00
it ( "expands referenced skills when requested" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
expandReferencedSkills : true ,
} ) ;
2026-03-19 07:28:26 -05:00
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/paperclip/SKILL.md" ] ) ) . toContain ( "# Paperclip" ) ;
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/paperclip/SKILL.md" ] ) ) . toContain ( "metadata:" ) ;
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/paperclip/references/api.md" ] ) ) . toContain ( "# API" ) ;
2026-03-18 16:23:19 -05:00
} ) ;
2026-03-19 16:29:11 -05:00
it ( "exports only selected skills when skills filter is provided" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
skills : [ "company-playbook" ] ,
} ) ;
expect ( exported . files [ "skills/company/PAP/company-playbook/SKILL.md" ] ) . toBeDefined ( ) ;
expect ( asTextFile ( exported . files [ "skills/company/PAP/company-playbook/SKILL.md" ] ) ) . toContain ( "# Company Playbook" ) ;
expect ( exported . files [ "skills/paperclipai/paperclip/paperclip/SKILL.md" ] ) . toBeUndefined ( ) ;
} ) ;
it ( "warns and exports all skills when skills filter matches nothing" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
skills : [ "nonexistent-skill" ] ,
} ) ;
expect ( exported . warnings ) . toContainEqual ( expect . stringContaining ( "nonexistent-skill" ) ) ;
expect ( exported . files [ "skills/company/PAP/company-playbook/SKILL.md" ] ) . toBeDefined ( ) ;
expect ( exported . files [ "skills/paperclipai/paperclip/paperclip/SKILL.md" ] ) . toBeDefined ( ) ;
2026-03-18 16:23:19 -05:00
} ) ;
2026-03-19 07:24:04 -05:00
it ( "exports the company logo into images/ and references it from .paperclip.yaml" , async ( ) = > {
const storage = {
getObject : vi.fn ( ) . mockResolvedValue ( {
stream : Readable.from ( [ Buffer . from ( "png-bytes" ) ] ) ,
} ) ,
} ;
companySvc . getById . mockResolvedValue ( {
id : "company-1" ,
name : "Paperclip" ,
description : null ,
issuePrefix : "PAP" ,
brandColor : "#5c5fff" ,
logoAssetId : "logo-1" ,
logoUrl : "/api/assets/logo-1/content" ,
requireBoardApprovalForNewAgents : true ,
} ) ;
assetSvc . getById . mockResolvedValue ( {
id : "logo-1" ,
companyId : "company-1" ,
objectKey : "assets/companies/logo-1" ,
contentType : "image/png" ,
originalFilename : "logo.png" ,
} ) ;
const portability = companyPortabilityService ( { } as any , storage as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : false ,
projects : false ,
issues : false ,
} ,
} ) ;
expect ( storage . getObject ) . toHaveBeenCalledWith ( "company-1" , "assets/companies/logo-1" ) ;
expect ( exported . files [ "images/company-logo.png" ] ) . toEqual ( {
encoding : "base64" ,
data : Buffer.from ( "png-bytes" ) . toString ( "base64" ) ,
contentType : "image/png" ,
} ) ;
expect ( exported . files [ ".paperclip.yaml" ] ) . toContain ( 'logoPath: "images/company-logo.png"' ) ;
} ) ;
2026-03-18 16:54:25 -05:00
it ( "exports duplicate skill slugs into readable namespaced paths" , async ( ) = > {
2026-03-18 16:23:19 -05:00
const portability = companyPortabilityService ( { } as any ) ;
companySkillSvc . readFile . mockImplementation ( async ( _companyId : string , skillId : string , relativePath : string ) = > {
if ( skillId === "skill-local" ) {
return {
skillId ,
path : relativePath ,
kind : "skill" ,
content : "---\nname: release-changelog\n---\n\n# Local Release Changelog\n" ,
language : "markdown" ,
markdown : true ,
editable : true ,
} ;
}
return {
skillId ,
path : relativePath ,
kind : "skill" ,
content : "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n" ,
language : "markdown" ,
markdown : true ,
editable : false ,
} ;
} ) ;
companySkillSvc . listFull . mockResolvedValue ( [
{
id : "skill-local" ,
companyId : "company-1" ,
key : "local/36dfd631da/release-changelog" ,
slug : "release-changelog" ,
name : "release-changelog" ,
description : "Local release changelog skill" ,
markdown : "---\nname: release-changelog\n---\n\n# Local Release Changelog\n" ,
sourceType : "local_path" ,
sourceLocator : "/tmp/release-changelog" ,
sourceRef : null ,
trustLevel : "markdown_only" ,
compatibility : "compatible" ,
fileInventory : [ { path : "SKILL.md" , kind : "skill" } ] ,
metadata : {
sourceKind : "local_path" ,
} ,
} ,
{
id : "skill-paperclip" ,
companyId : "company-1" ,
key : "paperclipai/paperclip/release-changelog" ,
slug : "release-changelog" ,
name : "release-changelog" ,
description : "Bundled release changelog skill" ,
markdown : "---\nname: release-changelog\n---\n\n# Bundled Release Changelog\n" ,
sourceType : "github" ,
sourceLocator : "https://github.com/paperclipai/paperclip/tree/master/skills/release-changelog" ,
sourceRef : "0123456789abcdef0123456789abcdef01234567" ,
trustLevel : "markdown_only" ,
compatibility : "compatible" ,
fileInventory : [ { path : "SKILL.md" , kind : "skill" } ] ,
metadata : {
sourceKind : "paperclip_bundled" ,
owner : "paperclipai" ,
repo : "paperclip" ,
ref : "0123456789abcdef0123456789abcdef01234567" ,
trackingRef : "master" ,
repoSkillDir : "skills/release-changelog" ,
} ,
} ,
] ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
} ) ;
2026-03-19 07:28:26 -05:00
expect ( asTextFile ( exported . files [ "skills/local/release-changelog/SKILL.md" ] ) ) . toContain ( "# Local Release Changelog" ) ;
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/release-changelog/SKILL.md" ] ) ) . toContain ( "metadata:" ) ;
expect ( asTextFile ( exported . files [ "skills/paperclipai/paperclip/release-changelog/SKILL.md" ] ) ) . toContain ( "paperclipai/paperclip/release-changelog" ) ;
2026-03-14 18:59:26 -05:00
} ) ;
2026-03-18 21:54:10 -05:00
it ( "builds export previews without tasks by default" , 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 ,
} ,
] ) ;
issueSvc . list . mockResolvedValue ( [
{
id : "issue-1" ,
identifier : "PAP-1" ,
title : "Write launch task" ,
description : "Task body" ,
projectId : "project-1" ,
assigneeAgentId : "agent-1" ,
status : "todo" ,
priority : "medium" ,
labelIds : [ ] ,
billingCode : null ,
executionWorkspaceSettings : null ,
assigneeAdapterOverrides : null ,
} ,
] ) ;
const preview = await portability . previewExport ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : true ,
} ,
} ) ;
expect ( preview . counts . issues ) . toBe ( 0 ) ;
expect ( preview . fileInventory . some ( ( entry ) = > entry . path . startsWith ( "tasks/" ) ) ) . toBe ( false ) ;
} ) ;
2026-03-23 11:14:01 -05:00
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" ,
} ) ) ;
} ) ;
2026-03-14 09:46:16 -05:00
it ( "reads env inputs back from .paperclip.yaml during preview import" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
} ) ;
const preview = await portability . previewImport ( {
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" ,
} ) ;
expect ( preview . errors ) . toEqual ( [ ] ) ;
expect ( preview . envInputs ) . toEqual ( [
{
key : "ANTHROPIC_API_KEY" ,
description : "Provide ANTHROPIC_API_KEY for agent claudecoder" ,
agentSlug : "claudecoder" ,
kind : "secret" ,
requirement : "optional" ,
defaultValue : "" ,
portability : "portable" ,
} ,
{
key : "GH_TOKEN" ,
description : "Provide GH_TOKEN for agent claudecoder" ,
agentSlug : "claudecoder" ,
kind : "secret" ,
requirement : "optional" ,
defaultValue : "" ,
portability : "portable" ,
} ,
] ) ;
} ) ;
2026-03-14 18:59:26 -05:00
2026-03-23 11:14:01 -05:00
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." ) ;
} ) ;
2026-03-20 14:06:37 -05:00
it ( "imports a vendor-neutral package without .paperclip.yaml" , 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" ,
} ) ;
const preview = await portability . previewImport ( {
source : {
type : "inline" ,
rootPath : "paperclip-demo" ,
files : {
"COMPANY.md" : [
"---" ,
'schema: "agentcompanies/v1"' ,
'name: "Imported Paperclip"' ,
'description: "Portable company package"' ,
"---" ,
"" ,
"# Imported Paperclip" ,
"" ,
] . join ( "\n" ) ,
"agents/claudecoder/AGENTS.md" : [
"---" ,
'name: "ClaudeCoder"' ,
'title: "Software Engineer"' ,
"---" ,
"" ,
"# ClaudeCoder" ,
"" ,
"You write code." ,
"" ,
] . join ( "\n" ) ,
} ,
} ,
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
target : {
mode : "new_company" ,
newCompanyName : "Imported Paperclip" ,
} ,
agents : "all" ,
collisionStrategy : "rename" ,
} ) ;
expect ( preview . errors ) . toEqual ( [ ] ) ;
expect ( preview . manifest . company ? . name ) . toBe ( "Imported Paperclip" ) ;
expect ( preview . manifest . agents ) . toEqual ( [
expect . objectContaining ( {
slug : "claudecoder" ,
name : "ClaudeCoder" ,
adapterType : "process" ,
} ) ,
] ) ;
expect ( preview . envInputs ) . toEqual ( [ ] ) ;
await portability . importBundle ( {
source : {
type : "inline" ,
rootPath : "paperclip-demo" ,
files : {
"COMPANY.md" : [
"---" ,
'schema: "agentcompanies/v1"' ,
'name: "Imported Paperclip"' ,
'description: "Portable company package"' ,
"---" ,
"" ,
"# Imported Paperclip" ,
"" ,
] . join ( "\n" ) ,
"agents/claudecoder/AGENTS.md" : [
"---" ,
'name: "ClaudeCoder"' ,
'title: "Software Engineer"' ,
"---" ,
"" ,
"# ClaudeCoder" ,
"" ,
"You write code." ,
"" ,
] . join ( "\n" ) ,
} ,
} ,
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
target : {
mode : "new_company" ,
newCompanyName : "Imported Paperclip" ,
} ,
agents : "all" ,
collisionStrategy : "rename" ,
} , "user-1" ) ;
expect ( companySvc . create ) . toHaveBeenCalledWith ( expect . objectContaining ( {
name : "Imported Paperclip" ,
description : "Portable company package" ,
} ) ) ;
expect ( agentSvc . create ) . toHaveBeenCalledWith ( "company-imported" , expect . objectContaining ( {
name : "ClaudeCoder" ,
adapterType : "process" ,
} ) ) ;
} ) ;
2026-03-20 08:55:10 -05:00
it ( "treats no-separator auth and api key env names as secrets during export" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
agentSvc . list . mockResolvedValue ( [
{
id : "agent-1" ,
name : "ClaudeCoder" ,
status : "idle" ,
role : "engineer" ,
title : "Software Engineer" ,
icon : "code" ,
reportsTo : null ,
capabilities : "Writes code" ,
adapterType : "claude_local" ,
adapterConfig : {
promptTemplate : "You are ClaudeCoder." ,
env : {
APIKEY : {
type : "plain" ,
value : "sk-plain-api" ,
} ,
GITHUBAUTH : {
type : "plain" ,
value : "gh-auth-token" ,
} ,
PRIVATEKEY : {
type : "plain" ,
value : "private-key-value" ,
} ,
} ,
} ,
runtimeConfig : { } ,
budgetMonthlyCents : 0 ,
permissions : { } ,
metadata : null ,
} ,
] ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
} ) ;
const extension = asTextFile ( exported . files [ ".paperclip.yaml" ] ) ;
expect ( extension ) . toContain ( "APIKEY:" ) ;
expect ( extension ) . toContain ( "GITHUBAUTH:" ) ;
expect ( extension ) . toContain ( "PRIVATEKEY:" ) ;
expect ( extension ) . not . toContain ( "sk-plain-api" ) ;
expect ( extension ) . not . toContain ( "gh-auth-token" ) ;
expect ( extension ) . not . toContain ( "private-key-value" ) ;
expect ( extension ) . toContain ( 'kind: "secret"' ) ;
} ) ;
2026-03-14 18:59:26 -05:00
it ( "imports packaged skills and restores desired skill refs on agents" , 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" ,
} ) ;
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" ) ;
2026-03-20 06:20:30 -05:00
const textOnlyFiles = Object . fromEntries ( Object . entries ( exported . files ) . filter ( ( [ , v ] ) = > typeof v === "string" ) ) ;
expect ( companySkillSvc . importPackageFiles ) . toHaveBeenCalledWith ( "company-imported" , textOnlyFiles , {
2026-03-18 21:54:10 -05:00
onConflict : "replace" ,
} ) ;
2026-03-14 18:59:26 -05:00
expect ( agentSvc . create ) . toHaveBeenCalledWith ( "company-imported" , expect . objectContaining ( {
adapterConfig : expect.objectContaining ( {
paperclipSkillSync : {
2026-03-16 18:27:20 -05:00
desiredSkills : [ paperclipKey ] ,
2026-03-14 18:59:26 -05:00
} ,
} ) ,
} ) ) ;
} ) ;
2026-03-16 10:14:09 -05:00
2026-03-19 07:24:04 -05:00
it ( "imports a packaged company logo and attaches it to the target company" , async ( ) = > {
const storage = {
putFile : vi.fn ( ) . mockResolvedValue ( {
provider : "local_disk" ,
objectKey : "assets/companies/imported-logo" ,
contentType : "image/png" ,
byteSize : 9 ,
sha256 : "logo-sha" ,
originalFilename : "company-logo.png" ,
} ) ,
} ;
companySvc . create . mockResolvedValue ( {
id : "company-imported" ,
name : "Imported Paperclip" ,
logoAssetId : null ,
} ) ;
companySvc . update . mockResolvedValue ( {
id : "company-imported" ,
name : "Imported Paperclip" ,
logoAssetId : "asset-created" ,
} ) ;
agentSvc . create . mockResolvedValue ( {
id : "agent-created" ,
name : "ClaudeCoder" ,
} ) ;
const portability = companyPortabilityService ( { } as any , storage as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
} ) ;
exported . files [ "images/company-logo.png" ] = {
encoding : "base64" ,
data : Buffer.from ( "png-bytes" ) . toString ( "base64" ) ,
contentType : "image/png" ,
} ;
exported . files [ ".paperclip.yaml" ] = ` ${ exported . files [ ".paperclip.yaml" ] } ` . replace (
'brandColor: "#5c5fff"\n' ,
'brandColor: "#5c5fff"\n logoPath: "images/company-logo.png"\n' ,
) ;
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" ) ;
expect ( storage . putFile ) . toHaveBeenCalledWith ( expect . objectContaining ( {
companyId : "company-imported" ,
namespace : "assets/companies" ,
originalFilename : "company-logo.png" ,
contentType : "image/png" ,
body : Buffer.from ( "png-bytes" ) ,
} ) ) ;
expect ( assetSvc . create ) . toHaveBeenCalledWith ( "company-imported" , expect . objectContaining ( {
objectKey : "assets/companies/imported-logo" ,
contentType : "image/png" ,
createdByUserId : "user-1" ,
} ) ) ;
expect ( companySvc . update ) . toHaveBeenCalledWith ( "company-imported" , {
logoAssetId : "asset-created" ,
} ) ;
} ) ;
2026-03-18 21:54:10 -05:00
it ( "copies source company memberships for safe new-company imports" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
companySvc . create . mockResolvedValue ( {
id : "company-imported" ,
name : "Imported Paperclip" ,
} ) ;
agentSvc . create . mockResolvedValue ( {
id : "agent-created" ,
name : "ClaudeCoder" ,
} ) ;
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" ,
} , null , {
mode : "agent_safe" ,
sourceCompanyId : "company-1" ,
} ) ;
expect ( accessSvc . listActiveUserMemberships ) . toHaveBeenCalledWith ( "company-1" ) ;
expect ( accessSvc . copyActiveUserMemberships ) . toHaveBeenCalledWith ( "company-1" , "company-imported" ) ;
expect ( accessSvc . ensureMembership ) . not . toHaveBeenCalledWith ( "company-imported" , "user" , expect . anything ( ) , "owner" , "active" ) ;
2026-03-20 06:20:30 -05:00
const textOnlyFiles = Object . fromEntries ( Object . entries ( exported . files ) . filter ( ( [ , v ] ) = > typeof v === "string" ) ) ;
expect ( companySkillSvc . importPackageFiles ) . toHaveBeenCalledWith ( "company-imported" , textOnlyFiles , {
2026-03-18 21:54:10 -05:00
onConflict : "rename" ,
} ) ;
} ) ;
2026-03-16 10:14:09 -05:00
it ( "imports only selected files and leaves unchecked company metadata alone" , async ( ) = > {
const portability = companyPortabilityService ( { } as any ) ;
const exported = await portability . exportBundle ( "company-1" , {
include : {
company : true ,
agents : true ,
projects : false ,
issues : false ,
} ,
} ) ;
agentSvc . list . mockResolvedValue ( [ ] ) ;
projectSvc . list . mockResolvedValue ( [ ] ) ;
companySvc . getById . mockResolvedValue ( {
id : "company-1" ,
name : "Paperclip" ,
description : "Existing company" ,
brandColor : "#123456" ,
requireBoardApprovalForNewAgents : false ,
} ) ;
agentSvc . create . mockResolvedValue ( {
id : "agent-cmo" ,
name : "CMO" ,
} ) ;
const result = await portability . importBundle ( {
source : {
type : "inline" ,
rootPath : exported.rootPath ,
files : exported.files ,
} ,
include : {
company : true ,
agents : true ,
projects : true ,
issues : true ,
} ,
selectedFiles : [ "agents/cmo/AGENTS.md" ] ,
target : {
mode : "existing_company" ,
companyId : "company-1" ,
} ,
agents : "all" ,
collisionStrategy : "rename" ,
} , "user-1" ) ;
expect ( companySvc . update ) . not . toHaveBeenCalled ( ) ;
expect ( companySkillSvc . importPackageFiles ) . toHaveBeenCalledWith (
"company-1" ,
expect . objectContaining ( {
"COMPANY.md" : expect . any ( String ) ,
"agents/cmo/AGENTS.md" : expect . any ( String ) ,
} ) ,
2026-03-18 21:54:10 -05:00
{
onConflict : "replace" ,
} ,
2026-03-16 10:14:09 -05:00
) ;
expect ( companySkillSvc . importPackageFiles ) . toHaveBeenCalledWith (
"company-1" ,
expect . not . objectContaining ( {
"agents/claudecoder/AGENTS.md" : expect . any ( String ) ,
} ) ,
2026-03-18 21:54:10 -05:00
{
onConflict : "replace" ,
} ,
2026-03-16 10:14:09 -05:00
) ;
expect ( agentSvc . create ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( agentSvc . create ) . toHaveBeenCalledWith ( "company-1" , expect . objectContaining ( {
name : "CMO" ,
} ) ) ;
expect ( result . company . action ) . toBe ( "unchanged" ) ;
expect ( result . agents ) . toEqual ( [
{
slug : "cmo" ,
id : "agent-cmo" ,
action : "created" ,
name : "CMO" ,
reason : null ,
} ,
] ) ;
} ) ;
2026-03-16 12:17:28 -05:00
it ( "applies adapter overrides while keeping imported AGENTS content implicit" , 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" ,
} ) ;
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" ,
adapterOverrides : {
claudecoder : {
adapterType : "codex_local" ,
adapterConfig : {
dangerouslyBypassApprovalsAndSandbox : true ,
instructionsFilePath : "/tmp/should-not-survive.md" ,
} ,
} ,
} ,
} , "user-1" ) ;
expect ( agentSvc . create ) . toHaveBeenCalledWith ( "company-imported" , expect . objectContaining ( {
adapterType : "codex_local" ,
adapterConfig : expect.objectContaining ( {
dangerouslyBypassApprovalsAndSandbox : true ,
} ) ,
} ) ) ;
expect ( agentSvc . create ) . toHaveBeenCalledWith ( "company-imported" , expect . objectContaining ( {
adapterConfig : expect.not.objectContaining ( {
instructionsFilePath : expect.anything ( ) ,
2026-03-17 13:42:00 -05:00
promptTemplate : expect.anything ( ) ,
2026-03-16 12:17:28 -05:00
} ) ,
} ) ) ;
2026-03-17 13:42:00 -05:00
expect ( agentInstructionsSvc . materializeManagedBundle ) . toHaveBeenCalledWith (
expect . objectContaining ( { name : "ClaudeCoder" } ) ,
expect . objectContaining ( {
"AGENTS.md" : expect . stringContaining ( "You are ClaudeCoder." ) ,
} ) ,
expect . objectContaining ( {
clearLegacyPromptTemplate : true ,
replaceExisting : true ,
} ) ,
) ;
2026-03-16 12:17:28 -05:00
} ) ;
2026-03-14 09:46:16 -05:00
} ) ;