diff --git a/README.md b/README.md index e7617c4..fb923e6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ TypeScript, installed as executables on `PATH`. | [`torrent`](#torrent) | Make a torrent out of a directory, and get it seeded | | [`codeburn`](#codeburn) | See where your AI spend goes, by task, tool, model and project | | [`agenticjobs`](#agenticjobs) | Search, apply, post and hire on an agent-friendly job board | +| [`alchemy`](#alchemy) | Alchemy onchain data from the terminal: balances, gas, transactions, apps, wallets, x402 | | [`jobhunt`](#jobhunt) | Find remote roles worth applying to, and apply through a browser, never twice | | [`openinstall`](#openinstall) | Give a repo an idempotent bin/install.sh: systemd, nginx + TLS, postgres, redis | | [`openmcp`](#openmcp) | The OpenMCP catalog of MCP relays: list, find a tool, call it, register your own | @@ -1814,6 +1815,41 @@ vault. `templates/openinstall/install.sh` is a vendored copy: the canonical one is sh1pt's `packages/targets/deploy-ssh/bin/install.sh`, so change it there first. +### `alchemy` + +[Alchemy](https://www.alchemy.com/)'s CLI, +[@alchemy/cli](https://www.npmjs.com/package/@alchemy/cli): onchain data +(balances, tokens, NFTs, transactions, gas, raw RPC), your Alchemy apps, agent +wallets and x402 payments, across EVM chains and Solana. + +```sh +alchemy auth # link your account in a browser, pick an app +alchemy auth login --device-code # the same on a headless box +alchemy evm data balance vitalik.eth # a balance; ENS names work +alchemy evm gas # current gas +alchemy evm tx # one transaction +alchemy app list # your Alchemy apps +alchemy --json --no-interactive evm gas # for scripts and agents +alchemy --help # it is upstream's CLI: upstream's flags +``` + +Everything is handed through untouched, so +[upstream's docs](https://www.alchemy.com/docs/alchemy-cli) are the docs. Two +flags are ours, spelled `--self-*` because every plain word belongs to them: + +```sh +alchemy --self-update # reinstall the latest release +alchemy --self-where # which copy runs, and from where +``` + +**The first run installs it**, into `~/.local/share/cli-tools/vendor/alchemy` +rather than globally, for the reason [`openmcp`](#openmcp) spells out: +upstream's executable is also called `alchemy`. A global install that is not +this wrapper (`moshcode install alchemy` makes one) is used as it is. +`ALCHEMY_BIN` points at a copy you would rather run, and `ALCHEMY_SPEC` pins a +version. Credentials are upstream's: `alchemy auth`, or `ALCHEMY_API_KEY` in +the environment. + ### `openmcp` The OpenMCP catalog of MCP relays, from the terminal: diff --git a/bin/alchemy.ts b/bin/alchemy.ts new file mode 100755 index 0000000..5461db1 --- /dev/null +++ b/bin/alchemy.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * alchemy — Alchemy's CLI: onchain data, apps, agent wallets and x402. + * + * A launcher for the `@alchemy/cli` package, not a reimplementation of it. + * Everything you type is handed through untouched, so upstream's docs + * (https://www.alchemy.com/docs/alchemy-cli) are the docs: + * + * alchemy auth link your account, pick an app + * alchemy evm data balance vitalik.eth a balance, ENS names welcome + * alchemy evm gas current gas + * alchemy evm tx one transaction + * alchemy app list your Alchemy apps + * alchemy --json --no-interactive … for scripts and agents + * alchemy --help all of it + * + * What this adds is that it is a command rather than a global install to + * remember: installed on first use into a prefix of ours, refreshed when you + * ask. src/alchemy.ts says why it never follows PATH back into itself. + * + * Ours, and therefore NOT passed through: + * --self-update reinstall the latest release + * --self-where say which copy would run, and from where + */ + +import { isMain } from '../src/is-main.ts'; +import { spawnInherit } from '../src/codeburn.ts'; +import { + PACKAGE, + install, + installFailureMessage, + resolveRunner, + vendorBin, + vendorRoot, +} from '../src/alchemy.ts'; + +const DESCRIBE: Record = { + env: 'ALCHEMY_BIN', + vendor: 'installed by cli-tools', + path: 'already on PATH', + missing: 'not installed yet', +}; + +/** Install, and say what happened. Shared by first run and every refresh. */ +async function refresh(reason: string): Promise { + const spec = process.env.ALCHEMY_SPEC || `${PACKAGE}@latest`; + process.stderr.write(`alchemy: ${reason} ${spec}\n`); + + const result = await install(spec); + if (!result.ok) { + process.stderr.write(`${installFailureMessage(vendorRoot())}\n`); + if (result.note) process.stderr.write(` ${result.note}\n`); + return null; + } + + process.stderr.write( + `alchemy: installed ${result.version ?? ''} with ${result.manager}\n`.replace(' ', ' '), + ); + return vendorBin(); +} + +async function main(argv: string[]): Promise { + const runner = resolveRunner(); + + if (argv[0] === '--self-where') { + process.stdout.write( + [`${runner.file ?? '(none)'} ${DESCRIBE[runner.kind]}`, `prefix: ${vendorRoot()}`, ''].join( + '\n', + ), + ); + return 0; + } + + const refreshing = argv[0] === '--self-update'; + const args = refreshing ? argv.slice(1) : argv; + + let file = runner.file; + + if (refreshing || runner.kind === 'missing') { + file = await refresh(refreshing ? 'updating' : 'first run, installing'); + if (file === null) return 1; + + // A bare --self-update is a maintenance run, not a launch. + if (refreshing && args.length === 0) return 0; + } + + if (file === null) { + process.stderr.write(`${installFailureMessage(vendorRoot())}\n`); + return 1; + } + + const code = await spawnInherit(file, args); + if (code === null) { + process.stderr.write( + `alchemy: could not start ${file}\n alchemy --self-update # reinstall it\n`, + ); + return 1; + } + return code; +} + +if (isMain(import.meta.url)) { + process.exit(await main(process.argv.slice(2))); +} diff --git a/package.json b/package.json index 1dcff18..8222fe4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.50.0", + "version": "0.51.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/alchemy.ts b/src/alchemy.ts new file mode 100644 index 0000000..9b060c3 --- /dev/null +++ b/src/alchemy.ts @@ -0,0 +1,192 @@ +/** + * alchemy — Alchemy's CLI for onchain data, apps, wallets and x402, on this box. + * + * The package is `@alchemy/cli` (https://www.alchemy.com/docs/alchemy-cli). + * Nothing here reimplements it; this is the part that makes `alchemy` a + * command on a server like every other one in this repo, installed on first + * use, rather than a global npm install you remember to do. + * + * INSTALLED rather than run through npx, for the same reason as openmcp and + * agenticjobs: a balance or gas lookup is run many times a day and dlx hits + * the registry for metadata before it hands over on every one of them. + * + * Into a PRIVATE PREFIX. The package's executable is called `alchemy`, which + * is also the name of this wrapper, so a global install (`moshcode install + * alchemy` does exactly that) puts a second `alchemy` on PATH. resolveRunner + * accepts that copy when it is not this wrapper, and never follows PATH back + * into ourselves. + * + * Unlike openmcp, no plain word is intercepted: upstream has no installer + * manifest for an npm copy to lack, so everything but the two --self-* flags + * is upstream's. + * + * ALCHEMY_BIN run this executable instead: a checkout, or a global install + * ALCHEMY_SPEC what gets installed, when you want a pinned version + * + * Credentials are upstream's business: `alchemy auth` stores them, and + * ALCHEMY_API_KEY in the environment is read by the package itself. + */ + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { firstOnPath, onPath, resolveCommand } from './registry.ts'; +import { spawnInherit } from './codeburn.ts'; +import { delivered, heldBackNote, installedVersion, wantedVersion } from './vendor-verify.ts'; + +/** The published package, and the executable it installs. */ +export const PACKAGE = '@alchemy/cli'; +export const EXECUTABLE = 'alchemy'; + +/** Where XDG says durable, non-config state goes. */ +export function dataHome(env: NodeJS.ProcessEnv = process.env): string { + return env.XDG_DATA_HOME || join(env.HOME ?? homedir(), '.local', 'share'); +} + +/** The private prefix: a directory whose entire job is to hold one package. */ +export function vendorRoot(env: NodeJS.ProcessEnv = process.env): string { + return join(dataHome(env), 'cli-tools', 'vendor', 'alchemy'); +} + +/** The installed executable, whether or not it exists yet. */ +export function vendorBin(env: NodeJS.ProcessEnv = process.env): string { + return join(vendorRoot(env), 'node_modules', '.bin', EXECUTABLE); +} + +export type PackageManager = 'pnpm' | 'npm'; + +export interface InstallPlan { + file: string; + args: string[]; +} + +/** + * How to install with each manager. + * + * `--ignore-workspace` because pnpm walks up from the install directory + * looking for a workspace root, and ~/.local/share is inside a home directory. + */ +export function installPlan(manager: PackageManager, spec = `${PACKAGE}@latest`): InstallPlan { + if (manager === 'pnpm') { + return { file: 'pnpm', args: ['add', '--ignore-workspace', '--reporter=silent', spec] }; + } + return { file: 'npm', args: ['install', '--no-audit', '--no-fund', '--silent', spec] }; +} + +/** The managers to try, in order. pnpm is the intent, npm is what a bare box has. */ +export function managers(env: NodeJS.ProcessEnv = process.env): PackageManager[] { + return onPath('pnpm', env) ? ['pnpm', 'npm'] : ['npm']; +} + +export type RunnerKind = 'env' | 'vendor' | 'path' | 'missing'; + +export interface Runner { + kind: RunnerKind; + file: string | null; +} + +export interface ResolveDeps { + env?: NodeJS.ProcessEnv; + exists?: (path: string) => boolean; + onPathStatus?: () => 'ours' | 'other' | 'missing'; + onPathTarget?: () => string | null; +} + +/** + * Which Alchemy CLI to run. + * + * This wrapper is on PATH as `alchemy`, the same name as upstream's bin, so + * "is alchemy on PATH" answers yes wherever this command is installed. Only a + * copy that is NOT this wrapper counts, or we would exec ourselves forever. + */ +export function resolveRunner(deps: ResolveDeps = {}): Runner { + const env = deps.env ?? process.env; + const exists = deps.exists ?? existsSync; + const status = deps.onPathStatus ?? (() => resolveCommand(EXECUTABLE, undefined, env).status); + // The PATH entry itself, not its realpath: a global install under mise is a + // shim symlinked to the mise binary, which only runs alchemy when invoked + // by that name. Exec the realpath and you get `mise --json …`. + const target = deps.onPathTarget ?? (() => firstOnPath(EXECUTABLE, env)); + + const override = env.ALCHEMY_BIN; + if (override) return { kind: 'env', file: override }; + + const vendored = vendorBin(env); + if (exists(vendored)) return { kind: 'vendor', file: vendored }; + + if (status() === 'other') return { kind: 'path', file: target() }; + + return { kind: 'missing', file: null }; +} + +/** Give the private prefix the package.json both managers insist on. */ +export function prepareVendorDir(root: string): void { + mkdirSync(root, { recursive: true }); + const manifest = join(root, 'package.json'); + if (existsSync(manifest)) return; + + writeFileSync( + manifest, + `${JSON.stringify( + { + name: 'cli-tools-vendor-alchemy', + version: '0.0.0', + private: true, + description: 'Prefix owned by profullstack/cli-tools. Managed by the alchemy command.', + }, + null, + 2, + )}\n`, + ); +} + +export interface InstallResult { + ok: boolean; + manager?: PackageManager; + code?: number | null; + /** What actually landed, when it could be read. */ + version?: string; + /** Why an install that exited 0 was not accepted. */ + note?: string; +} + +/** + * Install (or refresh) the Alchemy CLI in the private prefix. + * + * Exit 0 is not proof: pnpm's release-age cooldown can install the previous + * version and report success. src/vendor-verify.ts has the reproduction. + */ +export async function install( + spec: string = `${PACKAGE}@latest`, + env: NodeJS.ProcessEnv = process.env, + run: typeof spawnInherit = spawnInherit, +): Promise { + const root = vendorRoot(env); + prepareVendorDir(root); + + // Null means the registry was unreachable; an unverifiable install is + // allowed through so an offline box can reinstall what it already has. + const wanted = await wantedVersion(spec, PACKAGE); + let lastNote: string | undefined; + + for (const manager of managers(env)) { + const plan = installPlan(manager, spec); + const code = await run(plan.file, plan.args, root); + if (code !== 0) continue; + + const got = installedVersion(root, PACKAGE); + if (delivered(got, wanted)) return { ok: true, manager, code, ...(got ? { version: got } : {}) }; + lastNote = heldBackNote(manager, got, wanted); + } + + return { ok: false, ...(lastNote ? { note: lastNote } : {}) }; +} + +/** What to print when neither manager could install it. */ +export function installFailureMessage(root: string): string { + return [ + `alchemy: could not install ${PACKAGE}.`, + ` cd ${root} && npm install ${PACKAGE}@latest # by hand, to see the error`, + ' ALCHEMY_BIN=/path/to/alchemy alchemy # or point at a copy you have', + ].join('\n'); +} diff --git a/src/registry.ts b/src/registry.ts index 87fe5c1..c3bfff9 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -29,6 +29,7 @@ export interface Command { const SUMMARIES: Record = { affiliate: 'Work through a list of programs you mean to sign up for', agenticjobs: 'Search, apply, post and hire on an agent-friendly job board', + alchemy: 'Alchemy onchain data from the terminal: balances, gas, transactions, apps, wallets, x402', argontv: 'The shared IPTV line: is it healthy, and is there room to sell another pass', 'ask-web': 'Answer a question from the live web, with its sources', 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', @@ -108,7 +109,7 @@ export function whichOnPath(name: string, env: NodeJS.ProcessEnv = process.env): } /** The first executable of this name on PATH, or null. */ -function firstOnPath(name: string, env: NodeJS.ProcessEnv): string | null { +export function firstOnPath(name: string, env: NodeJS.ProcessEnv): string | null { for (const dir of (env.PATH ?? '').split(':').filter(Boolean)) { const candidate = join(dir, name); try { diff --git a/test/alchemy.test.ts b/test/alchemy.test.ts new file mode 100644 index 0000000..b3d3607 --- /dev/null +++ b/test/alchemy.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + EXECUTABLE, + PACKAGE, + installFailureMessage, + installPlan, + managers, + prepareVendorDir, + resolveRunner, + vendorBin, + vendorRoot, +} from '../src/alchemy.ts'; + +describe('vendorRoot', () => { + it('follows XDG_DATA_HOME when it is set', () => { + expect(vendorRoot({ XDG_DATA_HOME: '/data' })).toBe('/data/cli-tools/vendor/alchemy'); + }); + + it('falls back to ~/.local/share', () => { + expect(vendorRoot({ HOME: '/home/x' })).toBe('/home/x/.local/share/cli-tools/vendor/alchemy'); + }); +}); + +describe('vendorBin', () => { + it('has the same name as this wrapper, which is the whole hazard', () => { + expect(EXECUTABLE).toBe('alchemy'); + expect(PACKAGE).toBe('@alchemy/cli'); + expect(vendorBin({ XDG_DATA_HOME: '/data' })).toBe( + '/data/cli-tools/vendor/alchemy/node_modules/.bin/alchemy', + ); + }); +}); + +describe('installPlan', () => { + it('keeps pnpm out of a workspace it happens to be standing in', () => { + const plan = installPlan('pnpm'); + expect(plan.file).toBe('pnpm'); + expect(plan.args).toContain('--ignore-workspace'); + expect(plan.args.at(-1)).toBe(`${PACKAGE}@latest`); + }); + + it('installs a pinned spec when given one', () => { + expect(installPlan('npm', `${PACKAGE}@0.24.1`).args.at(-1)).toBe(`${PACKAGE}@0.24.1`); + }); + + it('falls back to npm without pnpm flags', () => { + const plan = installPlan('npm'); + expect(plan.file).toBe('npm'); + expect(plan.args).not.toContain('--ignore-workspace'); + }); +}); + +describe('managers', () => { + it('tries npm alone when pnpm is not on PATH', () => { + expect(managers({ PATH: '/nowhere' })).toEqual(['npm']); + }); +}); + +describe('resolveRunner', () => { + it('lets an explicit override win outright', () => { + const runner = resolveRunner({ env: { ALCHEMY_BIN: '/opt/alchemy' }, exists: () => true }); + expect(runner).toEqual({ kind: 'env', file: '/opt/alchemy' }); + }); + + it('prefers the vendored copy over anything on PATH', () => { + const runner = resolveRunner({ + env: { XDG_DATA_HOME: '/data' }, + exists: (path) => path === '/data/cli-tools/vendor/alchemy/node_modules/.bin/alchemy', + onPathStatus: () => 'other', + onPathTarget: () => '/usr/bin/alchemy', + }); + expect(runner.kind).toBe('vendor'); + }); + + it('uses a global install that is not ours', () => { + // `moshcode install alchemy` runs `npm i -g @alchemy/cli`; that copy is + // a deliberate act and works as well as ours would. + const runner = resolveRunner({ + env: {}, + exists: () => false, + onPathStatus: () => 'other', + onPathTarget: () => '/home/x/.local/share/mise/installs/node/24/bin/alchemy', + }); + expect(runner).toEqual({ + kind: 'path', + file: '/home/x/.local/share/mise/installs/node/24/bin/alchemy', + }); + }); + + it('refuses to follow our own wrapper back to itself', () => { + const runner = resolveRunner({ + env: {}, + exists: () => false, + onPathStatus: () => 'ours', + onPathTarget: () => '/home/x/.local/bin/alchemy', + }); + expect(runner).toEqual({ kind: 'missing', file: null }); + }); + + it('runs a shim by its PATH name, not its realpath', () => { + // mise's shim for a global npm bin is a symlink to the mise binary, which + // only dispatches to alchemy when invoked as `alchemy`. + const root = mkdtempSync(join(tmpdir(), 'alchemy-shim-')); + try { + const manager = join(root, 'mise'); + writeFileSync(manager, '#!/bin/sh\n'); + chmodSync(manager, 0o755); + symlinkSync(manager, join(root, 'alchemy')); + const runner = resolveRunner({ + env: { PATH: root }, + exists: () => false, + onPathStatus: () => 'other', + }); + expect(runner).toEqual({ kind: 'path', file: join(root, 'alchemy') }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('reports missing when there is nothing anywhere', () => { + const runner = resolveRunner({ env: {}, exists: () => false, onPathStatus: () => 'missing' }); + expect(runner.kind).toBe('missing'); + }); +}); + +describe('messages', () => { + it('name the package and the override', () => { + const message = installFailureMessage('/x'); + expect(message).toContain(`npm install ${PACKAGE}@latest`); + expect(message).toContain('ALCHEMY_BIN='); + }); +}); + +describe('prepareVendorDir', () => { + it('writes the manifest both package managers insist on', () => { + const root = mkdtempSync(join(tmpdir(), 'alchemy-vendor-')); + try { + const dir = join(root, 'vendor'); + prepareVendorDir(dir); + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + expect(manifest.private).toBe(true); + expect(manifest.name).toBe('cli-tools-vendor-alchemy'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('leaves an existing manifest alone', () => { + const root = mkdtempSync(join(tmpdir(), 'alchemy-vendor-')); + try { + writeFileSync(join(root, 'package.json'), '{"name":"kept"}\n'); + prepareVendorDir(root); + expect(readFileSync(join(root, 'package.json'), 'utf8')).toBe('{"name":"kept"}\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});