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