Skip to main content

Developing Custom Projects

Users of projen are encouraged to develop and publish their own constructs, both for internal use and for sharing with the community. This document describes the process of developing project constructs.

This guide assumes you've created a new JsiiProject using the projen new command. For more information, see the "building your own" guide.

Starting with a base project

In the src folder of your project, either edit the existing main.ts file or structure it in whichever way you are comfortable with. We're going to extend the GitHubProject instead of BaseProject so we have GitHub Actions workflows available to us immediately.

It's also helpful to start with your own interface that extends your base project's interface. We will be adding parameters later.

import { GitHubProject } from "projen";

export interface MyProjectProps extends GitHubProjectProps {}
export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);
}
}
note

Since this is object-oriented programming, any required parameters from GitHubProject will also be required in your new project and cannot be made optional. Confused? See the components concepts documentation.

Creating unmanaged files

Most projects will have need of files that are not managed by projen. For example, your project may have a JSON file that contains some configuration that is better managed manually, but initial defaults should be available. To create an unmanaged file, use the SampleFile component:

import { GitHubProject, SampleFile } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);

new SampleFile(this, "config.json", {
contents: JSON.stringify({ foo: "bar" }),
});
}
}

The second parameter in SampleFile is the name of the file. The third parameter is an object that contains the initial contents of the file. Since we're writing in TypeScript, we can use any of TypeScript's features to generate the contents of the file.

tip

A common approach is to actually have a sample file in your project's repository, and then use the readFileSync function to read the contents of that file and use it as the initial contents of your SampleFile.

Be sure to include any files you want to copy to your project's lib folder.

Copying files to a project

If you are reading sample files from your project's repository, those must be copied to the project's lib folder. This can be done using the project's compileTask.exec() method.

import { GitHubProject } from "projen";

export interface MyProjectProps extends GitHubProjectProps {}
export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);

this.compileTask.exec("cp src/files/* lib/files");
}
}

Not copying the files will result in an error when you try to create your project, similar to: Error: ENOENT: no such file or directory, open '/path/to/project/lib/files/file.txt'.

Creating managed files

Managed files are files that are generated by projen and are updated when the project is synthesized. For example, your deployment software may require a YAML file that is mostly boilerplate with some values that need to be filled in per repository. The benefit projen provides is that these values can be passed in as parameters to your project and the file will be updated the next time you run npx projen. The file contents and defaults can also be version controlled. For example, as an infrastructure team makes changes to the file, your projen project can be updated to reflect those changes without needing to manually edit anything.

To create a managed file, you can use the TextFile component (although you can start from any base class):

import { FileBase, GitHubProject } from "projen";

export interface MyProjectProps extends GitHubProjectProps {}
export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);

const contents = `
# ${options.name}

- name: ${options.name}
run: echo "Hello, world!"
`;

new TextFile(this, "config.yaml", {
contents: contents,
});
}
}
tip

As with unmanaged files, it may be best to separate the contents of your managed file into a separate file as a function that accepts parameters and returns the contents of the file.

Be sure to include any files you want to copy to your project's lib folder.

Creating multiple files and folder structures

projen has a SampleDir construct that allows you to specify an object of files to create with their contents. It can be used to create a common folder structure for your project.

import { GitHubProject, SampleDir } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);

new SampleDir(this, "src", {
files: {
"index.ts": "export * from './lib';",
"lib/index.ts": "export class MyProject {}",
},
});
}
}

The above example is simple, but can be as complex as needed for your use case.

tip

Use multiple instances of the SampleDir construct to give development teams options for what infrastructure tooling they want to use. For example, if your organization allows Terraform or Serverless Framework for deploying infrastructure, you can create a terraform and serverless directory with the appropriate files for each tool. Creation of the directory can be controlled by a parameter.

Be sure to include any files you want to copy to your project's lib folder.

Setting default values

