Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/@graphprotocol_graph-cli-2041-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@graphprotocol/graph-cli": patch
---
dependencies updates:
- Added dependency [`decompress@^4.2.1` ↗︎](https://www.npmjs.com/package/decompress/v/4.2.1) (to `dependencies`)
- Added dependency [`progress@^2.0.3` ↗︎](https://www.npmjs.com/package/progress/v/2.0.3) (to `dependencies`)
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
163 changes: 163 additions & 0 deletions packages/cli/src/command-helpers/local-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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';

// Add GitHub repository configuration via environment variables with defaults
const GRAPH_NODE_GITHUB_OWNER = process.env.GRAPH_NODE_GITHUB_OWNER || 'graphprotocol';
const GRAPH_NODE_GITHUB_REPO = process.env.GRAPH_NODE_GITHUB_REPO || 'graph-node';

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-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(GRAPH_NODE_GITHUB_OWNER, GRAPH_NODE_GITHUB_REPO);
}

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

try {
return await downloadGithubRelease(
GRAPH_NODE_GITHUB_OWNER,
GRAPH_NODE_GITHUB_REPO,
release,
outputDir,
fileName,
onProgress,
);
} catch (e) {
if (e === 404) {
throw new Error(
`Graph Node release ${release} does not exist, please check the release page for the correct release tag`,
);
}

throw new Error(`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 platform = os.platform();
const binaryName = platform === 'win32' ? 'gnd.exe' : 'gnd';
const destPath = path.join(targetDir, binaryName);
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;
}
113 changes: 113 additions & 0 deletions packages/cli/src/commands/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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.',
}),
'bin-dir': Flags.string({
summary: 'Directory to install the Graph Node binary to.',
}),
};

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

static examples = [
'$ graph node install',
'$ graph node install --tag v1.0.0',
'$ graph node install --bin-dir /usr/local/bin',
];

static strict = false;

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

if (args.install) {
try {
await installGraphNode(flags.tag, flags['bin-dir']);
} catch (e) {
this.error(`Failed to install: ${e.message}`, { exit: 1 });
}
return;
}

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

async function installGraphNode(tag?: string, binDir?: 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;

const 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));
},
);

let extractedPath: string;

print.info(`\nExtracting binary...`);
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, binDir);
print.info(`✅ Graph Node ${latestRelease} installed successfully`);
print.info(`Binary location: ${movedPath}`);

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

print.info('');
print.info(`📋 Next steps:`);
print.info(` Add ${path.dirname(movedPath)} to your PATH (if not already)`);
print.info(` Run 'gnd' to start your local Graph Node development environment`);
print.info('');

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