Repository: graphprotocol/graph-tooling (ts)

Issue:
show warning if user is using unsupported node.js version 
check in the CLI if there are incompatible Node versions and show an error?

Pull request description:
Error on incompatible node version
Fixes #1678

Patch:
diff --git a/.changeset/angry-ties-kiss.md b/.changeset/angry-ties-kiss.md
new file mode 100644
index 00000000..52018c22
--- /dev/null
+++ b/.changeset/angry-ties-kiss.md
@@ -0,0 +1,5 @@
+---
+'@graphprotocol/graph-cli': patch
+---
+
+Fix empty source name for substreams subgraphs #1868
diff --git a/.changeset/little-badgers-cover.md b/.changeset/little-badgers-cover.md
new file mode 100644
index 00000000..26f42112
--- /dev/null
+++ b/.changeset/little-badgers-cover.md
@@ -0,0 +1,5 @@
+---
+'@graphprotocol/graph-cli': patch
+---
+
+error out if node is too old
diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js
index 886f324d..e005f942 100755
--- a/packages/cli/bin/run.js
+++ b/packages/cli/bin/run.js
@@ -1,4 +1,14 @@
 #!/usr/bin/env node
+import process from 'node:process';
+import semver from 'semver';
 import { execute } from '@oclif/core';
+import { nodeVersion } from '../dist/version.js';
+
+if (!semver.satisfies(process.version, nodeVersion)) {
+  process.stderr.write(
+    `Node.js version ${nodeVersion} is required. Current version: ${process.version}\n`,
+  );
+  process.exit(1);
+}
 
 await execute({ dir: import.meta.url });
diff --git a/packages/cli/src/command-helpers/file-resolver.ts b/packages/cli/src/command-helpers/file-resolver.ts
index 5a4b3643..9a6b273f 100644
--- a/packages/cli/src/command-helpers/file-resolver.ts
+++ b/packages/cli/src/command-helpers/file-resolver.ts
@@ -1,7 +1,6 @@
 import os from 'node:os';
 import path from 'node:path';
 import fs from 'fs-extra';
-import fetch from '../fetch.js';
 import { DEFAULT_IPFS_URL } from './ipfs.js';
 
 export interface FileSource {
@@ -42,7 +41,7 @@ export async function resolveFile(
       if (source.startsWith('Qm')) {
         const response = await fetch(`${DEFAULT_IPFS_URL}/cat?arg=${source}`);
         if (!response.ok) {
-          throw new Error(`Failed to fetch file from IPFS: ${response.statusText}`);
+          throw new Error(`failed to fetch from IPFS: ${response.statusText}`);
         }
         const filePath = path.join(tempDir, fileName);
         const buffer = Buffer.from(await response.arrayBuffer());
@@ -52,9 +51,9 @@ export async function resolveFile(
 
       // If it's a URL
       if (source.startsWith('http')) {
-        const response = await fetch(source);
+        const response = await fetch(source, { redirect: 'follow' });
         if (!response.ok) {
-          throw new Error(`Failed to fetch file from URL: ${response.statusText}`);
+          throw new Error(`failed to fetch from URL: ${response.statusText}`);
         }
         const filePath = path.join(tempDir, fileName);
         const buffer = Buffer.from(await response.arrayBuffer());
@@ -66,7 +65,10 @@ export async function resolveFile(
       throw new Error('Invalid file source. Must be a file path, IPFS hash, or URL');
     } catch (error) {
       cleanup();
-      throw error;
+      if (error instanceof Error) {
+        throw new Error(`Failed to resolve ${source} - ${error.message}`);
+      }
+      throw new Error(`Failed to resolve ${source}`);
     }
   };
 
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index f1f45bc6..4c35dfd8 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -32,6 +32,7 @@ const protocolChoices = Array.from(Protocol.availableProtocols().keys());
 const initDebugger = debugFactory('graph-cli:commands:init');
 
 const DEFAULT_EXAMPLE_SUBGRAPH = 'ethereum-gravatar';
+const DEFAULT_CONTRACT_NAME = 'Contract';
 
 export default class InitCommand extends Command {
   static description = 'Creates a new subgraph with basic scaffolding.';
@@ -61,7 +62,7 @@ export default class InitCommand extends Command {
       description: 'Creates a scaffold based on an example subgraph.',
       // TODO: using a default sets the value and therefore requires not to have --from-contract
       // default: 'Contract',
-      exclusive: ['from-contract'],
+      exclusive: ['from-contract', 'spkg'],
     }),
 
     'contract-name': Flags.string({
@@ -103,7 +104,6 @@ export default class InitCommand extends Command {
       summary: 'Network the contract is deployed to.',
       description:
         'Refer to https://github.com/graphprotocol/networks-registry/ for supported networks',
-      dependsOn: ['from-contract'],
     }),
 
     ipfs: Flags.string({
@@ -143,17 +143,14 @@ export default class InitCommand extends Command {
         'The --skip-git flag will be removed in the next major version. By default we will stop initializing a Git repository.',
       );
     }
+    if ((!fromContract || !spkgPath) && !network && !fromExample) {
+      this.error('--network is required when using --from-contract or --spkg');
+    }
 
     const { node } = chooseNodeUrl({
       node: nodeFlag,
     });
 
-    if (fromContract && fromExample) {
-      this.error('Only one of "--from-example" and "--from-contract" can be used at a time.', {
-        exit: 1,
-      });
-    }
-
     // Detect git
     const git = system.which('git');
     if (!git) {
@@ -200,7 +197,7 @@ export default class InitCommand extends Command {
 
     // If all parameters are provided from the command-line,
     // go straight to creating the subgraph from an existing contract
-    if (fromContract && protocol && subgraphName && directory && network && node) {
+    if ((fromContract || spkgPath) && protocol && subgraphName && directory && network && node) {
       const registry = await loadRegistry();
       const contractService = new ContractService(registry);
 
@@ -225,7 +222,7 @@ export default class InitCommand extends Command {
           }
         } else {
           try {
-            abi = await contractService.getABI(ABI, network, fromContract);
+            abi = await contractService.getABI(ABI, network, fromContract!);
           } catch (e) {
             this.exit(1);
           }
@@ -237,11 +234,11 @@ export default class InitCommand extends Command {
           protocolInstance,
           abi,
           directory,
-          source: fromContract,
+          source: fromContract!,
           indexEvents,
           network,
           subgraphName,
-          contractName,
+          contractName: contractName || DEFAULT_CONTRACT_NAME,
           node,
           startBlock,
           spkgPath,
@@ -303,7 +300,7 @@ export default class InitCommand extends Command {
           network: answers.network,
           source: answers.source,
           indexEvents: answers.indexEvents,
-          contractName: answers.contractName,
+          contractName: answers.contractName || DEFAULT_CONTRACT_NAME,
           node,
           startBlock: answers.startBlock,
           spkgPath: answers.spkgPath,
diff --git a/packages/cli/src/scaffold/index.ts b/packages/cli/src/scaffold/index.ts
index 1d570b51..66a14cee 100644
--- a/packages/cli/src/scaffold/index.ts
+++ b/packages/cli/src/scaffold/index.ts
@@ -118,6 +118,7 @@ export default class Scaffold {
 
   async generateManifest() {
     const protocolManifest = this.protocol.getManifestScaffold();
+    const name = this.contractName || getSubgraphBasename(String(this.subgraphName));
 
     return await prettier.format(
       `
@@ -128,7 +129,7 @@ schema:
   file: ./schema.graphql
 dataSources:
   - kind: ${this.protocol.name}
-    name: ${this.contractName}
+    name: ${name}
     network: ${this.network}
     source: ${protocolManifest.source({ ...this, spkgPath: './substreams.spkg', spkgModule: 'graph_out' })}
     mapping: ${protocolManifest.mapping(this)}
diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts
index 84a76c97..26971c75 100644
--- a/packages/cli/src/version.ts
+++ b/packages/cli/src/version.ts
@@ -15,3 +15,4 @@ const packageJson = JSON.parse(
 );
 
 export const version = packageJson.version as string;
+export const nodeVersion = (packageJson.engines?.node ?? '') as string;


Test patch:
diff --git a/packages/cli/src/command-helpers/file-resolver.test.ts b/packages/cli/src/command-helpers/file-resolver.test.ts
new file mode 100644
index 00000000..853c560b
--- /dev/null
+++ b/packages/cli/src/command-helpers/file-resolver.test.ts
@@ -0,0 +1,125 @@
+import os from 'node:os';
+import path from 'node:path';
+import fs from 'fs-extra';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { resolveFile } from './file-resolver';
+
+// Mock fetch globally
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+describe('resolveFile', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  afterEach(async () => {
+    // Clean up any temp directories that might have been created
+    const dirs = await fs.readdir(os.tmpdir());
+    for (const dir of dirs) {
+      if (dir.startsWith('graph-file-')) {
+        await fs.remove(path.join(os.tmpdir(), dir));
+      }
+    }
+  });
+
+  it('should handle local files', async () => {
+    const testFile = path.join(os.tmpdir(), 'test.txt');
+    await fs.writeFile(testFile, 'test content');
+
+    const result = await resolveFile(testFile, 'test.txt');
+    expect(result.path).toBe(testFile);
+    expect(result.cleanup).toBeUndefined();
+
+    await fs.remove(testFile);
+  });
+
+  it('should handle HTTP URLs with redirects', async () => {
+    const testUrl = 'https://example.com/file.txt';
+    const testContent = 'test content';
+
+    mockFetch.mockResolvedValueOnce({
+      ok: true,
+      arrayBuffer: async () => Buffer.from(testContent),
+    });
+
+    const result = await resolveFile(testUrl, 'file.txt');
+
+    expect(mockFetch).toHaveBeenCalledWith(testUrl, { redirect: 'follow' });
+    expect(result.path).toMatch(/graph-file-.*\/file\.txt$/);
+    expect(result.cleanup).toBeDefined();
+    expect(await fs.readFile(result.path, 'utf-8')).toBe(testContent);
+
+    if (result.cleanup) {
+      result.cleanup();
+    }
+  });
+
+  it('should handle IPFS hashes', async () => {
+    const ipfsHash = 'QmTest';
+    const testContent = 'test content';
+
+    mockFetch.mockResolvedValueOnce({
+      ok: true,
+      arrayBuffer: async () => Buffer.from(testContent),
+    });
+
+    const result = await resolveFile(ipfsHash, 'file.txt');
+
+    expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining(ipfsHash));
+    expect(result.path).toMatch(/graph-file-.*\/file\.txt$/);
+    expect(result.cleanup).toBeDefined();
+    expect(await fs.readFile(result.path, 'utf-8')).toBe(testContent);
+
+    if (result.cleanup) {
+      result.cleanup();
+    }
+  });
+
+  it('should handle failed HTTP fetches', async () => {
+    const testUrl = 'https://example.com/file.txt';
+
+    mockFetch.mockResolvedValueOnce({
+      ok: false,
+      statusText: 'Not Found',
+    });
+
+    await expect(resolveFile(testUrl, 'file.txt')).rejects.toThrow(
+      `Failed to resolve ${testUrl} - failed to fetch from URL: Not Found`,
+    );
+  });
+
+  it('should handle network errors', async () => {
+    const testUrl = 'https://example.com/file.txt';
+
+    mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+    await expect(resolveFile(testUrl, 'file.txt')).rejects.toThrow(
+      `Failed to resolve ${testUrl} - Network error`,
+    );
+  });
+
+  it('should handle timeout', async () => {
+    const testUrl = 'https://example.com/file.txt';
+
+    mockFetch.mockImplementationOnce(() => new Promise(resolve => setTimeout(resolve, 2000)));
+
+    await expect(resolveFile(testUrl, 'file.txt', 100)).rejects.toThrow('File download timed out');
+  });
+
+  it('should clean up temp files on error', async () => {
+    const testUrl = 'https://example.com/file.txt';
+
+    mockFetch.mockRejectedValueOnce(new Error('Network error'));
+
+    try {
+      await resolveFile(testUrl, 'file.txt');
+    } catch (e) {
+      // Expected error
+    }
+
+    const dirs = await fs.readdir(os.tmpdir());
+    const graphTempDirs = dirs.filter(dir => dir.startsWith('graph-file-'));
+    expect(graphTempDirs).toHaveLength(0);
+  });
+});
diff --git a/packages/cli/tests/cli/__snapshots__/init.test.ts.snap b/packages/cli/tests/cli/__snapshots__/init.test.ts.snap
index 17fbfbbc..a9fb74ba 100644
--- a/packages/cli/tests/cli/__snapshots__/init.test.ts.snap
+++ b/packages/cli/tests/cli/__snapshots__/init.test.ts.snap
@@ -285,3 +285,35 @@ Next steps:
 Make sure to visit the documentation on https://thegraph.com/docs/ for further information.
 "
 `;
+
+exports[`Init > Substreams > From package 1`] = `
+" ›   Warning: The --skip-git flag will be removed in the next major version. By
+ ›    default we will stop initializing a Git repository.
+- Create subgraph scaffold
+  Generate subgraph
+- Create subgraph scaffold
+  Write subgraph to directory
+- Create subgraph scaffold
+✔ Create subgraph scaffold
+- Install dependencies with yarn
+✔ Install dependencies with yarn
+"
+`;
+
+exports[`Init > Substreams > From package 2`] = `0`;
+
+exports[`Init > Substreams > From package 3`] = `
+"
+Subgraph user/subgraph-from-substreams created in from-package
+
+Next steps:
+
+  1. Run \`graph auth\` to authenticate with your deploy key.
+
+  2. Type \`cd from-package\` to enter the subgraph.
+
+  3. Run \`yarn deploy\` to deploy the subgraph.
+
+Make sure to visit the documentation on https://thegraph.com/docs/ for further information.
+"
+`;
diff --git a/packages/cli/tests/cli/init.test.ts b/packages/cli/tests/cli/init.test.ts
index efcacb18..874659a2 100644
--- a/packages/cli/tests/cli/init.test.ts
+++ b/packages/cli/tests/cli/init.test.ts
@@ -210,6 +210,33 @@ describe.sequential(
         },
       );
     });
+
+    describe('Substreams', () => {
+      const substreamsBaseDir = path.join(baseDir, 'substreams');
+
+      cliTest(
+        'From package',
+        [
+          'init',
+          '--skip-git',
+          '--protocol',
+          'substreams',
+          '--spkg',
+          path.join(substreamsBaseDir, 'substreams.spkg'),
+          '--network',
+          'mainnet',
+          'user/subgraph-from-substreams',
+          path.join(substreamsBaseDir, 'from-package'),
+        ],
+        path.join('init', 'substreams', 'from-package'),
+        {
+          exitCode: 0,
+          timeout: 100_000,
+          cwd: substreamsBaseDir,
+          deleteDir: true,
+        },
+      );
+    });
   },
   {
     timeout: 60_000,
diff --git a/packages/cli/tests/cli/init/substreams/substreams.spkg b/packages/cli/tests/cli/init/substreams/substreams.spkg
new file mode 100644
index 00000000..0884a0c1
--- /dev/null
+++ b/packages/cli/tests/cli/init/substreams/substreams.spkg
@@ -0,0 +1,1 @@
+test file, nothing to see here