Repo Man can back up the dependencies of any repository — not just Swift packages and Git submodules. Write a small JavaScript plugin to teach it how to read your ecosystem's lock file, and it will automatically mirror those dependencies on every backup run.
Repo Man's backup engine discovers dependencies by running detectors against each cloned bare repository. Two detectors ship built in:
.gitmodulesPackage.resolvedThe Integrations system lets you add detectors written in JavaScript. A plugin is a single .js file that declares which manifest files it cares about, and exposes a detect() function that parses them. Repo Man handles all file I/O and calls your function with the raw text content — you just parse it and return a list of Git repository URLs to back up.
Here is the smallest valid plugin. It detects Go modules by reading go.sum and extracting any entries that point to a GitHub repository.
// 1. Stable identifier — becomes the backup subfolder name.
const kindLabel = "go-modules";
// 2. Human-readable name shown in Settings → Integrations.
const displayName = "Go Modules";
// 3. Filenames to search for. Repo Man finds ALL occurrences
// anywhere in the repository tree and calls detect() for each.
const manifestFileNames = ["go.mod"];
// 4. Parse function. Called once per found file.
// fileContent : string — raw text of the file
// filePath : string — path within the repo (e.g. "go.mod")
// Returns : array of { url: string, name: string }
function detect(fileContent, filePath) {
var results = [];
var seen = {};
var lines = fileContent.split("\n");
for (var i = 0; i < lines.length; i++) {
var m = lines[i].match(/^\s+([^\s]+)\s+v/);
if (!m) continue;
var module = m[1];
var url = "https://" + module;
if (seen[url]) continue;
seen[url] = true;
results.push({ url: url, name: module.split("/").pop() });
}
return results;
}
Open Repo Man → Settings (⌘,) and click the Integrations tab.
Click the + button in the "Custom Integrations" section header.
Select your .js plugin file in the file picker.
Repo Man validates the script. If anything is missing or wrong you will see an error describing the problem. Otherwise the plugin appears in the list and is enabled immediately.
Run a backup. Repo Man will call your plugin against every repository it clones.
Resources/SamplePlugins/ folder, or download them here.
Every plugin must declare three top-level constants before the detect() function. They must be const or var declarations at the global scope — not inside any function or block.
A stable, lowercase, hyphenated identifier for this ecosystem. It becomes the name of the backup subdirectory where detected dependencies are stored (e.g. npm-packages → <backupRoot>/npm-packages/github.com/…). Must be unique — cannot conflict with built-in labels (submodules, swift-packages) or other installed plugins.
A human-readable name shown in Settings → Integrations and in log output. Examples: "Node.js (npm)", "CocoaPods", "Cargo (Rust)".
An array of one or more filenames (not paths) to search for in each repository. Repo Man performs a recursive tree search and calls detect() once for each matching file it finds. Must contain at least one non-empty string. Examples: ["package-lock.json"], ["Podfile.lock"], ["Cargo.lock", "Cargo.toml"].
Restrictions enforced at import: each entry must be a bare filename — path separators (/, \) and the sequence .. are not allowed.
detect() FunctionCalled once per manifest file found. Must be a top-level function declaration named exactly detect.
| Parameter | Type | Description |
|---|---|---|
fileContent |
string | The complete UTF-8 text content of the manifest file. Never null or undefined — if the file couldn't be read, detect() is not called. |
filePath |
string | The path of the file relative to the repository root (e.g. "ios/package-lock.json"). Useful for filtering files in monorepos or skipping generated subdirectories. |
detect() must return an array of dependency objects. Each object must have exactly two string properties:
| Property | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | The Git clone URL of the dependency repository. Must be non-empty. Both HTTPS (https://github.com/…) and SSH (git@github.com:…) URLs are accepted. |
name |
string | Yes | A short human-readable name for the dependency. Shown in the repository list and log output. Note: the host strips /, \, and .. from the name before use. Names longer than 256 characters are truncated. |
Objects missing either property, or where either value is an empty string, are silently skipped. Returning an empty array ([]) is valid and means no dependencies were found.
// ✓ Valid return value
return [
{ url: "https://github.com/owner/repo", name: "repo" },
{ url: "https://github.com/another/package.git", name: "package" },
{ url: "git@github.com:owner/private.git", name: "private-dep" },
];
// ✓ Valid — no dependencies found
return [];
// ✗ These entries will be silently dropped
return [
{ url: "", name: "bad" }, // empty url
{ url: "https://…" }, // missing name
null, // not an object
];
Understanding the host's role helps you write correct plugins and avoid duplication.
For each filename listed in your manifestFileNames array, Repo Man recursively walks the entire repository tree using libgit2 and collects all paths whose final component matches exactly. This is done in-process and is fully macOS sandbox-compatible — no shell commands are spawned.
Repo Man deduplicates returned URLs both within a single detect() call and across multiple calls in the same backup session. If your plugin returns the same URL from two different branches, or two different plugins return the same URL, it is only cloned once.
Dependencies discovered by your plugin are backed up using the same credentials as the parent repository. If the parent was cloned via HTTPS with a GitHub token, dependencies on the same host will also use that token. SSH dependencies fall back to the SSH agent.
Detected repositories are backed up to:
<backupRoot> / <kindLabel> / <host> / <path>.git
For example, a plugin with kindLabel = "npm-packages" that returns https://github.com/lodash/lodash would produce:
~/Git-Backups/npm-packages/github.com/lodash/lodash.git
Plugins run inside a bare JavaScriptCore context with additional hardening applied by the host. This means:
fs, require, import, or any Node.js / browser API.fetch, XMLHttpRequest, or WebSocket.setTimeout, setInterval, and requestAnimationFrame are not available.require() does not exist.eval is disabled — replaced with an inert no-op that returns undefined.Function constructor is disabled — new Function("code") throws a TypeError.ObjC and $ globals are explicitly nulled out.detect() call is limited to 10 seconds of wall-clock time. See Runtime Limits.What is available: the full ECMAScript 5/6 standard library — JSON, Array, String, RegExp, Math, Object, Map, Set, and so on.
eval("…") inside a plugin does nothing — it returns undefined silently. new Function("return 1") throws a TypeError. Rewrite any such logic using static parsing.
When you import a plugin, Repo Man validates it before saving. Import will be rejected with a descriptive error if any of the following conditions are met:
| Error | Cause |
|---|---|
| Script too large | The .js file exceeds 512 KB. Split your plugin or remove unnecessary embedded data. |
| Script evaluation failed | The script throws a JavaScript syntax error or runtime exception at the top level during initial evaluation. |
| Missing field | Any of kindLabel, displayName, manifestFileNames, or detect is absent or undefined. |
| Wrong type | A required field exists but is not the expected type (e.g. kindLabel is a number, manifestFileNames is a string instead of an array). |
| Empty manifestFileNames | The manifestFileNames array is empty or contains only empty strings. |
| Invalid manifest filename | An entry in manifestFileNames contains a path separator (/ or \) or the sequence ... |
| Invalid kindLabel format | kindLabel contains characters other than lowercase letters, digits, or hyphens, or starts/ends with a hyphen. Pattern: ^[a-z0-9]+(-[a-z0-9]+)*$. |
| Duplicate kindLabel | kindLabel collides with a built-in detector (submodules, swift-packages) or an already-installed plugin. |
Validation errors during a backup run (e.g. detect() throws or times out) are logged to the system log under the category ScriptedDependencyDetector and silently skipped.
Parses package-lock.json (lockfileVersion 1, 2, and 3). Only packages with a Git clone URL in their resolved field are returned — tarball URLs from the npm registry are ignored.
const kindLabel = "npm-packages";
const displayName = "Node.js (npm)";
const manifestFileNames = ["package-lock.json"];
function detect(fileContent, filePath) {
var lock;
try { lock = JSON.parse(fileContent); } catch (e) { return []; }
var results = [], seen = {};
function addIfGit(url, name) {
if (!url || seen[url]) return;
if (url.indexOf("git+") === 0 || url.indexOf("git://") === 0) {
var clone = url.replace(/^git\+/, "");
seen[clone] = true;
results.push({ url: clone, name: name });
}
}
// lockfileVersion 2/3 — top-level "packages" map
if (lock.packages) {
Object.keys(lock.packages).forEach(function(key) {
if (key === "") return;
var pkg = lock.packages[key];
var name = key.replace(/^node_modules\//, "").split("/").pop();
addIfGit(pkg.resolved, name);
});
return results;
}
// lockfileVersion 1 — top-level "dependencies" map
if (lock.dependencies) {
Object.keys(lock.dependencies).forEach(function(name) {
addIfGit(lock.dependencies[name].resolved, name);
});
}
return results;
}
Parses Podfile.lock and extracts pods whose source is a Git repository (declared with :git:). Registry pods resolved as tarballs are ignored.
const kindLabel = "cocoapods";
const displayName = "CocoaPods";
const manifestFileNames = ["Podfile.lock"];
function detect(fileContent, filePath) {
var results = [], seen = {};
// Match ":git: <url>" lines in EXTERNAL SOURCES / CHECKOUT OPTIONS sections.
var re = /^\s+:git:\s+(.+?)\s*$/gm;
var m;
while ((m = re.exec(fileContent)) !== null) {
var url = m[1].trim();
if (url && !seen[url]) {
seen[url] = true;
results.push({ url: url, name: url.split("/").pop().replace(/\.git$/, "") });
}
}
return results;
}
Parses Cargo.lock (v3 TOML format) and returns crates sourced from Git repositories. Crates.io registry entries are ignored.
const kindLabel = "cargo";
const displayName = "Cargo (Rust)";
const manifestFileNames = ["Cargo.lock"];
function detect(fileContent, filePath) {
var results = [], seen = {};
var blocks = fileContent.split(/\[\[package\]\]/);
for (var i = 1; i < blocks.length; i++) {
var block = blocks[i];
var nm = block.match(/^\s*name\s*=\s*"([^"]+)"/m);
var src = block.match(/^\s*source\s*=\s*"([^"]+)"/m);
if (!nm || !src || src[1].indexOf("git+") !== 0) continue;
var url = src[1].replace(/^git\+/, "").replace(/#[^#]*$/, "");
if (url && !seen[url]) {
seen[url] = true;
results.push({ url: url, name: nm[1] });
}
}
return results;
}
Parses Gemfile.lock and returns gems sourced from Git repositories (GIT sections). RubyGems.org registry gems are ignored.
const kindLabel = "ruby-gems";
const displayName = "Ruby Gems (Bundler)";
const manifestFileNames = ["Gemfile.lock"];
function detect(fileContent, filePath) {
var results = [], seen = {};
var sections = fileContent.split(/^GIT\s*$/m);
for (var i = 1; i < sections.length; i++) {
var end = sections[i].search(/^\S/m);
var block = end > -1 ? sections[i].substring(0, end) : sections[i];
var m = block.match(/^\s+remote:\s+(.+?)\s*$/m);
if (!m) continue;
var url = m[1].trim();
if (url && !seen[url]) {
seen[url] = true;
results.push({ url: url, name: url.split("/").pop().replace(/\.git$/, "") });
}
}
return results;
}
Parses packages.lock.json (NuGet v2 lock file). Only packages with a resolved field containing a GitHub or GitLab URL are returned.
const kindLabel = "nuget-packages";
const displayName = ".NET / NuGet";
const manifestFileNames = ["packages.lock.json"];
function detect(fileContent, filePath) {
var lock;
try { lock = JSON.parse(fileContent); } catch (e) { return []; }
var results = [], seen = {};
var deps = lock.dependencies || {};
Object.keys(deps).forEach(function(framework) {
var pkgs = deps[framework] || {};
Object.keys(pkgs).forEach(function(pkgName) {
var url = (pkgs[pkgName].resolved || "");
if (
(url.indexOf("https://github.com") === 0 ||
url.indexOf("https://gitlab.com") === 0) &&
!seen[url]
) {
seen[url] = true;
results.push({ url: url, name: pkgName });
}
});
});
return results;
}
Parses go.mod and extracts module paths on GitHub, GitLab, or Bitbucket. Other module paths are skipped since they may not correspond to a public Git repository.
const kindLabel = "go-modules";
const displayName = "Go Modules";
const manifestFileNames = ["go.mod"];
function detect(fileContent, filePath) {
var results = [], seen = {};
var knownHosts = ["github.com", "gitlab.com", "bitbucket.org"];
var re = /^\s*(?:require\s+)?([^\s]+)\s+v[\d]/gm;
var m;
while ((m = re.exec(fileContent)) !== null) {
var mod = m[1].trim();
var host = mod.split("/")[0];
if (knownHosts.indexOf(host) === -1) continue;
var url = "https://" + mod;
if (!seen[url]) {
seen[url] = true;
results.push({ url: url, name: mod.split("/").pop() });
}
}
return results;
}
The kindLabel is used as a directory name on disk, so it must be filesystem-safe and stable. All rules below are enforced at import time.
a–z), digits (0–9), and hyphens (-) only.-go and go- are both invalid.^[a-z0-9]+(-[a-z0-9]+)*$submodules, swift-packages).| Good | Bad | Reason |
|---|---|---|
npm-packages | NPM Packages | uppercase / spaces |
go-modules | -go | starts with hyphen |
nuget2 | nuget/v2 | slash in name |
cocoapods | submodules | conflicts with built-in |
Repo Man accepts HTTPS and SSH clone URLs. The host enforces a URL scheme allowlist — results with any other scheme are silently dropped before cloning begins.
| Format | Example | Credentials used |
|---|---|---|
| HTTPS | https://github.com/owner/repohttps://github.com/owner/repo.git |
OAuth token of the parent repo's account (same host) |
| SSH (scp-style) | git@github.com:owner/repo.git |
SSH agent |
| SSH (URL-style) | ssh://git@github.com/owner/repo.git |
SSH agent |
https://, http://, git://, git@, or ssh:// are rejected by the host after detect() returns. This includes file://, data:, and javascript:. Your plugin does not need to filter these, but be aware they are ignored.
.git is optional but be consistent
Two URLs that differ only by the trailing .git suffix will be treated as two separate dependencies and cloned to different paths.
The host enforces the following hard limits on every plugin execution.
| Limit | Value | Behaviour when exceeded |
|---|---|---|
| Script file size | 512 KB | Import is rejected before the script is evaluated. |
| Execution timeout | 10 s per detect() call | Call is abandoned, treated as returning no dependencies. Warning written to system log. |
| Max results per call | 500 objects | Results beyond the first 500 are silently discarded. |
| Max URL length | 2,048 characters | Results with a longer url are silently dropped. |
| Max name length | 256 characters | Names longer than 256 characters are truncated. |
detect() for one manifest file. Each call has its own independent cap.
Wrap any parsing that might fail in a try/catch and return [] on error. An unhandled exception thrown from detect() will be caught by the host, logged to the system log, and treated as "no dependencies found" — the backup continues normally.
function detect(fileContent, filePath) {
var data;
try {
data = JSON.parse(fileContent);
} catch (e) {
return []; // host logs the exception automatically
}
if (!data || typeof data !== "object") return [];
// ... rest of parsing ...
return results;
}
Plugin errors are written to the macOS system log. To view them, open Console.app and filter by:
com.corybohon.Repo-ManScriptedDependencyDetectorOr stream them in Terminal:
log stream --predicate 'subsystem == "com.corybohon.Repo-Man" AND category == "ScriptedDependencyDetector"' --level debug
Repo Man deduplicates URLs across calls, but doing it inside your plugin too avoids redundant work on large lock files.
var seen = {};
function add(url, name) {
if (url && !seen[url]) { seen[url] = true; results.push({ url: url, name: name }); }
}
The filePath parameter lets you skip manifest files in irrelevant subdirectories:
function detect(fileContent, filePath) {
if (filePath.indexOf("test/") !== -1) return [];
if (filePath.indexOf("vendor/") !== -1) return [];
// ... parse ...
}
function nameFromURL(url) {
return url.replace(/\.git$/, "").split("/").pop() || url;
}
You can test your plugin logic in any browser's DevTools console or in Node.js before importing it into Repo Man. Because the API surface is just plain functions and JSON, writing a small test harness is easy:
// test-harness.js (run with: node test-harness.js)
const fs = require("fs");
eval(fs.readFileSync("my-plugin.js", "utf8"));
const sample = fs.readFileSync("sample-lock.json", "utf8");
const result = detect(sample, "package-lock.json");
console.log(result);
require and fs calls above are only in the test harness — they won't be available when Repo Man runs your plugin. Your plugin file itself must not use them.
If your ecosystem uses several different files, list them all in manifestFileNames and use filePath to branch on which one was found:
const manifestFileNames = ["Cargo.lock", "Cargo.toml"];
function detect(fileContent, filePath) {
if (filePath.endsWith("Cargo.lock")) return parseLock(fileContent);
if (filePath.endsWith("Cargo.toml")) return parseToml(fileContent);
return [];
}