Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 <hash> # 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:
Expand Down
104 changes: 104 additions & 0 deletions bin/alchemy.ts
Original file line number Diff line number Diff line change
@@ -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 <hash> 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<string, string> = {
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<string | null> {
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<number> {
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)));
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
192 changes: 192 additions & 0 deletions src/alchemy.ts
Original file line number Diff line number Diff line change
@@ -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<InstallResult> {
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');
}
3 changes: 2 additions & 1 deletion src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface Command {
const SUMMARIES: Record<string, string> = {
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',
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading