You develop in one private monorepo. Some projects in it should be open source, but moving them out would mean giving up the shared setup you built the monorepo for.
OpenRepo copies the projects you choose into a new pnpm workspace that installs and builds on its own. It brings along the internal packages they depend on, trims your lockfile and catalog down to what they use, and rewrites the few config paths that change when a project moves to the top level. Then it commits the result into a local clone of the public repo. You review it and push.
We call one run of this an "eject". This repository is itself an eject of a private monorepo:
every commit on main is one release.
- Only committed files. OpenRepo reads from your latest commit, not from your working directory, so unstaged edits and ignored files can't end up in the public repo.
- No leaked paths or secrets. Before anything is written, OpenRepo scans the whole tree and
stops if it finds a home directory path, a private key, an API token or a
.envfile, unless your config allows that one finding. - A complete workspace. If a selected package depends on another workspace package, that package is copied in too.
- The same versions. The public lockfile is your lockfile with the unused entries removed. OpenRepo checks that no version changed in the process.
- Your files, not generated ones. The public repo's
package.json, README, licence and CI are normal files you keep in the project directory. OpenRepo only generatespnpm-workspace.yamlandpnpm-lock.yaml. - No automatic push. The git plugin commits into a local clone and tells you the command to push. Running an eject never changes anything remote.
- Clear errors. If your setup has something OpenRepo can't handle yet, it stops and tells you what, instead of producing a broken repo.
- Plugins. pnpm, TypeScript, git and secret scanning are plugins. You can write your own.
pnpm add -Dw @openrepo/cli @openrepo/plugin-pnpm @openrepo/plugin-typescript @openrepo/plugin-local-leaks @openrepo/plugin-secretlint @openrepo/plugin-gitNeeds Node 24 or newer, git, and pnpm. Install at the workspace root (-w): the config file imports
the plugins, and the plugins depend on @openrepo/cli.
1. Add the public repo's root files to the project directory.
The directory you set as root becomes the top level of the public repo. Put in it whatever a
standalone repo needs: a package.json with scripts, dev dependencies and packageManager; a
tsconfig.json if you use project references; README.md, LICENSE, .gitignore, and a CI
workflow under .github/workflows/. In the private monorepo these files do nothing (pnpm's
workspace globs match projects/*/packages/*, not projects/thing itself). In the public repo
they are the real root files.
projects/thing/
├── package.json tsconfig.json README.md LICENSE .gitignore .github/workflows/ci.yml
├── openrepo.config.ts ← not exported
├── AGENTS.md ← private notes, not exported
└── packages/… ← the code
2. Write the config.
// projects/thing/openrepo.config.ts
import { defineExport } from '@openrepo/cli';
import { gitCommit } from '@openrepo/plugin-git';
import { localLeaks } from '@openrepo/plugin-local-leaks';
import { pnpm } from '@openrepo/plugin-pnpm';
import { secretlint } from '@openrepo/plugin-secretlint';
import { typescript } from '@openrepo/plugin-typescript';
export default defineExport({
root: 'projects/thing',
include: ['projects/thing', 'biome.json', 'tsconfig.base.json'],
exclude: ['projects/thing/AGENTS.md', 'projects/thing/openrepo.config.ts'],
plugins: [
pnpm(),
typescript(),
localLeaks(),
secretlint(),
gitCommit({
remote: 'git@github.com:you/thing.git',
branch: 'main',
authors: [{ name: 'You', email: 'you@example.com' }],
message: 'v1.0.0',
}),
],
});3. Try it without committing anything.
pnpm openrepo eject projects/thing/openrepo.config.ts --dry-runThis prints every file, which packages were pulled in, which files were generated, and where the
result is. Go to that directory and run the public repo's own checks: pnpm install --frozen-lockfile, lint, typecheck, tests. Fix things until they pass.
4. Eject for real.
pnpm openrepo eject projects/thing/openrepo.config.tsSame as the dry run, then the git plugin commits the result into a local clone of the public repo and prints the path. Look at the commit, change it if you want, and push it yourself.
5. Next release. Bump the version, commit in the monorepo, eject again. The new commit lands on top of the previous one in the same clone.
If the gap between releases runs to days, expect both temp directories to be gone. macOS deletes
files under /var/folders after a few days of disuse but leaves the directory tree standing, so
outDir refuses ("exists and was not written by openrepo", its .openrepo marker having been
eaten) and the clone is a .git with no files in it. Delete both and eject again; the clone is
rebuilt from the remote and nothing is lost.
- Lists the files matched by
includeandexcludeat your latest commit. - Adds every workspace package those files depend on.
excludeis applied again afterwards. - Works out each file's public path: files under
rootlose that prefix, other files keep their path. - Copies the files into a staging directory and runs each plugin's
transformon them. This is where atsconfig.jsonthat pointed at../../../../tsconfig.base.jsongets pointed at../../tsconfig.base.json. - Runs each plugin's
generateto add root files: the trimmedpnpm-workspace.yamland lockfile. - Runs each plugin's
scanover the finished tree. If any finding is not inallowLeaks, it stops. - Moves the result to
outDirand runs each plugin'spostExport.
| Key | Meaning |
|---|---|
root |
Directory that becomes the public root. Files under it lose the prefix; other files keep their path. |
include / exclude |
git pathspecs: directories, files, * and ** work like in .gitignore. exclude always wins, even over packages pulled in as dependencies. |
files |
{ [privatePath]: publicPath } for a file or a directory that has to land somewhere other than its default: a CI workflow the monorepo also runs, or a shared package pulled in from outside root that should sit under internal/ rather than keep its monorepo path. A directory moves with everything under it. |
allowLeaks |
{ path, rule } entries for findings of the leak scan that are meant to be public, like test fixtures. path is a glob over the public path; rule is a scan plugin's <plugin>/<rule>, like local-leaks/path or secretlint/github. Stale entries that match nothing fail the eject. Default []. |
outDir |
Where the result goes. Default is a directory under the OS temp dir, printed on every run. The directory must not exist yet, or must be one a previous eject wrote (it leaves a .openrepo marker). Anything else is refused. |
emptyOutDir |
true to wipe an existing outDir that OpenRepo didn't write. Same idea as Vite's option of the same name. Default false. |
plugins |
The plugins, in the order their hooks should run. |
The CLI has one command: openrepo eject <config> [--dry-run]. --dry-run does everything except
run postExport hooks.
Before anything is copied to outDir or committed, OpenRepo hands every file of the finished tree,
including what plugins changed or generated, to each plugin's scan. It runs on --dry-run too.
If a finding is not allowed, the eject stops and outDir is not touched. The scanning itself lives
in plugins; use both of these:
@openrepo/plugin-local-leaks: paths from your machine and.envfiles.@openrepo/plugin-secretlint: tokens, API keys and private keys.
With no scan plugin, nothing is scanned. A finding is named <plugin>/<rule>, like
secretlint/github. A secret is shown by its first four characters only, so the error itself never
leaks it.
Binary files are scanned for the text inside them, which catches a home path or token in an image's metadata. UTF-16 files are recognised by their byte order mark and scanned as text.
To allow a finding that is meant to be public, like a test fixture:
allowLeaks: [
{ path: 'packages/thing/test/fixtures/**', rule: 'secretlint/github' },
],path is a glob over the public path. * and ** skip dotfiles, so name a .env file outright.
Every entry must match at least one finding; one that matches nothing fails the eject, so the list
cannot quietly outlive the findings it was written for.
pnpm({ settings? })
Finds packages through pnpm-workspace.yaml and generates the public pnpm-workspace.yaml and
pnpm-lock.yaml. The workspace file keeps only the catalog entries the exported packages use. The
lockfile is your lockfile pruned by pnpm install --lockfile-only, then compared entry by entry
with the original; any changed version is an error. pnpm 12's package-manager lock document is
preserved, while OpenRepo compares and prunes the workspace lock document.
Each setting in pnpm-workspace.yaml is either copied (install policy such as minimumReleaseAge),
regenerated (packages, catalogs, overrides), or rejected because it can't work in the public
repo (pnpmfile, sharedWorkspaceLockfile, useLockfile). A setting the plugin hasn't seen
before is also rejected; the error tells you to add it to settings: { keep: [...] } or
settings: { drop: [...] }.
typescript()
Updates relative extends and references[].path entries in tsconfig*.json files so they still
point at the right file after the move. Comments are kept. Pointing at a file that isn't exported
is an error.
localLeaks()
Stops an eject that would publish something from your machine:
local-leaks/path: your home directory, the private repo root, and any home directory path (/Users/<name>,/home/<name>,C:\Users\<name>), also as JSON escapes it.local-leaks/env-file:.envand.env.*files..env.example,.env.sample,.env.templateand.env.schemaare fine.
These are not secrets, so the error shows them in full.
secretlint({ rules? })
Scans the tree for secrets with secretlint's
recommended rules, in-process, with no network calls. On top of them it finds Google API keys,
which the preset misses, and AWS access key ids, which the preset leaves off. rules adds more
secretlint rules.
Nothing in the tree can silence it: .secretlintrc, .secretlintignore and secretlint-disable
comments are not read. A finding meant to be public goes in allowLeaks, named secretlint/<rule>
(secretlint/github, secretlint/privatekey, secretlint/google-api-key).
gitCommit({ remote, branch, dir?, message, authors, tag? })
Clones remote into dir (default: a directory under the OS temp dir, printed on every run), or
reuses it if it's already a clean clone of that remote. Checks out branch, creating it from the
remote's default branch if it doesn't exist yet. Replaces the branch's files with the exported
ones and commits. The first entry in authors is the commit author; the rest become
Co-authored-by lines. The message and authors go through every scan plugin, and a finding stops
the commit before the checkout changes; allowLeaks does not apply to them. It never pushes; it prints the push command.
The plugin ends every commit message with a ref-hash: trailer holding the full SHA of the
commit the tree was ejected from. That is how a public commit is traced back to its source, and a
message that writes its own ref-hash: is refused.
message can be a string or a function. The function receives the export context plus
previous, the commit currently at the tip of the branch (sha, message, and source, the
ref-hash it carries or null), which lets you write a changelog. This repository's own config
does that:
message: ({ outDir, source, previous }) => {
const { version } = JSON.parse(readFileSync(join(outDir, 'packages/cli/package.json'), 'utf8'));
const from = previous?.source ? `${previous.source}..` : '';
const log = execSync(`git log --format='- %s' ${from}${source.sha} -- projects/openrepo`, {
cwd: source.root,
encoding: 'utf8',
});
return `v${version}\n\n${log.trim()}`;
},tag: { name: 'v1.2.0', reachableFrom: 'origin/main' } puts a tag in the clone on the public commit
that holds this eject's tree, so the public repo can build a release when the tag is pushed. Run the
eject on the commit to release. The tag is local, like the commit: push it with the branch
(git push --atomic origin main refs/tags/v1.2.0).
reachableFromis a ref in the private repo. A commit that was never on it is refused before the checkout changes, so a release can only come from your main line.- If the branch does not hold the tree yet, because its tip was ejected from an earlier commit, the tree is committed first and the tag goes on that commit. The plugin never commits an older tree on top of a newer one.
- If the branch has moved past the commit, nothing is committed. The tag goes on the newest commit
ejected from it or from an ancestor of it, found through the
ref-hash:trailers (a commit that changed nothing public has no commit of its own). That commit must hold exactly the tree of the eject, or it fails: the commit changed the public tree but was never ejected itself. - A tag already on that commit is left alone, so a rerun is safe. A tag on any other commit, or a
branch tip with no
ref-hash:to trace it by, is an error.
A plugin is an object with a name and any of five functions.
import type { Plugin } from '@openrepo/cli';
export const licenseHeaders = (header: string): Plugin => ({
name: 'license-headers',
transform: file =>
file.publicPath.endsWith('.ts') ? `${header}\n${file.content}` : file.content,
});| Function | Called | Purpose |
|---|---|---|
packages(repo) |
before files are selected | return the workspace packages and what they depend on, so dependencies get pulled in |
transform(file, paths) |
once per text file | return the file's new content. paths.toPublic(p) and paths.toPrivate(p) convert between the two layouts; paths.publicPaths lists every exported file |
generate(ctx) |
after all files are in the staging directory | add generated files with ctx.emit({ path, content }). You may run a tool in ctx.staging first |
scan(ctx) |
after generate, before anything reaches outDir; runs on --dry-run too |
ctx.files is every file as { path, content }, decoded to text (UTF-8, UTF-16 by its BOM, binary as latin1), and ctx.source.root is the private repo; return what leaks, as { path, line, rule, excerpt, isSecret? }[]. OpenRepo names each <plugin>/<rule>, shows a finding's first 4 characters unless isSecret: false, applies allowLeaks and decides whether to stop |
postExport(ctx) |
after the result is in outDir; skipped on --dry-run |
anything that acts on the finished result, like committing. ctx.scan(files) runs every scan on text that goes public outside the tree, like a commit message |
repo.read(path) returns a file's content at the latest commit. A plugin can't overwrite a file
that was selected from the repo, and can't overwrite a file another plugin generated.
OpenRepo is new and was built for one monorepo first, so it supports what that monorepo uses and not yet every setup out there. We want to get there one real case at a time. Until then, when it meets something it can't handle it stops and says so instead of producing a repo that looks right but isn't. Today it assumes:
- pnpm 9 or newer, with one lockfile for the whole workspace, and
pnpminstalled at the exact version the publicpackage.jsonnames inpackageManager. workspace:*,workspace:^,workspace:~orworkspace:<range>for internal dependencies.workspace:name@…andworkspace:../pathare not supported.- No patched dependencies among the exported packages.
- The exported root has its own
package.json. OpenRepo only generatespnpm-workspace.yamlandpnpm-lock.yaml. - TypeScript path rewriting covers
extendsandreferencesonly. Other settings that point outside the exported files (include,paths,rootDir) are left alone; the public repo's typecheck will show if one is wrong. - No symlinks, no submodules, and no files that
.gitattributesmarksexport-ignore. - Optional peer dependencies stay resolved the way your private lockfile resolved them. pnpm 11 satisfies an optional peer from anywhere in the graph, so a package only a sibling project needed can remain in the public lockfile, keyed differently. Versions never change; the public tree may install more than it strictly needs.
- Windows is untested.
If your monorepo doesn't fit, open an issue and describe it. That's how this list gets shorter.
Issues and feature requests are welcome. Pull requests are disabled, because this repository is an exported copy and a patch merged here would be lost on the next release. Describe the change in an issue, with a diff if you have one, and it will be applied in the source repo and credited.
