@protocol.land/git-remote-helper
Version:
Protocol Land git remote helper
1,185 lines (1,167 loc) • 34.5 kB
JavaScript
// src/lib/arweaveHelper.ts
import { ArweaveSigner, bundleAndSignData, createData } from "arbundles";
import readline from "readline";
import fs, { promises as fsPromises } from "fs";
// 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()
);
}
function getActivePublicKey() {
const wallet2 = getWallet();
if (!wallet2) {
process.exit(0);
}
return wallet2.n;
}
async function checkAccessToTty() {
try {
await fsPromises.access(
"/dev/tty",
fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK
);
return true;
} catch (err) {
return false;
}
}
function createTtyReadlineInterface() {
const ttyReadStream = fs.createReadStream("/dev/tty");
const ttyWriteStream = fs.createWriteStream("/dev/tty");
const rl = readline.createInterface({
input: ttyReadStream,
output: ttyWriteStream
});
return {
rl,
ttyReadStream,
ttyWriteStream
};
}
function askQuestionThroughTty(question) {
return new Promise((resolve, reject) => {
const { rl, ttyReadStream, ttyWriteStream } = createTtyReadlineInterface();
rl.question(question, (answer) => {
rl.close();
ttyReadStream.destroy();
ttyWriteStream.end();
ttyWriteStream.on("finish", () => {
resolve(answer.trim().toLowerCase());
});
});
rl.on("error", (err) => reject(err));
ttyReadStream.on("error", (err) => reject(err));
ttyWriteStream.on("error", (err) => reject(err));
});
}
var shouldPushChanges = async (uploadSize, uploadCost, subsidySize) => {
let hasAccessToTty = await checkAccessToTty();
if (!hasAccessToTty)
return true;
const thresholdCost = getThresholdCost();
let showPushConsent;
if (thresholdCost === null) {
showPushConsent = uploadSize > subsidySize;
} else if (uploadCost > thresholdCost) {
showPushConsent = uploadSize > subsidySize;
} else {
showPushConsent = false;
}
if (!showPushConsent)
return true;
try {
const answer = await askQuestionThroughTty(" [PL] Push? (y/n): ");
return answer === "yes" || answer === "y";
} catch (err) {
return true;
}
};
async function getTurboSubsidy() {
const defaultSubsidy = 107520;
try {
const response = await fetch("https://turbo.ardrive.io/");
if (!response.ok)
return defaultSubsidy;
const data = await response.json();
const freeUploadLimitBytes = +data.freeUploadLimitBytes;
if (Number.isFinite(freeUploadLimitBytes))
return freeUploadLimitBytes;
return defaultSubsidy;
} catch (err) {
}
return defaultSubsidy;
}
async function uploadRepo(zipBuffer, tags, uploadSize, uploadCost) {
try {
const uploadedTx = await subsidizedUpload(zipBuffer, tags);
const serviceUsed = uploadedTx.bundled ? "Turbo" : "Arweave";
log(`Posted Tx to ${serviceUsed}: ${uploadedTx.data.repoTxId}`);
return { txId: uploadedTx.data.repoTxId, pushCancelled: false };
} catch (error) {
const userWantsToPay = await shouldUserPayForTx();
if (!userWantsToPay) {
return { txid: "", pushCancelled: true };
}
}
const turboSubsidySize = await getTurboSubsidy();
const subsidySize = Math.max(turboSubsidySize, 0);
const pushChanges = await shouldPushChanges(
uploadSize,
uploadCost,
subsidySize
);
async function attemptUpload(uploaderName, uploader) {
if (pushChanges) {
const txId = await uploader(zipBuffer, tags);
log(`Posted Tx to ${uploaderName}: ${txId}`);
return { txId, pushCancelled: false };
}
return { txid: "", pushCancelled: true };
}
try {
return await attemptUpload("Turbo", turboUpload);
} catch (error) {
log("Turbo failed, trying with Arweave...");
return await attemptUpload("Arweave", arweaveUpload);
}
}
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 arweave2 = initArweave();
const dataSize = zipBuffer.length;
const tx = await arweave2.createTransaction({ data: zipBuffer }, jwk);
for (const tag of tags)
tx.addTag(tag.name, tag.value);
await arweave2.transactions.sign(tx, jwk);
const response = await arweave2.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 turboUpload(zipBuffer, tags) {
const jwk = getWallet();
if (!jwk)
throw "[ turbo ] No jwk wallet supplied";
const node = "https://turbo.ardrive.io";
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(
`[ turbo ] Posting repo with turbo failed. Error: ${res.status} - ${res.statusText}`
);
return dataItem.id;
}
async function subsidizedUpload(zipBuffer, tags) {
const jwk = getWallet();
if (!jwk)
throw "[ turbo ] No jwk wallet supplied";
const node = "https://subsidize.saikranthi.dev/api/v1/postrepo";
const uint8ArrayZip = new Uint8Array(zipBuffer);
const signer = new ArweaveSigner(jwk);
const address = await getAddress(jwk);
const dataItem = createData(uint8ArrayZip, signer, { tags });
await dataItem.sign(signer);
const bundle = await bundleAndSignData([dataItem], signer);
const res = await fetch(`${node}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify({
txBundle: bundle.getRaw(),
platform: "CLI",
owner: address
})
});
const upload = await res.json();
if (!upload || !upload.success)
throw new Error(
`[ turbo ] Posting repo with turbo failed. Error: ${res.status} - ${res.statusText}`
);
return upload;
}
async function shouldUserPayForTx() {
log("[ PL SUBSIDIZE ] Failed to subsidize this transaction.");
try {
const answer = await askQuestionThroughTty(
" [PL] Would you like to pay for this transaction yourself? (y/n): "
);
return answer === "yes" || answer === "y";
} catch (err) {
return true;
}
}
// src/lib/common.ts
import { execSync } from "child_process";
import { accessSync, constants, readFileSync } from "fs";
import path from "path";
import Arweave from "arweave";
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 GIT_CONFIG_THRESHOLD_COST = "protocol.land.thresholdCost";
var AOS_PROCESS_ID = "yJZ3_Yrc-qYRt1zHmY7YeNvpmQwuqyK3dT0-gxWftew";
var gitdir = process.env.GIT_DIR;
function initArweave() {
return Arweave.init({
host: "arweave.net",
port: 443,
protocol: "https"
});
}
var log = (message2, options) => {
if (!options)
console.error(` [PL] ${message2}`);
else {
const { color } = options;
console.error(
`${color === "red" ? ANSI_RED : ANSI_GREEN} [PL] ${message2}${ANSI_RESET}`
);
}
};
var wallet = null;
var getJwkPath = () => {
try {
return execSync(`git config --get ${GIT_CONFIG_KEYFILE}`).toString().trim();
} catch (error) {
return "";
}
};
var getThresholdCost = () => {
try {
const threshold = execSync(
`git config --get ${GIT_CONFIG_THRESHOLD_COST}`
).toString().trim();
if (threshold === "")
return null;
return +threshold;
} catch (error) {
return null;
}
};
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();
wallet = JSON.parse(jwk);
return wallet;
} 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));
function isValidUuid(uuid) {
const REGEX = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
return typeof uuid === "string" && REGEX.test(uuid);
}
function setGitRemoteOrigin(repo) {
try {
const remoteUrl2 = process.argv[3];
if (!remoteUrl2)
return;
const repoId = `${remoteUrl2.replace(/.*:\/\//, "")}`;
if (isValidUuid(repoId))
return;
const repoPath = gitdir.split(path.sep).slice(0, -2).join(path.sep);
const currentDir = process.cwd();
process.chdir(repoPath);
execSync(`git remote set-url origin proland://${repo.id}`);
process.chdir(currentDir);
} catch (error) {
}
}
// src/lib/remoteHelper.ts
import { existsSync as existsSync2, mkdirSync } from "fs";
import { spawn as spawn2 } from "child_process";
import readline2 from "readline";
import { Writable } from "stream";
// src/lib/aoHelper.ts
import {
createDataItemSigner,
dryrun,
message,
result
} from "@permaweb/aoconnect";
function capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function getTags2(payload) {
return Object.entries(payload).map(
([key, value]) => ({ name: capitalizeFirstLetter(key), value })
);
}
function extractMessage(text) {
const regex = /:\s*([^:!]+)!/;
const match = text.match(regex);
return match ? match[1].trim() : text;
}
async function sendMessage({ tags, data }) {
const args = {
process: AOS_PROCESS_ID,
tags,
signer: createDataItemSigner(getWallet())
};
if (data)
args.data = data;
const messageId = await message(args);
const { Output } = await result({
message: messageId,
process: AOS_PROCESS_ID
});
if (Output?.data?.output) {
throw new Error(extractMessage(Output?.data?.output));
}
return messageId;
}
async function getRepo(id) {
let Messages = [];
const fields = JSON.stringify([
"id",
"name",
"description",
"owner",
"fork",
"parent",
"dataTxId",
"contributors",
"githubSync",
"private",
"privateStateTxId"
]);
if (isValidUuid(id)) {
({ Messages } = await dryrun({
process: AOS_PROCESS_ID,
tags: getTags2({
Action: "Get-Repo",
Id: id,
Fields: fields
})
}));
} else {
const [username, repoName] = id.split("/");
if (!username || !repoName)
return;
({ Messages } = await dryrun({
process: AOS_PROCESS_ID,
tags: getTags2({
Action: "Get-Repo-By-Name-Username",
"Repo-Name": repoName,
Username: username,
Fields: fields
})
}));
}
if (Messages.length === 0)
return void 0;
return JSON.parse(Messages[0].Data)?.result;
}
async function updateRepo(repo, newDataTxId) {
if (!repo.id || !repo.name || !newDataTxId)
throw "[ AO ] No id, title or dataTxId to update repo ";
await waitFor(500);
await sendMessage({
tags: getTags2({
Action: "Update-Repo-TxId",
Id: repo.id,
"Data-TxId": newDataTxId
})
});
return { id: repo.id };
}
// src/lib/protocolLandSync.ts
import { spawn } from "child_process";
// src/lib/zipHelper.ts
import { promises as fsPromises2 } from "fs";
import path2 from "path";
import JSZip from "jszip";
import { exec } from "child_process";
async function unpackGitRepo({
destPath,
arrayBuffer
}) {
const zip = await JSZip.loadAsync(arrayBuffer);
const promises = [];
for (const [_, file] of Object.entries(zip.files)) {
if (file.dir) {
const folderPath = path2.join(destPath, file.name);
promises.push(fsPromises2.mkdir(folderPath, { recursive: true }));
} else {
promises.push(
(async () => {
const filePath = path2.join(destPath, file.name);
const dirPath = path2.dirname(filePath);
await fsPromises2.mkdir(dirPath, { recursive: true });
const content = await file.async("nodebuffer");
fsPromises2.writeFile(filePath, content);
})()
);
}
}
await Promise.all(promises);
await waitFor(1e3);
return true;
}
async function getGitTrackedFiles() {
return new Promise((resolve, reject) => {
exec("git ls-files", { encoding: "utf-8" }, (error, stdout) => {
if (error) {
reject(new Error("Error getting git tracked files"));
} else {
resolve(stdout.trim().split("\n"));
}
});
});
}
async function zipRepoJsZip(mainPath, zipRoot, folderToZip, ignoreFiles) {
if (!folderToZip)
folderToZip = zipRoot;
const ignoreFilesList = ignoreFiles ?? [];
const filesToInclude = [];
const walk = async (currentPath) => {
const items = await fsPromises2.readdir(currentPath);
for (const item of items) {
const itemPath = path2.join(currentPath, item);
if (ignoreFilesList.some(
(ignorePath) => itemPath.startsWith(ignorePath)
)) {
continue;
}
const stats = await fsPromises2.stat(itemPath);
if (stats.isDirectory()) {
await walk(itemPath);
} else {
filesToInclude.push(itemPath);
}
}
};
await walk(gitdir);
const gitTrackedFiles = await getGitTrackedFiles();
filesToInclude.push(...gitTrackedFiles);
const zip = new JSZip();
for (const file of filesToInclude) {
const content = await fsPromises2.readFile(file);
const relativePath = path2.join(
mainPath ? mainPath + "/" : "",
path2.relative(zipRoot, file)
);
zip.file(relativePath, content);
}
return await zip.generateAsync({ type: "nodebuffer" });
}
// src/lib/protocolLandSync.ts
import path3 from "path";
import { existsSync, promises as fsPromises3 } from "fs";
// src/lib/privateRepo.ts
import crypto from "crypto";
var arweave = initArweave();
async function deriveAddress(publicKey) {
const pubKeyBuf = arweave.utils.b64UrlToBuffer(publicKey);
const sha512DigestBuf = await crypto.subtle.digest("SHA-512", pubKeyBuf);
return arweave.utils.bufferTob64Url(new Uint8Array(sha512DigestBuf));
}
async function decryptAesKeyWithPrivateKey(encryptedAesKey) {
const privateKey = getWallet();
const key = await crypto.subtle.importKey(
"jwk",
privateKey,
{
name: "RSA-OAEP",
hash: "SHA-256"
},
false,
["decrypt"]
);
const options = {
name: "RSA-OAEP",
hash: "SHA-256"
};
const decryptedAesKey = await crypto.subtle.decrypt(
options,
key,
encryptedAesKey
);
return new Uint8Array(decryptedAesKey);
}
async function decryptFileWithAesGcm(encryptedFile, decryptedAesKey, iv) {
const aesKey = await crypto.subtle.importKey(
"raw",
decryptedAesKey,
{ name: "AES-GCM", length: 256 },
true,
["decrypt"]
);
const decryptedFile = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
encryptedFile
);
return decryptedFile;
}
async function decryptRepo(repoArrayBuf, privateStateTxId) {
const response = await fetch(`https://arweave.net/${privateStateTxId}`);
const privateState = await response.json();
const ivArrBuff = arweave.utils.b64UrlToBuffer(privateState.iv);
const pubKey = getActivePublicKey();
const address = await deriveAddress(pubKey);
const encAesKeyStr = privateState.encKeys[address];
const encAesKeyBuf = arweave.utils.b64UrlToBuffer(encAesKeyStr);
const aesKey = await decryptAesKeyWithPrivateKey(
encAesKeyBuf
);
const decryptedRepo = await decryptFileWithAesGcm(
repoArrayBuf,
aesKey,
ivArrBuff
);
return decryptedRepo;
}
// src/lib/prices.ts
import redstone from "redstone-api";
async function getArPrice() {
try {
const data = await (await fetch(
`https://api.coingecko.com/api/v3/simple/price?ids=arweave&vs_currencies=usd`
)).json();
return data.arweave.usd;
} catch (e) {
const response = await redstone.getPrice("AR");
if (!response.value) {
return 0;
}
return response.source.coingecko;
}
}
async function getWinstonPriceForBytes(bytes) {
try {
const response = await fetch(`https://arweave.net/price/${bytes}`);
const winston = await response.text();
return +winston;
} catch (error) {
throw new Error(error);
}
}
function formatBytes(bytes) {
if (bytes === 0)
return "0 Bytes";
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
async function calculateEstimate(bytes) {
const formattedSize = formatBytes(bytes);
const costInWinston = await getWinstonPriceForBytes(bytes);
const costInAR = +initArweave().ar.winstonToAr(costInWinston.toString());
const costFor1ARInUSD = await getArPrice();
const costInUSD = costInAR * costFor1ARInUSD;
return {
formattedSize,
costInAR,
costInARWithPrecision: costInAR.toPrecision(5),
costInUSD,
costInUSDWithPrecision: costInUSD.toPrecision(5)
};
}
// src/lib/protocolLandSync.ts
var downloadProtocolLandRepo = async (repoId, destPath) => {
log(`Getting latest repo from Protocol.Land into '${destPath}' ...`);
let repo;
try {
repo = await getRepo(repoId);
} 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 = path3.join(destPath, repo.dataTxId);
if (existsSync(latestVersionRepoPath)) {
if (!isCacheDirty(destPath, repo.dataTxId)) {
log(`Using cached repo in '${latestVersionRepoPath}'`);
return repo;
}
clearCache(destPath, { keepFolders: [] });
}
log(`Downloading from arweave with txId '${repo.dataTxId}' ...`);
let 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);
}
const isPrivate = repo?.private || false;
const privateStateTxId = repo?.privateStateTxId;
if (isPrivate && privateStateTxId) {
arrayBuffer = await decryptRepo(arrayBuffer, privateStateTxId);
}
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 = path3.join(destPath, repo.id);
const bareRepoPath = path3.join(destPath, repo.dataTxId);
if (repo.fork && repo.parent) {
const unpackedPath = path3.join(destPath, repo.parent);
if (existsSync(unpackedPath)) {
await fsPromises3.rename(unpackedPath, unpackedRepoPath);
}
}
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: [repo.dataTxId] });
} catch {
}
return repo;
};
var uploadProtocolLandRepo = async (repoPath, repo, destPath) => {
let dataTxId;
let pushCancelled = false;
try {
log("Packing repo ...\n");
let buffer = await zipRepoJsZip(repo.id, repoPath, "", [
path3.join(gitdir, PL_TMP_PATH)
]);
const isPrivate = repo?.private || false;
const privateStateTxId = repo?.privateStateTxId;
if (isPrivate && privateStateTxId) {
throw new Error("Private repos are no longer supported.");
}
const bufferSize = Buffer.byteLength(buffer);
const {
costInAR,
costInARWithPrecision,
costInUSDWithPrecision,
formattedSize
} = await calculateEstimate(bufferSize);
const spaces = " ".repeat(6);
log(
`Cost Estimates for push:
${spaces}Size: ${formattedSize}
${spaces}Cost: ~${costInARWithPrecision} AR (~${costInUSDWithPrecision} USD)
`,
{ color: "green" }
);
log("Uploading to Arweave ...");
({ txId: dataTxId, pushCancelled } = await uploadRepo(
buffer,
await getTags(repo.name, repo.description),
bufferSize,
costInAR
));
} catch (error) {
log(error?.message || error, { color: "red" });
pushCancelled = false;
}
if (!dataTxId)
return { success: false, pushCancelled };
log("Updating in AO ...");
const updated = await updateRepo(repo, dataTxId);
return { success: updated.id === repo.id, pushCancelled };
};
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 path4 from "path";
// src/lib/analytics.ts
import {
init as amplitudeInit,
track as amplitudeTrack
} from "@amplitude/analytics-node";
import machine from "node-machine-id";
var AMPLITUDE_TRACKING_ID = "92a463755ed8c8b96f0f2353a37b7b2";
var PLATFORM = "@protocol.land/git-remote-helper";
var isInitialized = false;
var initializeAmplitudeAnalytics = async () => {
if (isInitialized)
return;
await amplitudeInit(AMPLITUDE_TRACKING_ID).promise;
isInitialized = true;
};
var trackAmplitudeAnalyticsEvent = async (category, action, label, wallet2, data) => {
try {
let eventOptions = {
user_id: void 0,
device_id: void 0
};
await initializeAmplitudeAnalytics();
const { response: userAddress } = await withAsync(() => {
if (wallet2) {
return getAddress(wallet2);
}
return "";
});
if (userAddress) {
eventOptions = { user_id: userAddress };
} else {
const { response: machineId } = await withAsync(
() => machine.machineId(true)
);
if (machineId) {
eventOptions = { device_id: machineId };
}
}
if (eventOptions?.user_id || eventOptions?.device_id) {
await amplitudeTrack(
category,
{
action,
label,
platform: PLATFORM,
...data
},
eventOptions
).promise;
}
} catch (error) {
}
};
var trackRepositoryUpdateEvent = async (wallet2, data) => {
await trackAmplitudeAnalyticsEvent(
"Repository",
"Add files to repo",
"Add files",
wallet2,
data
);
};
var trackRepositoryCloneEvent = async (wallet2, data) => {
await trackAmplitudeAnalyticsEvent(
"Repository",
"Clone a repo",
"Clone repo",
wallet2,
data
);
};
// src/lib/githubSync.ts
import Arweave2 from "arweave";
import crypto2 from "crypto";
async function deriveAddress2(publicKey) {
const arweave2 = Arweave2.init({
host: "ar-io.net",
port: 443,
protocol: "https"
});
const pubKeyBuf = arweave2.utils.b64UrlToBuffer(publicKey);
const sha512DigestBuf = await crypto2.subtle.digest("SHA-512", pubKeyBuf);
return arweave2.utils.bufferTob64Url(new Uint8Array(sha512DigestBuf));
}
async function decryptPAT(encryptedPATString, privateStateTxId) {
const arweave2 = new Arweave2({
host: "ar-io.net",
port: 443,
protocol: "https"
});
const encryptedPAT = arweave2.utils.b64UrlToBuffer(encryptedPATString);
const response = await fetch(`https://arweave.net/${privateStateTxId}`);
const privateState = await response.json();
const ivArrBuff = arweave2.utils.b64UrlToBuffer(privateState.iv);
const pubKey = await getActivePublicKey();
const address = await deriveAddress2(pubKey);
const encAesKeyStr = privateState.encKeys[address];
const encAesKeyBuf = arweave2.utils.b64UrlToBuffer(encAesKeyStr);
const aesKey = await decryptAesKeyWithPrivateKey(
encAesKeyBuf
);
const accessToken = await decryptFileWithAesGcm(
encryptedPAT,
aesKey,
ivArrBuff
);
return new TextDecoder().decode(accessToken);
}
async function triggerGithubSync(repo) {
try {
if (!repo)
return;
const githubSync = repo.githubSync;
if (!githubSync || !githubSync?.enabled)
return;
const connectedAddress = await getAddress();
const isAllowed = githubSync?.allowed?.includes(connectedAddress);
if (!isAllowed || !githubSync.repository || !githubSync.workflowId || !githubSync.branch || !githubSync.accessToken || !githubSync.privateStateTxId) {
return;
}
const accessToken = await decryptPAT(
githubSync.accessToken,
githubSync.privateStateTxId
);
if (!accessToken)
return;
const response = await fetch(
`https://api.github.com/repos/${githubSync?.repository}/actions/workflows/${githubSync?.workflowId}/dispatches`,
{
method: "POST",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${accessToken}`
},
body: JSON.stringify({
ref: githubSync?.branch,
inputs: { repoId: repo.id }
})
}
);
if (response.status === 204) {
log("Successfully triggered GitHub Sync");
}
} catch (err) {
}
}
// src/lib/remoteHelper.ts
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 = path4.join(tmpPath, repo.dataTxId);
talkToGit(bareRemotePath, repo, tmpPath);
};
function getTmpPath(gitdir2) {
const tmpPath = path4.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 = readline2.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) => {
let wallet2;
if (gitCommand === "git-receive-pack") {
wallet2 = getWallet({ warn: false });
if (!wallet2)
process.exit(0);
const ownerOrContrib = await ownerOrContributor(repo, wallet2, {
pushing: true
});
if (!ownerOrContrib)
process.exit(0);
} else {
wallet2 = getWallet({ warn: true });
if (wallet2) {
ownerOrContributor(repo, wallet2);
}
}
let objectsUpdated = false;
const isCloningRepo = gitCommand === "git-upload-pack" && tmpPath.split(path4.sep).length > 2;
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 = path4.join(remoteUrl2, "..", "..", "..");
waitFor(1e3);
const { success, pushCancelled } = await uploadProtocolLandRepo(
pathToPack,
repo,
tmpPath
);
clearCache(tmpPath, { keepFolders: [] });
unsetCacheDirty(tmpPath, repo.dataTxId);
if (pushCancelled) {
log(
`Pushing repo '${repo.id}' to Protocol Land was cancelled`,
{
color: "red"
}
);
process.exit(0);
}
if (success) {
await triggerGithubSync(repo);
log(`Successfully pushed repo '${repo.id}' to Protocol Land`, {
color: "green"
});
await trackRepositoryUpdateEvent(wallet2, {
repo_name: repo.name,
repo_id: repo.id,
result: "SUCCESS"
});
} 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"
}
);
await trackRepositoryUpdateEvent(wallet2, {
repo_name: repo.name,
repo_id: repo.id,
result: "FAILED",
error: "Failed to update repository"
});
process.exit(1);
}
} else if (isCloningRepo) {
setGitRemoteOrigin(repo);
await trackRepositoryCloneEvent(wallet2, {
repo_name: repo.name,
repo_id: repo.id,
result: "SUCCESS"
});
}
});
};
// src/index.ts
var [remoteName, remoteUrl] = process.argv.slice(2, 4);
if (!gitdir)
throw new Error("Missing GIT_DIR env");
remoteHelper({
remoteName,
remoteUrl,
gitdir
}).then();