Sometimes you may want to enforce default values for parameters. For example, you may want to enforce that the licensed parameter is always false, thereby disabling the LICENSE file from being generated. This can be done with your base class' required values or optional parameters that you provide.

import { GitHubProject } from "projen";

export interface MyProjectProps extends GitHubProjectProps {
/**
* Whether or not to create default precommit hooks.
* @default true
*/
createPrecommitHook?: boolean;
}
export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
const createPrecommitHook = options.createPrecommitHook ?? true;
super({
...options, // pass through all options from the base class
licensed: false, // enforce that base parameter 'licensed' is always false
createPrecommitHook, // accepts the user's value or the default value
});
}
}
tip

Since we've used the spread operator to pass through all options provided by the user, when we explicitly define licensed as false it will override any value provided by the user. If we wanted to allow the user to override the licensed parameter, we could do so by adding it to the interface MyProjectProps and giving it a default value, similar to how we did with createPrecommitHook.

Setting default .gitignore values

It may be appropriate for your project to enforce a default .gitignore determined by your organization. This can be done by overriding the gitignore property of your project.

import { GitHubProject } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);
}

this.gitignore.addPatterns('.DS_Store', '.idea', '*.csv');
this.gitignore.exclude('*.log'); // Functionally the same as .addPatterns
}

For more information on the IgnoreFile class, see the API documentation.

Default Pull Request template

If your organization has a standard pull request template, you can enforce it by creating a PullRequestTemplate construct in your project.

import { PullRequestTemplate } from "projen/lib/github";
import { GitHubProject } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);

new PullRequestTemplate(this.github!, {
lines: [
"## What is this PR for?",
"",
"## What type of PR is it?",
"",
"- [ ] Bug fix",
"- [ ] Feature",
"- [ ] Documentation update",
"- [ ] Other, please describe:",
"",
"## What is the new behavior?",
"",
"## Does this PR introduce a breaking change?",
"",
"- [ ] Yes",
"- [ ] No",
"",
"## Other information",
"",
"## Checklist:",
"",
"- [ ] Code review",
"- [ ] Tests",
"- [ ] Documentation",
"",
],
});

// Another option is read it from a file:
new PullRequestTemplate(this.github!, {
lines: fs
.readFileSync(path.join(__dirname, "pr-template.md"))
.toString()
.split("\n"),
});

// Be sure to only specify one of the above. If you copy both, only the second will be used.
}
}
tip

The above code couples the pull request template contents tightly with the project. It would be more reusable to create a separate construct that accepts the contents of the pull request template as a parameter, perhaps with a header or footer that is always included by default.

Executing commands

Sometimes you may want to execute a command as part of your project's synthesis. For example, if you operate using GitFlow, you may want to automatically create a develop branch when your project is synthesized.

import { exec } from "child_process";
import { GitHubProject } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);

exec("git rev-parse --verify develop", (err) => {
if (err) {
exec("git checkout -b develop");
exec("git branch -M develop");
}
});
}
}
warning

Exercise caution when executing commands as part of your project's synthesis. Users may not have the binaries you want to execute, or those binaries are only available on one platform, narrowing the scope of your project's portability. Using system-level commands such as rm or mv could also cause unintended consequences, including making your project unusable.

Populating default dependencies

If your project has dependencies that are required for all iterations of that project, you can enforce them by overriding the deps property of your project. By using JavaScript's splat operator, you can be sure those dependencies are always included.

import { GitHubProject } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);
}

this.deps.addDeps(...options.deps, ...["aws-sdk", "cdk8s", "cdk8s-plus"]);
}
warning

Be careful about default versions of your dependencies. Requiring a specific version of a library that is incompatible with other libraries in your project will require an upstream change to the projen project and block developers from their work.

It's important to make the experience of using projen as seamless as possible for your users or they will not use it, including manually overriding managed files.

The above code is technically correct and projen will allow it. However, because of the problems it will create for both the users of the project and the maintainers of the project, it is not recommended - we could even go so far as to say it is wrong.

