diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index a5cfb46..a58bdaa 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -66,7 +66,7 @@ jobs: strategy: matrix: - node-version: [16.x, 20.x] + node-version: [20.x, 22.x, 24.x, 26.x] steps: - uses: actions/checkout@v3 @@ -79,6 +79,11 @@ jobs: run: | npm i -g npm@7 --registry=https://registry.npmjs.org + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Cache node modules id: cache-nodemodules uses: actions/cache@v3 @@ -98,4 +103,6 @@ jobs: if: steps.cache-nodemodules.outputs.cache-hit != 'true' run: yarn install - run: yarn run build + # Exercises `npm install` / `yarn install` / `pnpm install` + build for + # every supported --package-manager option of `hooks-cli init`. - run: yarn run test:integration diff --git a/README.md b/README.md index c714650..78ca3a1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,13 @@ Use: You can initialize a new project by running: ```bash -hooks-cli init +hooks-cli init +``` + +By default, the generated project's scripts use `npm`. To use `yarn` or `pnpm` instead, pass `--package-manager`: + +```bash +hooks-cli init js my-project --package-manager pnpm ``` To build the c contracts, run: diff --git a/src/cli.ts b/src/cli.ts index 88904f3..9c79936 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,6 +35,11 @@ export async function main(target?: Target) { .description("Initialize a new project") .argument("type", "The type of project to initialize, 'c' or 'js'") .argument("folderName", "The name of the folder to initialize") + .option( + "-p, --package-manager ", + "The package manager to use in the generated project ('npm', 'yarn', or 'pnpm')", + "npm" + ) .showHelpAfterError() .action(initCommand); diff --git a/src/commands.ts b/src/commands.ts index 59022f7..216c724 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,6 +8,30 @@ import { addListeners, ISelect } from "./debug"; import axios from "axios"; import dotenv from "dotenv"; +export type PackageManager = "npm" | "yarn" | "pnpm"; +const PACKAGE_MANAGERS: PackageManager[] = ["npm", "yarn", "pnpm"]; + +const applyPackageManager = ( + projectDir: string, + packageManager: PackageManager +) => { + const packageJsonPath = path.join(projectDir, "package.json"); + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")); + if (pkg.scripts) { + for (const name of Object.keys(pkg.scripts)) { + pkg.scripts[name] = pkg.scripts[name].replace( + /\bnpm\b/g, + packageManager + ); + } + } + fs.writeFileSync( + packageJsonPath, + `${JSON.stringify(pkg, null, 2)}\n`, + "utf-8" + ); +}; + const copyFiles = (source: string, destination: string) => { fs.readdirSync(source).forEach((file) => { const srcFile = path.join(source, file); @@ -90,7 +114,22 @@ const validateJSCode = (filePath: string) => { } }; -export const initCommand = async (type: "c" | "js", folderName: string) => { +export const initCommand = async ( + type: "c" | "js", + folderName: string, + options?: { packageManager?: string } +) => { + const packageManager = (options?.packageManager ?? + "npm") as PackageManager; + if (!PACKAGE_MANAGERS.includes(packageManager)) { + console.error( + `Invalid package manager "${packageManager}". Use one of: ${PACKAGE_MANAGERS.join( + ", " + )}.` + ); + process.exit(1); + } + const templateDir = path.join(__dirname, "init", type); const newProjectDir = path.join(process.cwd(), folderName); @@ -103,6 +142,7 @@ export const initCommand = async (type: "c" | "js", folderName: string) => { if (type === "c" || type === "js") { copyFiles(templateDir, newProjectDir); + applyPackageManager(newProjectDir, packageManager); console.log( `Created ${ type === "c" ? "CHooks" : "JSHooks" diff --git a/src/init/c/package.json b/src/init/c/package.json index 763ee99..e54ae4a 100644 --- a/src/init/c/package.json +++ b/src/init/c/package.json @@ -5,19 +5,17 @@ "main": "src/index.ts", "scripts": { "build": "hooks-cli compile-c contracts build/ --headers contracts/include", - "deploy": "yarn run build && ts-node src/index.ts" - }, - "author": { - "name": "Denis Angell", - "url": "https://github.com/dangell7" + "deploy": "npm run build && ts-node src/index.ts" }, "license": "ISC", "dependencies": { - "@transia/hooks-toolkit": "^2.0.0-alpha.4", - "dotenv": "^16.3.1" + "@xahau/hooks-toolkit": "^2.1.0", + "dotenv": "^16.3.1", + "xahau": "^4.1.1" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", + "@xahau/hooks-cli": "^2.1.0", "ts-node": "^10.9.1", "typescript": "^4.9.5" } diff --git a/src/init/c/src/index.ts b/src/init/c/src/index.ts index 9b86333..d3e8c8b 100644 --- a/src/init/c/src/index.ts +++ b/src/init/c/src/index.ts @@ -1,23 +1,17 @@ -import { - Client, - Wallet, - Invoke, - SetHookFlags, - TransactionMetadata, -} from "@transia/xrpl"; +import { Client, Wallet, Invoke, TransactionMetadata } from "xahau"; +import { HookFlags } from "xahau/dist/npm/models/common/xahau"; import { createHookPayload, - setHooksV3, + setHooks, SetHookParams, Xrpld, ExecutionUtility, -} from "@transia/hooks-toolkit"; +} from "@xahau/hooks-toolkit"; import "dotenv/config"; export async function main(): Promise { const client = new Client(process.env.XRPLD_WSS || ""); await client.connect(); - client.networkID = await client.getNetworkID(); const aliceWallet = Wallet.fromSeed(process.env.ALICE_SEED || ""); @@ -25,13 +19,13 @@ export async function main(): Promise { version: 0, createFile: "base", namespace: "base", - flags: SetHookFlags.hsfOverride, + flags: HookFlags.hsfOverride, hookOnArray: ["Invoke"], }); - await setHooksV3({ + await setHooks({ client: client, - seed: aliceWallet.seed, + wallet: aliceWallet, hooks: [{ Hook: hook }], } as SetHookParams); diff --git a/src/init/js/package.json b/src/init/js/package.json index ebfd7e9..42821e5 100644 --- a/src/init/js/package.json +++ b/src/init/js/package.json @@ -5,20 +5,18 @@ "main": "src/index.ts", "scripts": { "build": "hooks-cli compile-js contracts/base.ts build/", - "deploy": "yarn run build && ts-node src/index.ts" - }, - "author": { - "name": "Denis Angell", - "url": "https://github.com/dangell7" + "deploy": "npm run build && ts-node src/index.ts" }, "license": "ISC", "dependencies": { - "@transia/hooks-toolkit": "^2.0.0-alpha.4", + "@xahau/hooks-toolkit": "^2.1.0", "dotenv": "^16.3.1", - "jshooks-api": "^1.0.5" + "jshooks-api": "^1.0.5", + "xahau": "^4.1.1" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", + "@xahau/hooks-cli": "^2.1.0", "ts-node": "^10.9.1", "typescript": "^4.9.5" } diff --git a/src/init/js/src/index.ts b/src/init/js/src/index.ts index e863019..25494dc 100644 --- a/src/init/js/src/index.ts +++ b/src/init/js/src/index.ts @@ -1,23 +1,17 @@ -import { - Client, - Wallet, - Invoke, - SetHookFlags, - TransactionMetadata, -} from "@transia/xrpl"; +import { Client, Wallet, Invoke, TransactionMetadata } from "xahau"; +import { HookFlags } from "xahau/dist/npm/models/common/xahau"; import { createHookPayload, setHooksV3, SetHookParams, Xrpld, ExecutionUtility, -} from "@transia/hooks-toolkit"; +} from "@xahau/hooks-toolkit"; import "dotenv/config"; export async function main(): Promise { const client = new Client(process.env.XRPLD_WSS || ""); await client.connect(); - client.networkID = await client.getNetworkID(); const aliceWallet = Wallet.fromSeed(process.env.ALICE_SEED || ""); @@ -25,14 +19,14 @@ export async function main(): Promise { version: 1, createFile: "base", namespace: "base", - flags: SetHookFlags.hsfOverride, + flags: HookFlags.hsfOverride, hookOnArray: ["Invoke"], fee: "100000", }); await setHooksV3({ client: client, - seed: aliceWallet.seed, + wallet: aliceWallet, hooks: [{ Hook: hook }], } as SetHookParams); diff --git a/test/integration/build.test.ts b/test/integration/build.test.ts index 4a6b9f9..100d6fa 100644 --- a/test/integration/build.test.ts +++ b/test/integration/build.test.ts @@ -10,6 +10,7 @@ import * as path from "path"; describe("Build Tests", () => { const originalConsoleLog = console.log; const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; const originalProcessExit = process.exit; beforeAll(() => { @@ -17,6 +18,8 @@ describe("Build Tests", () => { console.log = jest.fn(); // Mock console.error to suppress error messages during tests console.error = jest.fn(); + // Mock console.warn to suppress warning messages during tests + console.warn = jest.fn(); // replace process.exit with a function that throws an error process.exit = jest.fn(() => { throw Error("Process exit called"); @@ -24,9 +27,10 @@ describe("Build Tests", () => { }); afterAll(() => { - // Restore original console.log, console.error and process.exit + // Restore original console.log, console.error, console.warn and process.exit console.log = originalConsoleLog; console.error = originalConsoleError; + console.warn = originalConsoleWarn; process.exit = originalProcessExit; }); diff --git a/test/integration/init-build.test.ts b/test/integration/init-build.test.ts new file mode 100644 index 0000000..cd5d6d9 --- /dev/null +++ b/test/integration/init-build.test.ts @@ -0,0 +1,141 @@ +import { initCommand } from "../../src/commands"; +import { execFileSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import axios from "axios"; + +jest.mock("axios"); + +// These tests exercise the actual ` install` / ` run build` flow of a +// freshly generated project for each supported package manager (as an end +// user would run them), so they need real network access to the npm +// registry and a longer timeout than the other integration tests. +describe("Init project install/build", () => { + const repoRoot = path.join(__dirname, "..", ".."); + const binDir = path.join(repoRoot, "bin"); + const cliEntry = path.join(repoRoot, "dist", "npm", "src", "cli.js"); + + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + beforeAll(() => { + console.log = jest.fn(); + console.error = jest.fn(); + + // The generated project's `build` script shells out to the `hooks-cli` + // binary, which requires this repo to already be built. + if (!fs.existsSync(cliEntry)) { + execFileSync("yarn", ["run", "build"], { + cwd: repoRoot, + stdio: "inherit", + }); + } + }); + + afterAll(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + const headerFiles = { + error: "#define INTERNAL_ERROR -2\n", + extern: "extern int64_t accept(uint32_t, uint32_t, int64_t);\n", + hookapi: '#include "macro.h"\n', + macro: "#define SBUF(str) (uint32_t)(str), sizeof(str)\n", + sfcodes: "#define sfAccount ((8U << 16U) + 1U)\n", + tts: "#define ttINVOKE 99\n", + }; + + const runInProject = ( + projectDir: string, + packageManager: string, + args: string[] + ) => + execFileSync(packageManager, args, { + cwd: projectDir, + env: { ...process.env, PATH: `${binDir}${path.delimiter}${process.env.PATH}` }, + stdio: "pipe", + }); + + describe.each([ + ["c", "npm"], + ["c", "yarn"], + ["c", "pnpm"], + ["js", "npm"], + ["js", "yarn"], + ["js", "pnpm"], + ])("%s project (%s)", (type, packageManager) => { + const folderName = `e2e-init-build-${type}-${packageManager}-project`; + // Generated deliberately outside the repo tree (rather than e.g. a + // subdirectory of repoRoot): initCommand's copyFiles gives the project + // its own node_modules, but Node's require() resolution still walks up + // through ancestor node_modules directories. If the project lived under + // repoRoot, a package missing from the project's own install could + // silently resolve from this repo's node_modules instead of failing + // (verified: repoRoot has several deps, e.g. dotenv, in common with the + // templates), masking a bug a real, non-nested user project would hit. + let tmpBase: string; + let projectPath: string; + + beforeEach(() => { + jest.clearAllMocks(); + tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "hooks-cli-init-")); + projectPath = path.join(tmpBase, folderName); + }); + + afterEach(() => { + fs.rmSync(tmpBase, { recursive: true, force: true }); + }); + + it( + "installs and builds successfully", + async () => { + (axios.get as jest.Mock).mockResolvedValue({ data: headerFiles }); + (axios.post as jest.Mock).mockResolvedValue({ + data: { code: "tesSUCCESS", secret: "ss8Smfd73swruz4LATV5xkmydjZd6" }, + }); + + // initCommand resolves the target directory from process.cwd(), so + // point it at tmpBase just for this call. + const originalCwd = process.cwd(); + process.chdir(tmpBase); + try { + await initCommand(type as "c" | "js", folderName, { + packageManager, + }); + } finally { + process.chdir(originalCwd); + } + expect(fs.existsSync(projectPath)).toBe(true); + + if (type === "c") { + // The mocked header files above are stand-ins used only to + // exercise initCommand's download/write logic; they are not real + // enough to compile against. Clearing them makes the CLI fall + // back to its bundled default headers, which is what a project + // generated without a live compile host would also do. + const includeDir = path.join(projectPath, "contracts", "include"); + fs.readdirSync(includeDir).forEach((file) => + fs.rmSync(path.join(includeDir, file)) + ); + } + + try { + runInProject(projectPath, packageManager, ["install"]); + runInProject(projectPath, packageManager, ["run", "build"]); + } catch (error: unknown) { + const err = error as { stdout?: Buffer; stderr?: Buffer }; + throw new Error( + `${packageManager} install/build failed for ${type} project:\n${err.stdout?.toString()}\n${err.stderr?.toString()}` + ); + } + + const buildDir = path.join(projectPath, "build"); + expect(fs.existsSync(buildDir)).toBe(true); + expect(fs.readdirSync(buildDir).length).toBeGreaterThan(0); + }, + 5 * 60 * 1000 + ); + }); +}); diff --git a/test/integration/init.test.ts b/test/integration/init.test.ts index 20cb4f0..e5eacdf 100644 --- a/test/integration/init.test.ts +++ b/test/integration/init.test.ts @@ -131,6 +131,10 @@ describe("Init Tests", () => { expect(generatedPackage.scripts.build).toBe( "hooks-cli compile-c contracts build/ --headers contracts/include" ); + // Defaults to npm when no package manager is specified. + expect(generatedPackage.scripts.deploy).toBe( + "npm run build && ts-node src/index.ts" + ); }); it("should initialize a new JS project", async () => { @@ -208,5 +212,44 @@ describe("Init Tests", () => { `Directory ${folderNameC} already exists.` ); }); + + describe.each(["npm", "yarn", "pnpm"])( + "packageManager=%s", + (packageManager) => { + it(`should rewrite the deploy script to use ${packageManager}`, async () => { + (axios.post as jest.Mock).mockResolvedValue({ + data: { + code: "tesSUCCESS", + secret: "ss8Smfd73swruz4LATV5xkmydjZd6", + }, + }); + + await expect( + initCommand("js", folderNameJS, { packageManager }) + ).resolves.not.toThrow(); + + const generatedPackage = JSON.parse( + fs.readFileSync( + path.join(projectPathJS, "package.json"), + "utf-8" + ) + ); + expect(generatedPackage.scripts.deploy).toBe( + `${packageManager} run build && ts-node src/index.ts` + ); + }); + } + ); + + it("should reject an unsupported package manager", async () => { + await expect( + initCommand("c", folderNameC, { packageManager: "bun" }) + ).rejects.toThrow(); + expect(process.exit).toHaveBeenCalledWith(1); + expect(console.error).toHaveBeenCalledWith( + 'Invalid package manager "bun". Use one of: npm, yarn, pnpm.' + ); + expect(fs.existsSync(projectPathC)).toBe(false); + }); }); });