Skip to content

Add graph node install command to install local dev node #2041

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/easy-ideas-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphprotocol/graph-cli': minor
---

Add a new command to install graph-node dev binary (gnd)
4 changes: 4 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"assemblyscript": "0.19.23",
"chokidar": "4.0.3",
"debug": "4.4.1",
"decompress": "^4.2.1",
"docker-compose": "1.2.0",
"fs-extra": "11.3.0",
"glob": "11.0.2",
Expand All @@ -57,6 +58,7 @@
"kubo-rpc-client": "^5.0.2",
"open": "10.1.2",
"prettier": "3.5.3",
"progress": "^2.0.3",
"semver": "7.7.2",
"tmp-promise": "3.0.3",
"undici": "7.9.0",
Expand All @@ -65,8 +67,10 @@
},
"devDependencies": {
"@types/debug": "^4.1.12",
"@types/decompress": "^4.2.7",
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9",
"@types/progress": "^2.0.7",
"@types/semver": "^7.5.8",
"@types/which": "^3.0.4",
"copyfiles": "^2.4.1",
Expand Down
154 changes: 154 additions & 0 deletions packages/cli/src/command-helpers/local-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import * as fs from 'node:fs';
import { createReadStream, createWriteStream } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { createGunzip } from 'node:zlib';
import decompress from 'decompress';
import fetch from '../fetch.js';

function getPlatformBinaryName(): string {
const platform = os.platform();
const arch = os.arch();

if (platform === 'linux' && arch === 'x64') return 'gnd-linux-x86_64.gz';
if (platform === 'linux' && arch === 'arm64') return 'gnd-linux-aarch64.gz';
if (platform === 'darwin' && arch === 'x64') return 'gnd-macos-x86_64.gz';
if (platform === 'darwin' && arch === 'arm64') return 'gnd-windows-x86_64.exe.zip'; //'gnd-macos-aarch64.gz';
if (platform === 'win32' && arch === 'x64') return 'gnd-windows-x86_64.exe.zip';

throw new Error(`Unsupported platform: ${platform} ${arch}`);
}

export async function getGlobalBinDir(): Promise<string> {
const platform = os.platform();
let binDir: string;

if (platform === 'win32') {
// Prefer %USERPROFILE%\gnd\bin
binDir = path.join(process.env.USERPROFILE || os.homedir(), 'gnd', 'bin');
} else {
binDir = path.join(os.homedir(), '.local', 'bin');
}

await fs.promises.mkdir(binDir, { recursive: true });
return binDir;
}

async function getLatestGithubRelease(owner: string, repo: string) {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
const data = await res.json();
return data.tag_name;
}

export async function getLatestGraphNodeRelease(): Promise<string> {
return getLatestGithubRelease('incrypto32', 'graph-node');
}

export async function downloadGraphNodeRelease(
release: string,
outputDir: string,
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<string> {
const fileName = getPlatformBinaryName();

try {
return await downloadGithubRelease(
'incrypto32',
'graph-node',
release,
outputDir,
fileName,
onProgress,
);
} catch (e) {
if (e === 404) {
throw `Graph Node release ${release} does not exist, please check the release page for the correct release tag`;
}

throw `Failed to download: ${release}`;
}
}

async function downloadGithubRelease(
owner: string,
repo: string,
release: string,
outputDir: string,
fileName: string,
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<string> {
const url = `https://github.com/${owner}/${repo}/releases/download/${release}/${fileName}`;
return downloadFile(url, path.join(outputDir, fileName), onProgress);
}

export async function downloadFile(
url: string,
outputPath: string,
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<string> {
return download(url, outputPath, onProgress);
}

export async function download(
url: string,
outputPath: string,
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<string> {
const res = await fetch(url);
if (!res.ok || !res.body) {
throw res.status;
}

const totalLength = Number(res.headers.get('content-length')) || null;
let downloaded = 0;

const fileStream = fs.createWriteStream(outputPath);
const nodeStream = Readable.from(res.body);

nodeStream.on('data', chunk => {
downloaded += chunk.length;
onProgress?.(downloaded, totalLength);
});

nodeStream.pipe(fileStream);

await new Promise<void>((resolve, reject) => {
nodeStream.on('error', reject);
fileStream.on('finish', resolve);
fileStream.on('error', reject);
});

return outputPath;
}

export async function extractGz(gzPath: string, outputPath?: string): Promise<string> {
const outPath = outputPath || path.join(path.dirname(gzPath), path.basename(gzPath, '.gz'));

await pipeline(createReadStream(gzPath), createGunzip(), createWriteStream(outPath));

return outPath;
}

export async function extractZipAndGetExe(zipPath: string, outputDir: string): Promise<string> {
const files = await decompress(zipPath, outputDir);
const exe = files.filter(file => file.path.endsWith('.exe'));

if (exe.length !== 1) {
throw new Error(`Expected 1 executable file in zip, got ${exe.length}`);
}

return path.join(outputDir, exe[0].path);
}

export async function moveFileToBinDir(srcPath: string, binDir?: string): Promise<string> {
const targetDir = binDir || (await getGlobalBinDir());
const destPath = path.join(targetDir, path.basename(srcPath));
await fs.promises.rename(srcPath, destPath);
return destPath;
}
export async function moveFile(srcPath: string, destPath: string): Promise<string> {
await fs.promises.rename(srcPath, destPath);
return destPath;
}
106 changes: 106 additions & 0 deletions packages/cli/src/commands/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import * as fs from 'node:fs';
import { chmod } from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { print } from 'gluegun';
import ProgressBar from 'progress';
import { Args, Command, Flags } from '@oclif/core';
import {
downloadGraphNodeRelease,
extractGz,
extractZipAndGetExe,
getLatestGraphNodeRelease,
moveFileToBinDir,
} from '../command-helpers/local-node.js';

export default class NodeCommand extends Command {
static description = 'Manage Graph node related operations';

static override flags = {
help: Flags.help({
char: 'h',
}),
tag: Flags.string({
summary: 'Tag of the Graph Node release to install.',
}),
'download-dir': Flags.string({
summary: 'Directory to download the Graph Node release to.',
default: os.tmpdir(),
}),
};

static override args = {
install: Args.boolean({
description: 'Install the Graph Node',
}),
};

static examples = ['$ graph node install'];

static strict = false;

async run() {
const { flags, args } = await this.parse(NodeCommand);

if (args.install) {
await installGraphNode(flags.tag);
return;
}

// If no valid subcommand is provided, show help
await this.config.runCommand('help', ['node']);
}
}

async function installGraphNode(tag?: string) {
const latestRelease = tag || (await getLatestGraphNodeRelease());
const tmpBase = os.tmpdir();
const tmpDir = await fs.promises.mkdtemp(path.join(tmpBase, 'graph-node-'));
let progressBar: ProgressBar | undefined;

let downloadPath: string;
try {
downloadPath = await downloadGraphNodeRelease(latestRelease, tmpDir, (downloaded, total) => {
if (!total) return;

progressBar ||= new ProgressBar(`Downloading ${latestRelease} [:bar] :percent`, {
width: 30,
total,
complete: '=',
incomplete: ' ',
});

progressBar.tick(downloaded - (progressBar.curr || 0));
});
} catch (e) {
print.error(e);
throw e;
}

let extractedPath: string;

print.info(`Extracting ${downloadPath}`);
if (downloadPath.endsWith('.gz')) {
extractedPath = await extractGz(downloadPath);
} else if (downloadPath.endsWith('.zip')) {
extractedPath = await extractZipAndGetExe(downloadPath, tmpDir);
} else {
print.error(`Unsupported file type: ${downloadPath}`);
throw new Error(`Unsupported file type: ${downloadPath}`);
}

const movedPath = await moveFileToBinDir(extractedPath);
print.info(`Moved ${extractedPath} to ${movedPath}`);

if (os.platform() !== 'win32') {
await chmod(movedPath, 0o755);
}

print.info(`Installed Graph Node ${latestRelease}`);
print.info(
`Please add the following to your PATH: ${path.dirname(movedPath)} if it's not already there or if you're using a custom download directory`,
);

// Delete the temporary directory
await fs.promises.rm(tmpDir, { recursive: true, force: true });
}
Loading
Loading