@7i7o/git-remote-proland
Version:
Git Remote Helper
613 lines (600 loc) • 18 kB
JavaScript
// 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();