UNPKG

@7i7o/git-remote-proland

Version:
613 lines (600 loc) 18 kB
#!/usr/bin/env node // src/lib/remoteHelper.ts import { existsSync as existsSync2, mkdirSync } from "fs"; import { spawn as spawn2 } from "child_process"; import readline from "readline"; import { Writable } from "stream"; // src/lib/warpHelper.ts import { LoggerFactory, WarpFactory, defaultCacheOptions } from "warp-contracts/mjs"; // src/lib/arweaveHelper.ts import Arweave from "arweave"; import { ArweaveSigner, createData } from "arbundles"; // src/lib/withAsync.ts async function withAsync(fn) { try { if (typeof fn !== "function") throw new Error("The first argument must be a function"); const response = await fn(); return { response, error: null }; } catch (error) { return { error, response: null }; } } // src/lib/arweaveHelper.ts async function getAddress(wallet2) { return await initArweave().wallets.jwkToAddress( wallet2 ? wallet2 : getWallet() ); } async function uploadRepo(zipBuffer, tags) { try { const bundlrTxId = await bundlrUpload(zipBuffer, tags); log(`Posted Tx to Bundlr: ${bundlrTxId}`); return bundlrTxId; } catch (error) { log("Bundlr failed, trying with Arweave..."); const arweaveTxId = await arweaveUpload(zipBuffer, tags); log(`Posted Tx to Arweave: ${arweaveTxId}`); return arweaveTxId; } } function initArweave() { return Arweave.init({ host: "arweave.net", port: 443, protocol: "https" }); } async function arweaveDownload(txId) { const { response, error } = await withAsync( () => fetch(`https://arweave.net/${txId}`) ); if (error) { throw new Error(error); } else if (response) { return await response.arrayBuffer(); } } async function arweaveUpload(zipBuffer, tags) { const jwk = getWallet(); if (!jwk) throw "[ arweave ] No jwk wallet supplied"; const arweave = initArweave(); const dataSize = zipBuffer.length; const tx = await arweave.createTransaction({ data: zipBuffer }, jwk); for (const tag of tags) tx.addTag(tag.name, tag.value); await arweave.transactions.sign(tx, jwk); const response = await arweave.transactions.post(tx); log(`${response.status} - ${response.statusText}`); if (response.status !== 200) { throw `[ arweave ] Posting repo to arweave failed. Error: '${response.status}' - '${response.statusText}' Check if you have plenty $AR to upload ~${Math.ceil( dataSize / 1024 )} KB of data.`; } return tx.id; } async function bundlrUpload(zipBuffer, tags) { const jwk = getWallet(); if (!jwk) throw "[ bundlr ] No jwk wallet supplied"; const node = "https://node2.bundlr.network"; const uint8ArrayZip = new Uint8Array(zipBuffer); const signer = new ArweaveSigner(jwk); const dataItem = createData(uint8ArrayZip, signer, { tags }); await dataItem.sign(signer); const res = await fetch(`${node}/tx`, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: dataItem.getRaw() }); if (res.status >= 400) throw new Error( `[ bundlr ] Posting repo w/bundlr failed. Error: ${res.status} - ${res.statusText}` ); return dataItem.id; } // src/lib/common.ts import { execSync } from "child_process"; import { accessSync, constants, readFileSync } from "fs"; import path from "path"; var ANSI_RESET = "\x1B[0m"; var ANSI_RED = "\x1B[31m"; var ANSI_GREEN = "\x1B[32m"; var DIRTY_EXT = ".tmp"; var PL_TMP_PATH = ".protocol.land"; var GIT_CONFIG_KEYFILE = "protocol.land.keyfile"; var getWarpContractTxId = () => "w5ZU15Y2cLzZlu3jewauIlnzbKw-OAxbN9G5TbuuiDQ"; var log = (message, options) => { if (!options) console.error(` [PL] ${message}`); else { const { color } = options; console.error( `${color === "red" ? ANSI_RED : ANSI_GREEN} [PL] ${message}${ANSI_RESET}` ); } }; var wallet = null; var getJwkPath = () => { try { return execSync(`git config --get ${GIT_CONFIG_KEYFILE}`).toString().trim(); } catch (error) { return ""; } }; var getWallet = (params = { warn: false }) => { if (wallet) return wallet; const jwkPath = getJwkPath(); if (!jwkPath) return walletNotFoundMessage(params); try { const jwk = readFileSync(jwkPath, { encoding: "utf-8" }).toString().trim(); if (!jwk) return walletNotFoundMessage(); return JSON.parse(jwk); } catch (error) { return walletNotFoundMessage(); } }; var walletNotFoundMessage = (params = { warn: false }) => { const { warn } = params; if (warn) { log( `If you need to push to the repo, please set up the path to your Arweave JWK.`, { color: "green" } ); } else { log(`Failed to get wallet keyfile path from git config.`); log( `You need an owner or contributor wallet to have write access to the repo.`, { color: "red" } ); } log( `Run 'git config --add ${GIT_CONFIG_KEYFILE} YOUR_WALLET_KEYFILE_FULL_PATH' to set it up`, { color: "green" } ); log( `Use '--global' to have a default keyfile for all Protocol Land repos`, { color: "green" } ); return null; }; var ownerOrContributor = async (repo, wallet2, options = { pushing: false }) => { const { pushing } = options; const address = await getAddress(wallet2); const ownerOrContrib = repo.owner === address || repo.contributors.some((contributor) => contributor === address); if (!ownerOrContrib) notOwnerOrContributorMessage({ warn: !pushing }); return ownerOrContrib; }; var notOwnerOrContributorMessage = (params = { warn: false }) => { const { warn } = params; if (warn) { log( `You are not the repo owner nor a contributor. You will need an owner or contributor jwk to push to this repo.`, { color: "green" } ); } else { log( `You are not the repo owner nor a contributor. You can't push to this repo.`, { color: "red" } ); } return null; }; async function getTags(title, description) { return [ { name: "App-Name", value: "Protocol.Land" }, { name: "Content-Type", value: "application/zip" }, { name: "Creator", value: await getAddress() }, { name: "Title", value: title }, { name: "Description", value: description }, { name: "Type", value: "repo-update" } ]; } function clearCache(cachePath, options) { const { keepFolders = [] } = options; const ommitedFolders = keepFolders.map((v) => `! -name "${v}"`).join(" "); execSync( `find ${cachePath} -mindepth 1 -maxdepth 1 -type d ${ommitedFolders} -exec rm -rf {} \\;` ); } function setCacheDirty(cachePath, remoteName2) { if (!cachePath || !remoteName2) throw new Error("Cache and MutexName are required"); execSync(`touch ${path.join(cachePath, remoteName2, DIRTY_EXT)}`); } function unsetCacheDirty(cachePath, remoteName2) { if (!cachePath || !remoteName2) throw new Error("Cache and MutexName are required"); execSync(`rm -f ${path.join(cachePath, remoteName2, DIRTY_EXT)}`); } function isCacheDirty(cachePath, remoteName2) { if (!cachePath || !remoteName2) throw new Error("Cache and MutexName are required"); try { accessSync( path.join(cachePath, remoteName2, DIRTY_EXT), constants.R_OK | constants.W_OK ); return true; } catch { return false; } } var waitFor = (delay) => new Promise((res) => setTimeout(res, delay)); // src/lib/warpHelper.ts import path2 from "path"; var getWarpCacheOptions = (cachePath) => { return { ...defaultCacheOptions, dbLocation: path2.join(cachePath, defaultCacheOptions.dbLocation) }; }; var getWarp = (destPath, logLevel) => { LoggerFactory.INST.logLevel(logLevel ? logLevel : "none"); const options = destPath ? getWarpCacheOptions(destPath) : { ...defaultCacheOptions, inMemory: true }; return WarpFactory.forMainnet({ ...options }); }; async function getRepo(id, destpath) { let pl = getWarp(destpath).contract(getWarpContractTxId()); const response = await pl.viewState({ function: "getRepository", payload: { id } }); return response.result; } async function updateWarpRepo(repo, newDataTxId, destPath) { if (!repo.id || !repo.name || !newDataTxId) throw "[ warp ] No id, title or dataTxId to update repo "; const payload = { id: repo.id, name: repo.name, description: repo.description, dataTxId: newDataTxId }; await waitFor(500); const contract = getWarp().contract(getWarpContractTxId()); await contract.connect(getWallet()).writeInteraction({ function: "updateRepositoryTxId", payload }); return { id: payload.id }; } // src/lib/protocolLandSync.ts import { spawn } from "child_process"; // src/lib/zipHelper.ts import fs from "fs"; import path3 from "path"; import JSZip from "jszip"; function loadIgnoreList(rootPath) { const gitignorePath = path3.join(rootPath, ".gitignore"); if (fs.existsSync(gitignorePath)) { const gitignoreContent = fs.readFileSync(gitignorePath, "utf-8"); return gitignoreContent.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")); } return []; } async function unpackGitRepo({ destPath, arrayBuffer }) { const zip = await JSZip.loadAsync(arrayBuffer); zip.forEach(async (_, file) => { if (file.dir) { const folderPath = path3.join(destPath, file.name); fs.mkdirSync(folderPath, { recursive: true }); } else { const filePath = path3.join(destPath, file.name); const content = await file.async("blob"); fs.writeFileSync( filePath, new Uint8Array(await content.arrayBuffer()) ); } }); await waitFor(1e3); return true; } async function zipRepoJsZip(mainPath, zipRoot, folderToZip, useGitignore, ignoreFiles) { if (!folderToZip) folderToZip = zipRoot; const ignoreList = useGitignore ? loadIgnoreList(zipRoot) : []; const ignoreFilesList = ignoreFiles ? ignoreFiles.map((f) => path3.join(zipRoot, f)) : []; const ignoreSet = /* @__PURE__ */ new Set([...ignoreList, ...ignoreFilesList]); const zip = new JSZip(); const filesToInclude = []; const walk = (currentPath) => { const items = fs.readdirSync(currentPath); for (const item of items) { const itemPath = path3.join(currentPath, item); if (ignoreSet.has(item)) { continue; } if (fs.statSync(itemPath).isDirectory()) { walk(itemPath); } else { filesToInclude.push(itemPath); } } }; walk(folderToZip); for (const file of filesToInclude) { const content = fs.readFileSync(file); const relativePath = path3.join( mainPath ? mainPath + "/" : "", path3.relative(zipRoot, file) ); zip.file(relativePath, content); } return await zip.generateAsync({ type: "nodebuffer" }); } // src/lib/protocolLandSync.ts import path4 from "path"; import { existsSync } from "fs"; var downloadProtocolLandRepo = async (repoId, destPath) => { log(`Getting latest repo from Protocol.Land into '${destPath}' ...`); let repo; try { repo = await getRepo(repoId, destPath); } catch (err) { log(err); } if (!repo) { log(`Repo '${repoId}' not found`, { color: "red" }); log(`Please create a repo in https://protocol.land first`, { color: "green" }); process.exit(0); } const latestVersionRepoPath = path4.join(destPath, repo.dataTxId); if (existsSync(latestVersionRepoPath)) { if (!isCacheDirty(destPath, repo.dataTxId)) { log(`Using cached repo in '${latestVersionRepoPath}'`); return repo; } clearCache(destPath, { keepFolders: ["cache"] }); } log(`Downloading from arweave with txId '${repo.dataTxId}' ...`); const arrayBuffer = await arweaveDownload(repo.dataTxId); if (!arrayBuffer) { log("Failed to fetch repo data from arweave.", { color: "red" }); log("Check connection or repo integrity in https://protocol.land", { color: "green" }); process.exit(0); } log(`Unpacking downloaded repo ...`); const status = await unpackGitRepo({ destPath, arrayBuffer }); if (!status) { log("Unpacking failed!", { color: "red" }); log("Check repo integrity in https://protocol.land", { color: "green" }); process.exit(0); } const unpackedRepoPath = path4.join(destPath, repo.name); const bareRepoPath = path4.join(destPath, repo.dataTxId); const cloned = await runCommand( "git", ["clone", "--bare", unpackedRepoPath, bareRepoPath], { forwardStdOut: true } ); if (!cloned) { log("Failed to prepare bare remote from unpacked repo!", { color: "red" }); log("Check repo integrity in https://protocol.land", { color: "green" }); process.exit(0); } try { clearCache(destPath, { keepFolders: ["cache", repo.dataTxId] }); } catch { } return repo; }; var uploadProtocolLandRepo = async (repoPath, repo, destPath) => { log("Packing repo ..."); const buffer = await zipRepoJsZip(repo.name, repoPath, "", true, [ PL_TMP_PATH ]); log("Uploading to Arweave ..."); let dataTxId; try { dataTxId = await uploadRepo( buffer, await getTags(repo.name, repo.description) ); } catch (error) { log(error); } if (!dataTxId) return false; log("Updating in warp ..."); const updated = await updateWarpRepo(repo, dataTxId, destPath); return updated.id === repo.id; }; var runCommand = async (command, args, options) => { log(`Running '${command} ${args.join(" ")}' ...`); const child = spawn(command, args, { shell: true, stdio: ["pipe", "pipe", "pipe"] }); return await new Promise((resolve, reject) => { child.on("error", reject); if (options?.forwardStdOut) { child.stdout.on("data", (data) => log); } child.on("close", (code) => { if (code === 0) { resolve(true); } else { log(`Command Failed. Exit code: ${code}`); resolve(false); } }); }); }; // src/lib/remoteHelper.ts import path5 from "path"; var OBJECTS_PUSHED = "unpack ok"; var remoteHelper = async (params) => { const { remoteUrl: remoteUrl2, gitdir: gitdir2 } = params; const tmpPath = getTmpPath(gitdir2); const repoId = `${remoteUrl2.replace(/.*:\/\//, "")}`; const repo = await downloadProtocolLandRepo(repoId, tmpPath); const bareRemotePath = path5.join(tmpPath, repo.dataTxId); talkToGit(bareRemotePath, repo, tmpPath); }; function getTmpPath(gitdir2) { const tmpPath = path5.join(gitdir2, PL_TMP_PATH); if (!existsSync2(tmpPath)) { mkdirSync(tmpPath, { recursive: true }); if (!existsSync2(tmpPath)) throw new Error(`Failed to create the directory: ${tmpPath}`); } return tmpPath; } function talkToGit(bareRemotePath, repo, tmpPath) { const rl = readline.createInterface({ input: process.stdin, output: new Writable({ write(chunk, encoding, callback) { callback(); } }) // Passing a null stream for output }); async function readLinesUntilEmpty() { const promptForLine = () => new Promise((resolve) => rl.question("", resolve)); while (true) { const line = (await promptForLine()).trim(); if (line === "") { rl.close(); process.exit(0); } const [command, arg] = line.split(" "); switch (command) { case "capabilities": console.log("connect"); console.log(""); break; case "connect": console.log(""); await spawnPipedGitCommand( arg, bareRemotePath, repo, tmpPath ); break; } } } readLinesUntilEmpty(); } var spawnPipedGitCommand = async (gitCommand, remoteUrl2, repo, tmpPath) => { if (gitCommand === "git-receive-pack") { const wallet2 = getWallet({ warn: false }); if (!wallet2) process.exit(0); const ownerOrContrib = await ownerOrContributor(repo, wallet2, { pushing: true }); if (!ownerOrContrib) process.exit(0); } else { const wallet2 = getWallet({ warn: true }); if (wallet2) { ownerOrContributor(repo, wallet2); } } let objectsUpdated = false; const gitProcess = spawn2(gitCommand, [remoteUrl2], { stdio: ["pipe", "pipe", "pipe"] // Pipe for stdin, stdout, and stderr }); process.stdin.pipe(gitProcess.stdin); gitProcess.stdout.pipe(process.stdout); gitProcess.stderr.pipe(process.stderr); gitProcess.stdout.on("data", (data) => { if (data.toString().includes(OBJECTS_PUSHED)) objectsUpdated = true; }); gitProcess.on("exit", async (code) => { if (code !== 0) { log( `git command '${gitCommand}' exited with error. Exit code: ${code}`, { color: "red" } ); process.exit(code ? code : 1); } if (gitCommand === "git-receive-pack" && objectsUpdated) { log( `Push to temp remote finished successfully, now syncing with Protocol Land ...` ); setCacheDirty(tmpPath, repo.dataTxId); const pathToPack = path5.join(remoteUrl2, "..", "..", ".."); waitFor(1e3); const success = await uploadProtocolLandRepo( pathToPack, repo, tmpPath ); clearCache(tmpPath, { keepFolders: ["cache"] }); unsetCacheDirty(tmpPath, repo.dataTxId); if (success) log(`Successfully pushed repo '${repo.id}' to Protocol Land`, { color: "green" }); else { log(`Failed to push repo '${repo.id}' to Protocol Land`, { color: "red" }); log( "Please run `git pull` first to clean the cache and integrate your changes", { color: "red" } ); process.exit(1); } } }); }; // src/index.ts var [remoteName, remoteUrl] = process.argv.slice(2, 4); var gitdir = process.env.GIT_DIR; if (!gitdir) throw new Error("Missing GIT_DIR env"); remoteHelper({ remoteName, remoteUrl, gitdir }).then();