Consider instead introducing a property that allows users to specify the version of the dependency they want to use.

import { GitHubProject } from "projen";

export interface MyProjectProps extends GitHubProjectProps {
/**
* The version of the aws-sdk to use.
* @default "2.1031.0"
*/
awsSdkVersion?: string;
}

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);
}
const awsSdkVersion = options.awsSdkVersion ?? "2.1031.0";

this.deps.addDeps(...options.deps, ...[`aws-sdk@${awsSdkVersion}`]);
}

Creating default tasks

projen comes with a number of tasks that can be used to automate common tasks. They can all be removed or overriden with the exception of build, which can only be overridden. projen tasks are available as scripts in your package.json file.

import { GitHubProject } from "projen";

export class MyProject extends GitHubProject {
constructor(options: MyProjectProps) {
super(options);
}

this.addTask("docker-build", {
exec: "docker build -t myproject:latest .",
receiveArgs: false,
});
this.addTask("docker-stable", {
exec: "docker build -t myproject:stable .",
receiveArgs: false,
});
}

Note that we are explicitly not passing arguments to the command. Anything added after the command specified in exec would cause an error. Some commands you will want to set receiveArgs to true. For more information, see the docs on tasks.

These tasks can be executed using npx projen or your package manager's default for executing scripts. For example, if you use npm, you can run npm run docker-build to execute the docker-build task.

Making small changes to published projects

Sometimes you may find a projen project that is close to what you need, but you need to make a small change to it. For example, you may want to remove an automatically created config.json file from a project. You can do this by creating a new class that extends the class you want to modify and using the base project's tryRemoveFile() method.

export class MyChangedProject extends MyProject {
constructor(options: MyProjectProps) {
super(options);
}

this.tryRemoveFile("config.json");
}

Preparing for publishing

Before publishing your project, you will need to make sure all your exported classes and interfaces are exported from your project's index.ts file. This is the file that will be imported when a user runs projen new.

export * from "./my-project";

Publishing your project to GitHub Packages

projen's JsiiProject projects are published to any Node Package Manager (NPM) repository. This includes the public NPM registry, GitHub Packages, and private registries. By default, the JsiiProject will try to publish to npm. It's sometimes simpler to publish to GitHub Packages.

Here are some settings that must be configured in your project's projenrc file:

const project = new JsiiProject({
// ...
const repoUrl = 'https://github.com/githubuser/my-project.git';

name: '@githubuser/my-project', // Scope the package to your GitHub username
repositoryUrl: repoUrl, // Required for GitHub Packages
repository: repoUrl, // Required for GitHub Packages
npmRegistryUrl: 'https://npm.pkg.github.com', // Required for GitHub Packages
releaseToNpm: true, // Required for GitHub Packages
depsUpgradeOptions: {
workflowOptions: {
projenCredentials:
GithubCredentials.fromPersonalAccessToken({ secret: 'GITHUB_TOKEN' }),
},
},
});
// If you get an error in your GHA workflow to publish to GitHub Packages,
// try this override to assign the appropriate permissions to the token:
const upgradeMain = project.tryFindObjectFile('.github/workflows/upgrade-main.yml');
upgradeMain?.addOverride('jobs.pr.steps.4.with.token', '${{ secrets.GITHUB_TOKEN }}');
upgradeMain?.addOverride('jobs.pr.permissions.pull-requests', 'write');
upgradeMain?.addOverride('jobs.pr.permissions.contents', 'write');

With these settings, merges to trunk will publish your project, which can then be used with the projen new command.

By default NPM will not know to use GitHub packages for your username unless you add the following to your ~/.npmrc file (create one if it doesn't exist):

@githubuser:registry=https://npm.pkg.github.com

Private repositories will need a GitHub token added to ~/.npmrc:

//npm.pkg.github.com/:_authToken=YOUR_TOKEN_HERE

Now, you can run the following to create a new project using your published package:

npx projen new myproject --from "@githubuser/my-project"