UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

562 lines (561 loc) 18 kB
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