Skip to content

Commit 42f3803

Browse files
incrypto32pinax-botYaroShkvorets
authored
Add graph node install command to install local dev node (#2041)
* Add `graph node install` command to install local dev node * Add changeset * Better error handling * Rename binaries approriately * improve installation UX and add custom bin directory support * Use the right graph protocol repo * catch exceptions * Make the github org and repo an env var * chore(dependencies): updated changesets for modified dependencies * Use Error constructor * fix lint --------- Co-authored-by: pinax-bot <ops@pinax.network> Co-authored-by: YaroShkvorets <shkvorets@gmail.com>
1 parent 31103e7 commit 42f3803

File tree

6 files changed

+489
-0
lines changed

6 files changed

+489
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@graphprotocol/graph-cli": patch
3+
---
4+
dependencies updates:
5+
- Added dependency [`decompress@^4.2.1` ↗︎](https://www.npmjs.com/package/decompress/v/4.2.1) (to `dependencies`)
6+
- Added dependency [`progress@^2.0.3` ↗︎](https://www.npmjs.com/package/progress/v/2.0.3) (to `dependencies`)

.changeset/easy-ideas-kick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphprotocol/graph-cli': minor
3+
---
4+
5+
Add a new command to install graph-node dev binary (gnd)

packages/cli/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"assemblyscript": "0.19.23",
4747
"chokidar": "4.0.3",
4848
"debug": "4.4.1",
49+
"decompress": "^4.2.1",
4950
"docker-compose": "1.2.0",
5051
"fs-extra": "11.3.0",
5152
"glob": "11.0.2",
@@ -57,6 +58,7 @@
5758
"kubo-rpc-client": "^5.0.2",
5859
"open": "10.1.2",
5960
"prettier": "3.5.3",
61+
"progress": "^2.0.3",
6062
"semver": "7.7.2",
6163
"tmp-promise": "3.0.3",
6264
"undici": "7.9.0",
@@ -65,8 +67,10 @@
6567
},
6668
"devDependencies": {
6769
"@types/debug": "^4.1.12",
70+
"@types/decompress": "^4.2.7",
6871
"@types/fs-extra": "^11.0.4",
6972
"@types/js-yaml": "^4.0.9",
73+
"@types/progress": "^2.0.7",
7074
"@types/semver": "^7.5.8",
7175
"@types/which": "^3.0.4",
7276
"copyfiles": "^2.4.1",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import * as fs from 'node:fs';
2+
import { createReadStream, createWriteStream } from 'node:fs';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
import { Readable } from 'node:stream';
6+
import { pipeline } from 'node:stream/promises';
7+
import { createGunzip } from 'node:zlib';
8+
import decompress from 'decompress';
9+
import fetch from '../fetch.js';
10+
11+
// Add GitHub repository configuration via environment variables with defaults
12+
const GRAPH_NODE_GITHUB_OWNER = process.env.GRAPH_NODE_GITHUB_OWNER || 'graphprotocol';
13+
const GRAPH_NODE_GITHUB_REPO = process.env.GRAPH_NODE_GITHUB_REPO || 'graph-node';
14+
15+
function getPlatformBinaryName(): string {
16+
const platform = os.platform();
17+
const arch = os.arch();
18+
19+
if (platform === 'linux' && arch === 'x64') return 'gnd-linux-x86_64.gz';
20+
if (platform === 'linux' && arch === 'arm64') return 'gnd-linux-aarch64.gz';
21+
if (platform === 'darwin' && arch === 'x64') return 'gnd-macos-x86_64.gz';
22+
if (platform === 'darwin' && arch === 'arm64') return 'gnd-macos-aarch64.gz';
23+
if (platform === 'win32' && arch === 'x64') return 'gnd-windows-x86_64.exe.zip';
24+
25+
throw new Error(`Unsupported platform: ${platform} ${arch}`);
26+
}
27+
28+
export async function getGlobalBinDir(): Promise<string> {
29+
const platform = os.platform();
30+
let binDir: string;
31+
32+
if (platform === 'win32') {
33+
// Prefer %USERPROFILE%\gnd\bin
34+
binDir = path.join(process.env.USERPROFILE || os.homedir(), 'gnd', 'bin');
35+
} else {
36+
binDir = path.join(os.homedir(), '.local', 'bin');
37+
}
38+
39+
await fs.promises.mkdir(binDir, { recursive: true });
40+
return binDir;
41+
}
42+
43+
async function getLatestGithubRelease(owner: string, repo: string) {
44+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
45+
const data = await res.json();
46+
return data.tag_name;
47+
}
48+
49+
export async function getLatestGraphNodeRelease(): Promise<string> {
50+
return getLatestGithubRelease(GRAPH_NODE_GITHUB_OWNER, GRAPH_NODE_GITHUB_REPO);
51+
}
52+
53+
export async function downloadGraphNodeRelease(
54+
release: string,
55+
outputDir: string,
56+
onProgress?: (downloaded: number, total: number | null) => void,
57+
): Promise<string> {
58+
const fileName = getPlatformBinaryName();
59+
60+
try {
61+
return await downloadGithubRelease(
62+
GRAPH_NODE_GITHUB_OWNER,
63+
GRAPH_NODE_GITHUB_REPO,
64+
release,
65+
outputDir,
66+
fileName,
67+
onProgress,
68+
);
69+
} catch (e) {
70+
if (e === 404) {
71+
throw new Error(
72+
`Graph Node release ${release} does not exist, please check the release page for the correct release tag`,
73+
);
74+
}
75+
76+
throw new Error(`Failed to download: ${release}`);
77+
}
78+
}
79+
80+
async function downloadGithubRelease(
81+
owner: string,
82+
repo: string,
83+
release: string,
84+
outputDir: string,
85+
fileName: string,
86+
onProgress?: (downloaded: number, total: number | null) => void,
87+
): Promise<string> {
88+
const url = `https://github.com/${owner}/${repo}/releases/download/${release}/${fileName}`;
89+
return downloadFile(url, path.join(outputDir, fileName), onProgress);
90+
}
91+
92+
export async function downloadFile(
93+
url: string,
94+
outputPath: string,
95+
onProgress?: (downloaded: number, total: number | null) => void,
96+
): Promise<string> {
97+
return download(url, outputPath, onProgress);
98+
}
99+
100+
export async function download(
101+
url: string,
102+
outputPath: string,
103+
onProgress?: (downloaded: number, total: number | null) => void,
104+
): Promise<string> {
105+
const res = await fetch(url);
106+
if (!res.ok || !res.body) {
107+
throw res.status;
108+
}
109+
110+
const totalLength = Number(res.headers.get('content-length')) || null;
111+
let downloaded = 0;
112+
113+
const fileStream = fs.createWriteStream(outputPath);
114+
const nodeStream = Readable.from(res.body);
115+
116+
nodeStream.on('data', chunk => {
117+
downloaded += chunk.length;
118+
onProgress?.(downloaded, totalLength);
119+
});
120+
121+
nodeStream.pipe(fileStream);
122+
123+
await new Promise<void>((resolve, reject) => {
124+
nodeStream.on('error', reject);
125+
fileStream.on('finish', resolve);
126+
fileStream.on('error', reject);
127+
});
128+
129+
return outputPath;
130+
}
131+
132+
export async function extractGz(gzPath: string, outputPath?: string): Promise<string> {
133+
const outPath = outputPath || path.join(path.dirname(gzPath), path.basename(gzPath, '.gz'));
134+
135+
await pipeline(createReadStream(gzPath), createGunzip(), createWriteStream(outPath));
136+
137+
return outPath;
138+
}
139+
140+
export async function extractZipAndGetExe(zipPath: string, outputDir: string): Promise<string> {
141+
const files = await decompress(zipPath, outputDir);
142+
const exe = files.filter(file => file.path.endsWith('.exe'));
143+
144+
if (exe.length !== 1) {
145+
throw new Error(`Expected 1 executable file in zip, got ${exe.length}`);
146+
}
147+
148+
return path.join(outputDir, exe[0].path);
149+
}
150+
151+
export async function moveFileToBinDir(srcPath: string, binDir?: string): Promise<string> {
152+
const targetDir = binDir || (await getGlobalBinDir());
153+
const platform = os.platform();
154+
const binaryName = platform === 'win32' ? 'gnd.exe' : 'gnd';
155+
const destPath = path.join(targetDir, binaryName);
156+
await fs.promises.rename(srcPath, destPath);
157+
return destPath;
158+
}
159+
160+
export async function moveFile(srcPath: string, destPath: string): Promise<string> {
161+
await fs.promises.rename(srcPath, destPath);
162+
return destPath;
163+
}

packages/cli/src/commands/node.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import * as fs from 'node:fs';
2+
import { chmod } from 'node:fs/promises';
3+
import * as os from 'node:os';
4+
import * as path from 'node:path';
5+
import { print } from 'gluegun';
6+
import ProgressBar from 'progress';
7+
import { Args, Command, Flags } from '@oclif/core';
8+
import {
9+
downloadGraphNodeRelease,
10+
extractGz,
11+
extractZipAndGetExe,
12+
getLatestGraphNodeRelease,
13+
moveFileToBinDir,
14+
} from '../command-helpers/local-node.js';
15+
16+
export default class NodeCommand extends Command {
17+
static description = 'Manage Graph node related operations';
18+
19+
static override flags = {
20+
help: Flags.help({
21+
char: 'h',
22+
}),
23+
tag: Flags.string({
24+
summary: 'Tag of the Graph Node release to install.',
25+
}),
26+
'bin-dir': Flags.string({
27+
summary: 'Directory to install the Graph Node binary to.',
28+
}),
29+
};
30+
31+
static override args = {
32+
install: Args.boolean({
33+
description: 'Install the Graph Node',
34+
}),
35+
};
36+
37+
static examples = [
38+
'$ graph node install',
39+
'$ graph node install --tag v1.0.0',
40+
'$ graph node install --bin-dir /usr/local/bin',
41+
];
42+
43+
static strict = false;
44+
45+
async run() {
46+
const { flags, args } = await this.parse(NodeCommand);
47+
48+
if (args.install) {
49+
try {
50+
await installGraphNode(flags.tag, flags['bin-dir']);
51+
} catch (e) {
52+
this.error(`Failed to install: ${e.message}`, { exit: 1 });
53+
}
54+
return;
55+
}
56+
57+
// If no valid subcommand is provided, show help
58+
await this.config.runCommand('help', ['node']);
59+
}
60+
}
61+
62+
async function installGraphNode(tag?: string, binDir?: string) {
63+
const latestRelease = tag || (await getLatestGraphNodeRelease());
64+
const tmpBase = os.tmpdir();
65+
const tmpDir = await fs.promises.mkdtemp(path.join(tmpBase, 'graph-node-'));
66+
let progressBar: ProgressBar | undefined;
67+
68+
const downloadPath = await downloadGraphNodeRelease(
69+
latestRelease,
70+
tmpDir,
71+
(downloaded, total) => {
72+
if (!total) return;
73+
74+
progressBar ||= new ProgressBar(`Downloading ${latestRelease} [:bar] :percent`, {
75+
width: 30,
76+
total,
77+
complete: '=',
78+
incomplete: ' ',
79+
});
80+
81+
progressBar.tick(downloaded - (progressBar.curr || 0));
82+
},
83+
);
84+
85+
let extractedPath: string;
86+
87+
print.info(`\nExtracting binary...`);
88+
if (downloadPath.endsWith('.gz')) {
89+
extractedPath = await extractGz(downloadPath);
90+
} else if (downloadPath.endsWith('.zip')) {
91+
extractedPath = await extractZipAndGetExe(downloadPath, tmpDir);
92+
} else {
93+
print.error(`Unsupported file type: ${downloadPath}`);
94+
throw new Error(`Unsupported file type: ${downloadPath}`);
95+
}
96+
97+
const movedPath = await moveFileToBinDir(extractedPath, binDir);
98+
print.info(`✅ Graph Node ${latestRelease} installed successfully`);
99+
print.info(`Binary location: ${movedPath}`);
100+
101+
if (os.platform() !== 'win32') {
102+
await chmod(movedPath, 0o755);
103+
}
104+
105+
print.info('');
106+
print.info(`📋 Next steps:`);
107+
print.info(` Add ${path.dirname(movedPath)} to your PATH (if not already)`);
108+
print.info(` Run 'gnd' to start your local Graph Node development environment`);
109+
print.info('');
110+
111+
// Delete the temporary directory
112+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
113+
}

0 commit comments

Comments
 (0)