alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
562 lines (561 loc) • 18 kB
JavaScript
import { $atom, $context, $inject, $module, $state, AlephaError, z } from "alepha";
import { PackageManagerUtils } from "alepha/cli";
import { $command } from "alepha/command";
import { $logger, ConsoleColorProvider } from "alepha/logger";
import { FileSystemProvider, ShellProvider } from "alepha/system";
//#region ../../src/cli/vendor/atoms/vendorOptions.ts
/**
* Vendor configuration atom.
*
* Filled from the `vendor` section of `alepha.config.ts`.
* Read by `VendorCommand` to resolve remote, branch, and packages.
*/
const vendorOptions = $atom({
name: "alepha.cli.vendor.options",
description: "Vendor synchronization configuration",
schema: z.object({
/**
* Git remote URL.
*
* @default "git@github.com:feunard/alepha.git"
*/
remote: z.text().optional(),
/**
* Branch to sync from.
*
* @default "main"
*/
branch: z.text().optional(),
/**
* Parent directory holding the vendored packages in the local project.
* Relative to the project root. Also where the `vendor.json` lock file
* is written. The remote is always expected to lay its packages out
* under `packages/`.
*
* @default ".vendor"
*/
dir: z.text().optional(),
/**
* Package directory names under `dir` to sync.
*
* @example ["alepha", "@alepha/payments-stripe"]
*/
packages: z.array(z.text())
}).optional()
});
//#endregion
//#region ../../src/cli/vendor/services/VendorService.ts
/**
* Parent directory of vendored packages on the remote. Hardcoded because the
* Alepha monorepo lays its packages out under `packages/` and the vendor
* tool only targets that layout.
*/
const REMOTE_DIR = "packages";
/**
* Handles syncing and diffing vendored packages from a remote git repository.
*/
var VendorService = class {
log = $logger();
shell = $inject(ShellProvider);
fs = $inject(FileSystemProvider);
/**
* Sync vendored packages from a remote repository.
*
* Without `force`: checks for local modifications by comparing the local
* copy against the last-synced commit (stored in `<dir>/vendor.json`).
* If modifications are found, aborts without touching local files.
*
* With `force` (or first sync): replaces local copies unconditionally.
*/
async sync(options) {
const synced = [];
const errors = [];
if (!options.force) {
const lock = await this.readLock(options.root, options.dir);
if (lock) {
let baselineDir;
try {
baselineDir = await this.cloneAtCommit(options.remote, lock.commit);
const diffResult = await this.diffFromClone(options.root, baselineDir, options.dir, options.packages);
if (diffResult.totalChanges > 0) return {
synced: [],
errors: [],
aborted: diffResult
};
} finally {
if (baselineDir) await this.fs.rm(baselineDir, {
recursive: true,
force: true
});
}
}
}
let tmpDir;
try {
tmpDir = await this.cloneRemote(options.remote, options.branch);
for (const pkg of options.packages) {
const remotePkgDir = this.fs.join(tmpDir, REMOTE_DIR, pkg);
const localPkgDir = this.fs.join(options.root, options.dir, pkg);
if (!await this.fs.exists(remotePkgDir)) {
errors.push(`Package "${pkg}" not found in remote`);
continue;
}
this.log.debug(`Syncing package: ${pkg}`);
await this.fs.rm(localPkgDir, {
recursive: true,
force: true
});
await this.fs.cp(remotePkgDir, localPkgDir, { recursive: true });
await this.removeIgnoredFiles(localPkgDir);
synced.push(pkg);
}
const commit = await this.getCommitHash(tmpDir);
await this.writeLock(options.root, options.dir, {
remote: options.remote,
commit
});
} finally {
if (tmpDir) await this.fs.rm(tmpDir, {
recursive: true,
force: true
});
}
return {
synced,
errors
};
}
/**
* Diff vendored packages against the last-synced commit.
*
* Reads the commit hash from `<dir>/vendor.json`, clones at that commit,
* and compares local files to detect modifications since last sync.
*/
async diff(options) {
const lock = await this.readLock(options.root, options.dir);
if (!lock) return {
packages: [],
totalChanges: 0
};
let tmpDir;
try {
tmpDir = await this.cloneAtCommit(options.remote, lock.commit);
return await this.diffFromClone(options.root, tmpDir, options.dir, options.packages);
} finally {
if (tmpDir) await this.fs.rm(tmpDir, {
recursive: true,
force: true
});
}
}
/**
* Diff local packages against an already-cloned remote.
*/
async diffFromClone(root, tmpDir, dir, packages) {
const results = [];
let totalChanges = 0;
for (const pkg of packages) {
const remotePkgDir = this.fs.join(tmpDir, REMOTE_DIR, pkg);
const localPkgDir = this.fs.join(root, dir, pkg);
const remoteExists = await this.fs.exists(remotePkgDir);
const localExists = await this.fs.exists(localPkgDir);
if (!remoteExists && !localExists) {
results.push({
name: pkg,
added: [],
modified: [],
removed: []
});
continue;
}
if (!remoteExists) {
const localFiles = await this.fs.ls(localPkgDir, { recursive: true });
results.push({
name: pkg,
added: localFiles,
modified: [],
removed: []
});
totalChanges += localFiles.length;
continue;
}
if (!localExists) {
const remoteFiles = await this.fs.ls(remotePkgDir, { recursive: true });
results.push({
name: pkg,
added: [],
modified: [],
removed: remoteFiles
});
totalChanges += remoteFiles.length;
continue;
}
const result = await this.diffDirectories(localPkgDir, remotePkgDir);
const pkgChanges = result.added.length + result.modified.length + result.removed.length;
totalChanges += pkgChanges;
results.push({
name: pkg,
added: result.added,
modified: result.modified,
removed: result.removed
});
}
return {
packages: results,
totalChanges
};
}
/**
* Remove test files and ignored directories from a synced package.
*/
async removeIgnoredFiles(pkgDir) {
const allFiles = await this.fs.ls(pkgDir, { recursive: true });
for (const file of allFiles) if (file.endsWith(".spec.ts") || file.endsWith(".spec.tsx") || file === "LICENSE" || file === "tsdown.config.ts") await this.fs.rm(this.fs.join(pkgDir, file), { force: true });
for (const file of allFiles) for (const ignored of this.ignoredPaths) if (file === ignored || file.startsWith(`${ignored}/`) || file.includes(`/${ignored}/`) || file.endsWith(`/${ignored}`)) {
const idx = file.indexOf(ignored);
const dirPath = this.fs.join(pkgDir, file.substring(0, idx + ignored.length));
await this.fs.rm(dirPath, {
recursive: true,
force: true
});
}
}
/**
* Clone a remote repository into a temporary directory.
*/
async cloneRemote(remote, branch) {
const tmpDir = this.fs.join(process.env.TMPDIR || "/tmp", `.alepha-vendor-${Date.now()}`);
this.log.debug(`Cloning ${remote}#${branch} into ${tmpDir}`);
const output = await this.shell.run(`git clone --depth 1 --branch ${branch} --filter=blob:none ${remote} ${tmpDir}`, { capture: true });
if (output) this.log.debug(output);
return tmpDir;
}
/**
* Clone a remote repository at a specific commit hash.
*/
async cloneAtCommit(remote, commit) {
const tmpDir = this.fs.join(process.env.TMPDIR || "/tmp", `.alepha-vendor-${Date.now()}`);
this.log.debug(`Cloning ${remote}@${commit} into ${tmpDir}`);
await this.shell.run(`git init ${tmpDir}`, { capture: true });
await this.shell.run(`git -C ${tmpDir} remote add origin ${remote}`, { capture: true });
await this.shell.run(`git -C ${tmpDir} fetch --depth 1 origin ${commit}`, { capture: true });
await this.shell.run(`git -C ${tmpDir} checkout FETCH_HEAD`, { capture: true });
return tmpDir;
}
/**
* Get the HEAD commit hash from a cloned repository.
*/
async getCommitHash(repoDir) {
return (await this.shell.run(`git -C ${repoDir} rev-parse HEAD`, { capture: true })).trim();
}
/**
* Read the vendor lock file at `<root>/<dir>/vendor.json`.
*/
async readLock(root, dir) {
const lockPath = this.fs.join(root, dir, "vendor.json");
if (!await this.fs.exists(lockPath)) return;
const content = await this.fs.readFile(lockPath);
return JSON.parse(content.toString());
}
/**
* Write the vendor lock file to `<root>/<dir>/vendor.json`.
*/
async writeLock(root, dir, lock) {
const vendorDir = this.fs.join(root, dir);
await this.fs.mkdir(vendorDir, { recursive: true });
await this.fs.writeFile(this.fs.join(vendorDir, "vendor.json"), JSON.stringify(lock, null, 2));
}
/**
* Directories to ignore during diff comparisons.
*/
ignoredPaths = [
"__tests__",
"assets/swagger-ui",
"node_modules",
"dist"
];
/**
* Check if a file path should be ignored during diff.
*/
isIgnored(filePath) {
if (filePath.endsWith(".spec.ts") || filePath.endsWith(".spec.tsx") || filePath === "LICENSE" || filePath === "tsdown.config.ts") return true;
return this.ignoredPaths.some((p) => filePath === p || filePath.startsWith(`${p}/`) || filePath.includes(`/${p}/`) || filePath.endsWith(`/${p}`));
}
/**
* Recursively compare two directories and return the differences.
*/
async diffDirectories(localDir, remoteDir) {
const added = [];
const modified = [];
const removed = [];
const [localFiles, remoteFiles] = await Promise.all([this.fs.ls(localDir, { recursive: true }), this.fs.ls(remoteDir, { recursive: true })]);
const filteredLocal = localFiles.filter((f) => !this.isIgnored(f));
const filteredRemote = remoteFiles.filter((f) => !this.isIgnored(f));
const localSet = new Set(filteredLocal);
const remoteSet = new Set(filteredRemote);
for (const file of filteredRemote) {
if (!localSet.has(file)) {
removed.push(file);
continue;
}
try {
const [localContent, remoteContent] = await Promise.all([this.fs.readFile(this.fs.join(localDir, file)), this.fs.readFile(this.fs.join(remoteDir, file))]);
if (!localContent.equals(remoteContent)) {
const changes = this.computeLineDiff(remoteContent.toString(), localContent.toString());
modified.push({
file,
changes
});
}
} catch {}
}
for (const file of filteredLocal) if (!remoteSet.has(file)) added.push(file);
return {
added,
modified,
removed
};
}
/**
* Compute line-level differences between two file contents.
*
* Uses a longest-common-subsequence algorithm to produce minimal
* added/removed line changes with accurate line numbers.
*/
computeLineDiff(baseline, local) {
const baseLines = baseline.split("\n");
const localLines = local.split("\n");
const lcs = this.longestCommonSubsequence(baseLines, localLines);
const changes = [];
let bi = 0;
let li = 0;
let ci = 0;
while (bi < baseLines.length || li < localLines.length) if (ci < lcs.length && bi < baseLines.length && li < localLines.length) if (baseLines[bi] === lcs[ci] && localLines[li] === lcs[ci]) {
bi++;
li++;
ci++;
} else if (baseLines[bi] !== lcs[ci]) {
changes.push({
line: bi + 1,
type: "removed",
text: baseLines[bi]
});
bi++;
} else {
changes.push({
line: li + 1,
type: "added",
text: localLines[li]
});
li++;
}
else if (bi < baseLines.length) {
changes.push({
line: bi + 1,
type: "removed",
text: baseLines[bi]
});
bi++;
} else {
changes.push({
line: li + 1,
type: "added",
text: localLines[li]
});
li++;
}
return changes;
}
/**
* Compute the longest common subsequence of two string arrays.
*/
longestCommonSubsequence(a, b) {
const m = a.length;
const n = b.length;
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
const result = [];
let i = m;
let j = n;
while (i > 0 && j > 0) if (a[i - 1] === b[j - 1]) {
result.unshift(a[i - 1]);
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) i--;
else j--;
return result;
}
};
//#endregion
//#region ../../src/cli/vendor/commands/VendorCommand.ts
/**
* Default remote when none is configured. The HTTPS URL is used so anyone
* (CI runners, AI agents, contributors without SSH keys) can clone without
* extra setup.
*/
const DEFAULT_REMOTE = "https://github.com/feunard/alepha";
var VendorCommand = class {
log = $logger();
options = $state(vendorOptions);
vendorService = $inject(VendorService);
color = $inject(ConsoleColorProvider);
pm = $inject(PackageManagerUtils);
/**
* Ensure vendor config is present and return resolved options.
*/
resolveOptions() {
if (!this.options) throw new AlephaError("Missing vendor configuration. Add a \"vendor\" section to alepha.config.ts.");
return {
remote: this.options.remote ?? DEFAULT_REMOTE,
branch: this.options.branch ?? "main",
dir: this.options.dir ?? ".vendor",
packages: this.options.packages
};
}
syncFlags = z.object({
force: z.boolean().meta({ aliases: ["f"] }).describe("Skip local modification check").optional(),
remote: z.text({ description: "Override the configured remote for this invocation. Accepts any git-clone URL, including local paths (`file:///abs/path/to/alepha`). Useful for CI canaries that need to sync against a local checkout instead of the published repo." }).optional()
});
sync = $command({
name: "sync",
description: "Replace local packages with remote source",
flags: this.syncFlags,
handler: async ({ flags, root, run }) => {
const opts = this.resolveOptions();
const remote = flags.remote ?? opts.remote;
const c = this.color;
let result = {
synced: [],
errors: []
};
await run({
name: `Syncing from ${opts.branch}`,
handler: async () => {
result = await this.vendorService.sync({
root,
remote,
branch: opts.branch,
dir: opts.dir,
packages: opts.packages,
force: flags.force
});
}
});
if (result.aborted) {
run.end();
process.stdout.write(`\nLocal modifications detected. Use ${c.set("CYAN", "--force")} to overwrite.\n`);
for (const pkg of result.aborted.packages) this.printPackageDiff(pkg);
process.stdout.write("\n");
return;
}
if (result.synced.length > 0) await run(`${await this.pm.getPackageManager(root)} install`, { root });
run.end();
if (result.errors.length > 0) for (const error of result.errors) process.stdout.write(`${c.set("RED", " error")} ${error}\n`);
if (result.synced.length > 0) {
process.stdout.write(`\nSynced ${c.set("CYAN", String(result.synced.length))} ${result.synced.length === 1 ? "package" : "packages"} from ${c.set("CYAN", opts.branch)}\n`);
for (const pkg of result.synced) process.stdout.write(` ${c.set("GREEN", "✓")} ${pkg}\n`);
}
process.stdout.write("\n");
}
});
diff = $command({
name: "diff",
description: "Compare local packages against remote",
handler: async ({ root, run }) => {
const opts = this.resolveOptions();
let result = {
packages: [],
totalChanges: 0
};
await run({
name: `Cloning ${opts.remote} at ${opts.branch}`,
handler: async () => {
result = await this.vendorService.diff({
root,
remote: opts.remote,
branch: opts.branch,
dir: opts.dir,
packages: opts.packages
});
}
});
run.end();
if (result.totalChanges === 0) {
process.stdout.write("\nNo changes\n\n");
return;
}
for (const pkg of result.packages) this.printPackageDiff(pkg);
process.stdout.write("\n");
}
});
printPackageDiff(pkg) {
const c = this.color;
const count = pkg.added.length + pkg.modified.length + pkg.removed.length;
if (count === 0) {
process.stdout.write(`\n${c.set("CYAN", pkg.name)}: no changes\n`);
return;
}
process.stdout.write(`\n${c.set("CYAN", pkg.name)}: ${count} ${count === 1 ? "file differs" : "files differ"}\n`);
for (const file of pkg.added) process.stdout.write(` ${c.set("GREEN", "A")} ${file}\n`);
for (const fileDiff of pkg.modified) {
process.stdout.write(` ${c.set("ORANGE", "M")} ${fileDiff.file}\n`);
for (const change of fileDiff.changes) {
const prefix = change.type === "removed" ? "-" : "+";
const color = change.type === "removed" ? "RED" : "GREEN";
const lineNum = `L${change.line}`;
process.stdout.write(` ${c.set("DIM", lineNum.padEnd(5))} ${c.set(color, `${prefix} ${change.text}`)}\n`);
}
}
for (const file of pkg.removed) process.stdout.write(` ${c.set("RED", "D")} ${file}\n`);
}
vendor = $command({
name: "vendor",
description: "Vendor Alepha packages into the project",
children: [this.sync, this.diff],
handler: async ({ help }) => {
help();
}
});
};
//#endregion
//#region ../../src/cli/vendor/index.ts
/**
* CLI plugin for vendoring Alepha packages into external projects.
*
* Copies package source code from a git remote into the current project's
* `packages/` directory. Useful for corporate projects that need a local
* copy of Alepha for AI tooling, audits, documentation, or quick fixes.
*
* Commands:
* - `alepha vendor sync` — replace local packages with remote source
* - `alepha vendor diff` — compare local packages against remote HEAD
*
* Configuration in `alepha.config.ts`:
*
* ```typescript
* import { vendor } from "alepha/cli/vendor";
*
* export default defineConfig({
* plugins: [
* vendor({
* branch: "main",
* packages: ["alepha", "@alepha/payments-stripe"],
* }),
* ],
* });
* ```
*/
const AlephaCliVendorPlugin = $module({
name: "alepha.cli.plugins.vendor",
atoms: [vendorOptions],
services: [VendorCommand, VendorService]
});
const vendor = (options) => {
return () => {
const { alepha } = $context();
alepha.with(AlephaCliVendorPlugin).set(vendorOptions, options);
};
};
//#endregion
export { AlephaCliVendorPlugin, VendorCommand, VendorService, vendor, vendorOptions };
//# sourceMappingURL=index.js.map